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..1a44a9c --- /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..4d173d9 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/ui/Event.kt b/app/src/main/java/place/pic/android/plus/ui/Event.kt new file mode 100644 index 0000000..cc11604 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/Event.kt @@ -0,0 +1,29 @@ +package place.pic.android.plus.ui + +/** + * Created By kimdahyee + * on 11월 23일, 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/ui/SingleLiveEvent.kt b/app/src/main/java/place/pic/android/plus/ui/SingleLiveEvent.kt new file mode 100644 index 0000000..074a418 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/SingleLiveEvent.kt @@ -0,0 +1,54 @@ +package place.pic.android.plus.ui + +import android.util.Log +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Created By kimdahyee + * on 11월 23일, 2020 + */ + +class SingleLiveEvent : MutableLiveData() { + + // AtomicBoolean - multiThread 환경에서 동시성 보장 + private val pending = AtomicBoolean(false) + + // view가 활성화 상태가 되거나 setValue를 통해 값이 바뀌었을 때 호출되는 함수 + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") + } + // Observe the internal MutableLiveData + super.observe( + owner, + { t -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + } + ) + } + + @MainThread + override fun setValue(t: T?) { + pending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } + + companion object { + private const val TAG = "SingleLiveEvent" + } +} diff --git a/app/src/main/java/place/pic/android/plus/ui/adapter/BindingAdapter.kt b/app/src/main/java/place/pic/android/plus/ui/adapter/BindingAdapter.kt new file mode 100644 index 0000000..dea7115 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/adapter/BindingAdapter.kt @@ -0,0 +1,26 @@ +package place.pic.android.plus.ui.adapter + +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide + +/** + * Created By kimdahyee + * on 11월 17일, 2020 + */ + +class BindingAdapter { + companion object { + @JvmStatic + @BindingAdapter("imageUrl") + fun loadImage(imageView: ImageView, url: String) { + Glide.with(imageView.context).load(url).into(imageView) + } + + @JvmStatic + @BindingAdapter("detailImageUrl") + fun loadDetailImage(imageView: ImageView, url: String?) { + Glide.with(imageView.context).load(url).into(imageView) + } + } +} diff --git a/app/src/main/java/place/pic/android/plus/ui/adapter/UserSearchAdapter.kt b/app/src/main/java/place/pic/android/plus/ui/adapter/UserSearchAdapter.kt new file mode 100644 index 0000000..1b415a3 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/adapter/UserSearchAdapter.kt @@ -0,0 +1,56 @@ +package place.pic.android.plus.ui.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 var data: MutableList = mutableListOf() + 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]) + } + + override fun getItemCount(): Int { + return data.size + } + + inner class UserViewHolder( + private val binding: ItemUserSearchBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(user: User) { + binding.user = user + binding.clickListener = createUserItemClickListener(user) + } + } + + fun setItem(users: List) { + data = users as MutableList + 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/MainActivity.kt b/app/src/main/java/place/pic/android/plus/ui/view/MainActivity.kt similarity index 79% rename from app/src/main/java/place/pic/android/plus/MainActivity.kt rename to app/src/main/java/place/pic/android/plus/ui/view/MainActivity.kt index b8670ea..ce7e4a5 100644 --- a/app/src/main/java/place/pic/android/plus/MainActivity.kt +++ b/app/src/main/java/place/pic/android/plus/ui/view/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.view + +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/view/UserDetailActivity.kt b/app/src/main/java/place/pic/android/plus/ui/view/UserDetailActivity.kt new file mode 100644 index 0000000..93bc42e --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/view/UserDetailActivity.kt @@ -0,0 +1,28 @@ +package place.pic.android.plus.ui.view + +import android.os.Bundle +import android.util.Log +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import place.pic.android.plus.databinding.ActivityUserDatailBinding +import place.pic.android.plus.ui.viewmodel.UserDetailViewModel + +class UserDetailActivity : AppCompatActivity() { + + private val userDetailViewModel: UserDetailViewModel by viewModels() + private var username: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + username = intent.getStringExtra("username").toString() + Log.d("디테일액티비티", username) + init() + } + + private fun init() { + val binding = ActivityUserDatailBinding.inflate(layoutInflater) + binding.viewModel = userDetailViewModel + userDetailViewModel.requestUserDetail(username) + setContentView(binding.root) + } +} diff --git a/app/src/main/java/place/pic/android/plus/ui/view/UserSearchActivity.kt b/app/src/main/java/place/pic/android/plus/ui/view/UserSearchActivity.kt new file mode 100644 index 0000000..5fe02f8 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/view/UserSearchActivity.kt @@ -0,0 +1,72 @@ +package place.pic.android.plus.ui.view + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +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.adapter.UserSearchAdapter +import place.pic.android.plus.ui.viewmodel.UserSearchViewModel + +class UserSearchActivity : AppCompatActivity() { + + private val userSearchViewModel: UserSearchViewModel by viewModels() + private val userSearchAdapter by lazy { UserSearchAdapter() } + private lateinit var binding: ActivitySearchBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_search) + binding.rcvUserSearch.adapter = userSearchAdapter + binding.viewModel = userSearchViewModel + binding.lifecycleOwner = this + + searchUser() + userItemClick() + } + + private fun searchUser() { + binding.btUserSearch.setOnClickListener { onSearchClick() } + userSearchViewModel.users.observe(this) { + userSearchAdapter.setItem(it) + } + } + + private fun userItemClick() { + userSearchAdapter.setItemClickListener { onUserItemClick(it) } + } + + private fun onUserItemClick(user: User) { + val intent = Intent(this, UserDetailActivity::class.java) + Log.d("온유저아이템클릭", user.name) + intent.putExtra("username", user.name) + startActivity(intent) + } + + private fun onSearchClick() { + var input = findViewById(R.id.et_user_search) + if (input == null) { + Toast.makeText(this, "검색어를 입력하세요.", Toast.LENGTH_SHORT).show() + } else { + input.run { + userSearchViewModel.requestUserSearch(input.text.toString()) + hideKeyboard() + } + } + } + + 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/viewmodel/UserDetailViewModel.kt b/app/src/main/java/place/pic/android/plus/ui/viewmodel/UserDetailViewModel.kt new file mode 100644 index 0000000..3746daa --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/viewmodel/UserDetailViewModel.kt @@ -0,0 +1,55 @@ +package place.pic.android.plus.ui.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 lateinit var user: UserDetail + private val _userDetail = MutableLiveData() + val userDetail: LiveData + get() = _userDetail + + fun setUserDetail() { + _userDetail.value = user + Log.d("디테일세터확인", _userDetail.value!!.name) + } + + fun requestUserDetail(login: String) { + GithubApiServiceImpl.service.requestUserDetail( + token = token, + login = login + ).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + user = UserDetail( + imageUrl = response.body()?.imageUrl, + name = response.body()!!.name, + bio = response.body()?.bio, + followers = response.body()!!.followers, + htmlUrl = response.body()!!.htmlUrl + ) + } + setUserDetail() + } + + 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/viewmodel/UserSearchViewModel.kt b/app/src/main/java/place/pic/android/plus/ui/viewmodel/UserSearchViewModel.kt new file mode 100644 index 0000000..d0d0e5c --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/viewmodel/UserSearchViewModel.kt @@ -0,0 +1,71 @@ +package place.pic.android.plus.ui.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.ui.Event +import place.pic.android.plus.ui.SingleLiveEvent +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +/** + * Created By kimdahyee + * on 11월 02일, 2020 + */ + +class UserSearchViewModel : ViewModel() { + + private val list = mutableListOf() + + private val _users = MutableLiveData>() + val users: LiveData> + get() = _users + + fun addUser(user: User) { + list.add(user) + _users.value = list + } + + private val _searchItemClickEvent = SingleLiveEvent>() + val searchItemClickEvent: SingleLiveEvent> + get() = _searchItemClickEvent + + fun setSearchItemClickEvent() { // itemId: String + _searchItemClickEvent.call() + // _searchItemClickEvent.value = Event(itemId) + } + + fun requestUserSearch(query: String) { + GithubApiServiceImpl.service.requestUserSearch( + param = query + ).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + if (response.isSuccessful) { + Log.d("server check", "통신 성공") + list.clear() + for (i in response.body()!!.items.indices) { + addUser( + User( + imageUrl = response.body()!!.items[i].avatar_url, + name = response.body()!!.items[i].login, + id = response.body()!!.items[i].id + ) + ) + } + } + } + + override fun onFailure(call: Call, t: Throwable) { + t.message?.let { Log.d("fail", it) } + } + }) + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ca0c0de..19e79c9 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.view.MainActivity"> + + + + + + + + + + +