diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 148fdd2..bb44937 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2a4bbd8..40e128f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,4 +45,16 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + implementation(libs.androidx.fragment.ktx) + implementation (libs.retrofit) + implementation (libs.converter.gson) + implementation (libs.androidx.lifecycle.viewmodel.ktx) + implementation (libs.androidx.lifecycle.livedata.ktx) + implementation (libs.androidx.recyclerview) + implementation(libs.kotlin.stdlib) +} +android { + buildFeatures { + dataBinding = true + } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 398b755..0a6d179 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/GitHubApiService.kt b/app/src/main/java/com/example/android_25_2/GitHubApiService.kt new file mode 100644 index 0000000..78c9493 --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/GitHubApiService.kt @@ -0,0 +1,36 @@ +package com.example.android_25_2 + +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.create + +interface GitHubApiService { + + @GET("search/users") + suspend fun searchUsers( + @Query("q") query: String, + @Query("page") page: Int, + @Query("per_page") perPage: Int + ): UserSearchResponse + + @GET("users/{username}/repos") + suspend fun getUserRepositories( + @Path("username") username: String, + @Query("page") page: Int, + @Query("per_page") perPage: Int + ): List + + companion object { + fun create(): GitHubApiService { + val retrofit = Retrofit.Builder() + .baseUrl("https://api.github.com/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + + return retrofit.create() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/MainActivity.kt b/app/src/main/java/com/example/android_25_2/MainActivity.kt index aed359b..f55d2c0 100644 --- a/app/src/main/java/com/example/android_25_2/MainActivity.kt +++ b/app/src/main/java/com/example/android_25_2/MainActivity.kt @@ -1,20 +1,84 @@ package com.example.android_25_2 import android.os.Bundle -import androidx.activity.enableEdgeToEdge +import android.content.Intent +import android.util.Log +import androidx.lifecycle.ViewModelProvider import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.android_25_2.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var viewModel: UserSearchViewModel + private lateinit var adapter: UserAdapter + companion object { + const val user_name = "username" + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + Log.d("MainActivity", "πŸš€ onCreate μ‹œμž‘") + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) insets } + + viewModel = ViewModelProvider(this)[UserSearchViewModel::class.java] + + setupRecyclerView() + setupSearchView() + observeViewModel() + Log.d("MainActivity", "βœ… onCreate μ™„λ£Œ") + } + + private fun setupRecyclerView() { + Log.d("MainActivity", "πŸ“¦ RecyclerView μ„€μ •") + adapter = UserAdapter { username -> + val intent = Intent(this, RepositoryActivity::class.java) + intent.putExtra(user_name, username) + startActivity(intent) + } + binding.recyclerView.adapter = adapter + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val lastVisible = layoutManager.findLastVisibleItemPosition() + val totalCount = layoutManager.itemCount + + if (lastVisible >= totalCount - 5) { + viewModel.loadMore() + } + } + }) + } + + private fun setupSearchView() { + Log.d("MainActivity", "πŸ” SearchView μ„€μ •") + binding.editSearch.addTextChangedListener { text -> + val query = text.toString() + Log.d("MainActivity", "⌨️ ν…μŠ€νŠΈ λ³€κ²½: '$query'") + viewModel.searchUsers(query) + } + Log.d("MainActivity", "βœ… SearchView λ¦¬μŠ€λ„ˆ 등둝 μ™„λ£Œ") + } + + private fun observeViewModel() { + Log.d("MainActivity", "πŸ‘€ ViewModel κ΄€μ°° μ‹œμž‘") + viewModel.users.observe(this) { users -> + Log.d("MainActivity", "πŸ“± μ‚¬μš©μž λͺ©λ‘ μ—…λ°μ΄νŠΈ: ${users.size}개") + adapter.submitList(users) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/Repository.kt b/app/src/main/java/com/example/android_25_2/Repository.kt new file mode 100644 index 0000000..ad4db50 --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/Repository.kt @@ -0,0 +1,8 @@ +package com.example.android_25_2 + +data class Repository( + val id: Long, + val full_name: String, + val description: String?, + val html_url: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/RepositoryActivity.kt b/app/src/main/java/com/example/android_25_2/RepositoryActivity.kt new file mode 100644 index 0000000..c5c6071 --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/RepositoryActivity.kt @@ -0,0 +1,64 @@ +package com.example.android_25_2 + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.android_25_2.MainActivity.Companion.user_name +import com.example.android_25_2.databinding.ActivityRepositoryBinding + +class RepositoryActivity : AppCompatActivity() { + + private lateinit var binding: ActivityRepositoryBinding + private lateinit var viewModel: RepositoryViewModel + private lateinit var adapter: RepositoryAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityRepositoryBinding.inflate(layoutInflater) + setContentView(binding.root) + viewModel = ViewModelProvider(this)[RepositoryViewModel::class.java] + val username = intent.getStringExtra(user_name) ?: "" + + title = "$username's Repositories" + + setupRecyclerView() + observeViewModel() + + viewModel.loadRepositories(username) + } + + private fun setupRecyclerView() { + adapter = RepositoryAdapter { url -> + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + binding.recyclerView.adapter = adapter + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val lastVisible = layoutManager.findLastVisibleItemPosition() + val totalCount = layoutManager.itemCount + + if (lastVisible >= totalCount - 5) { + viewModel.loadMore() + } + } + }) + } + + private fun observeViewModel() { + viewModel.repositories.observe(this) { repositories -> + adapter.submitList(repositories) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/RepositoryAdapter.kt b/app/src/main/java/com/example/android_25_2/RepositoryAdapter.kt new file mode 100644 index 0000000..7be8f56 --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/RepositoryAdapter.kt @@ -0,0 +1,32 @@ +package com.example.android_25_2 + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.example.android_25_2.databinding.ItemRepositoryBinding + +class RepositoryAdapter( + private val onItemClick: (String) -> Unit +) : ListAdapter(RepositoryDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RepositoryViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemRepositoryBinding.inflate(inflater, parent, false) + return RepositoryViewHolder(binding, onItemClick) + } + + override fun onBindViewHolder(holder: RepositoryViewHolder, position: Int) { + val repository = getItem(position) + holder.bind(repository) + } +} + +class RepositoryDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Repository, newItem: Repository): Boolean { + return oldItem.id == newItem.id + } + override fun areContentsTheSame(oldItem: Repository, newItem: Repository): Boolean { + return oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/RepositoryViewHolder.kt b/app/src/main/java/com/example/android_25_2/RepositoryViewHolder.kt new file mode 100644 index 0000000..4a9bf71 --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/RepositoryViewHolder.kt @@ -0,0 +1,18 @@ +package com.example.android_25_2 + +import androidx.recyclerview.widget.RecyclerView +import com.example.android_25_2.databinding.ItemRepositoryBinding + +class RepositoryViewHolder( + private val binding: ItemRepositoryBinding, + private val onItemClick: (String) -> Unit +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(repository: Repository) { + binding.repository = repository + binding.executePendingBindings() + binding.root.setOnClickListener { + onItemClick(repository.html_url) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/RepositoryViewModel.kt b/app/src/main/java/com/example/android_25_2/RepositoryViewModel.kt new file mode 100644 index 0000000..fa9cbbb --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/RepositoryViewModel.kt @@ -0,0 +1,50 @@ +package com.example.android_25_2 + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +class RepositoryViewModel : ViewModel() { + + private val apiService = GitHubApiService.create() + private val _repositories = MutableLiveData>() + val repositories: LiveData> = _repositories + private var currentPage = 1 + private var currentUsername = "" + + fun loadRepositories(username: String) { + currentUsername = username + currentPage = 1 + + viewModelScope.launch { + try { + val repos = apiService.getUserRepositories(username, currentPage, 30) + _repositories.value = repos + } catch (e: Exception) { + _repositories.value = emptyList() + } + } + } + + fun loadMore() { + if (currentUsername.isEmpty()) { + return + } + + viewModelScope.launch { + try { + currentPage += 1 + val repos = apiService.getUserRepositories(currentUsername, currentPage, 30) + val currentList = _repositories.value ?: emptyList() + val newList = currentList + repos + + _repositories.value = newList + } catch (e: Exception) { + currentPage -= 1 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/User.kt b/app/src/main/java/com/example/android_25_2/User.kt new file mode 100644 index 0000000..288a2bb --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/User.kt @@ -0,0 +1,12 @@ +package com.example.android_25_2 + +data class User( + val id: Long, + val login: String, + val html_url: String +) + +data class UserSearchResponse( + val total_count: Int, + val items: List +) \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/UserAdapter.kt b/app/src/main/java/com/example/android_25_2/UserAdapter.kt new file mode 100644 index 0000000..5c73f22 --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/UserAdapter.kt @@ -0,0 +1,32 @@ +package com.example.android_25_2 + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.example.android_25_2.databinding.ItemUserBinding + +class UserAdapter( + private val onItemClick: (String) -> Unit +) : ListAdapter(UserDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemUserBinding.inflate(inflater, parent, false) + return UserViewHolder(binding, onItemClick) + } + + override fun onBindViewHolder(holder: UserViewHolder, position: Int) { + val user = getItem(position) + holder.bind(user) + } +} + +class UserDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: User, newItem: User): Boolean { + return oldItem.id == newItem.id + } + override fun areContentsTheSame(oldItem: User, newItem: User): Boolean { + return oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/UserSearchViewModel.kt b/app/src/main/java/com/example/android_25_2/UserSearchViewModel.kt new file mode 100644 index 0000000..1912202 --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/UserSearchViewModel.kt @@ -0,0 +1,66 @@ +package com.example.android_25_2 + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class UserSearchViewModel : ViewModel() { + + private val apiService = GitHubApiService.create() + private val _users = MutableLiveData>() + val users: LiveData> = _users + private var searchJob: Job? = null + private var currentPage = 1 + private var currentQuery = "" + + fun searchUsers(query: String) { + Log.d("UserSearchViewModel", "πŸ” searchUsers 호좜: '$query'") + if (query.isEmpty()) { + Log.d("UserSearchViewModel", "❌ 검색어 λΉ„μ–΄μžˆμŒ") + _users.value = emptyList() + return + } + searchJob = viewModelScope.launch { + Log.d("UserSearchViewModel", "⏰ 300ms λŒ€κΈ° μ‹œμž‘") + delay(300) + + currentQuery = query + currentPage = 1 + + try { + Log.d("UserSearchViewModel", "🌐 API 호좜 μ‹œμž‘: $query") + val response = apiService.searchUsers(query, currentPage, 30) + Log.d("UserSearchViewModel", "βœ… κ²°κ³Ό λ°›μŒ: ${response.items.size}개") + _users.value = response.items + } catch (e: Exception) { + Log.e("UserSearchViewModel", "❌ μ—λŸ¬ λ°œμƒ: ${e.message}", e) + _users.value = emptyList() + } + } + } + + fun loadMore() { + if (currentQuery.isEmpty()) { + return + } + + viewModelScope.launch { + try { + currentPage += 1 + + val response = apiService.searchUsers(currentQuery, currentPage, 30) + val currentList = _users.value ?: emptyList() + val newList = currentList + response.items + + _users.value = newList + } catch (e: Exception) { + currentPage -= 1 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_25_2/UserViewHolder.kt b/app/src/main/java/com/example/android_25_2/UserViewHolder.kt new file mode 100644 index 0000000..87497be --- /dev/null +++ b/app/src/main/java/com/example/android_25_2/UserViewHolder.kt @@ -0,0 +1,18 @@ +package com.example.android_25_2 + +import androidx.recyclerview.widget.RecyclerView +import com.example.android_25_2.databinding.ItemUserBinding + +class UserViewHolder( + private val binding: ItemUserBinding, + private val onItemClick: (String) -> Unit +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(user: User) { + binding.user = user + binding.executePendingBindings() + binding.root.setOnClickListener { + onItemClick(user.login) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 9affce0..017b8fa 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,10 +1,34 @@ - + xmlns:tools="http://schemas.android.com/tools"> - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_repository.xml b/app/src/main/res/layout/activity_repository.xml new file mode 100644 index 0000000..b58a9ce --- /dev/null +++ b/app/src/main/res/layout/activity_repository.xml @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_repository.xml b/app/src/main/res/layout/item_repository.xml new file mode 100644 index 0000000..a757636 --- /dev/null +++ b/app/src/main/res/layout/item_repository.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_user.xml b/app/src/main/res/layout/item_user.xml new file mode 100644 index 0000000..9a3d201 --- /dev/null +++ b/app/src/main/res/layout/item_user.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..fb36573 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,7 @@ #FF018786 #FF000000 #FFFFFFFF + #FACC00 + #F99E14 + #F5F5F5 \ 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 13379b4..306d33b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,8 @@ Android_25-2 + search… + Delet + Sure? + Yes + No \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8162ca2..adf4592 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,37 @@ [versions] -agp = "8.9.1" -kotlin = "1.9.24" +agp = "8.9.3" +converterGson = "2.9.0" +fragmentKtx = "1.8.9" +kotlin = "2.1.0" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" appcompat = "1.7.1" -material = "1.12.0" -activity = "1.11.0" +kotlinStdlib = "2.2.0" +lifecycleViewmodelKtx = "2.10.0" +material = "1.13.0" +activity = "1.12.2" constraintlayout = "2.2.1" +fragment_version = "1.8.9" +recyclerview = "1.4.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleViewmodelKtx" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlinStdlib" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "converterGson" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }