diff --git a/README.md b/README.md index df2a6477..a6d1f031 100644 --- a/README.md +++ b/README.md @@ -1 +1,16 @@ # android-map-keyword +## 카카오맵 클론 코딩을 위한 시작 + +### step 1 +- 검색어 입력 및 검색 결과를 표시할 기본 레이아웃을 구현한다. +- 검색에 사용될 데이터를 로컬 데이터베이스에 생성한다. + +### step 2 +- 검색어를 입력하면 검색 결과 목록이 표시된다. +- 검색 결과 목록은 세로 스크롤이 된다. +- 입력한 검색어는 X를 눌러서 삭제할 수 있다. +- 검색 결과 목록에서 하나의 항목을 선택할 수 있다. +- 선택된 항목은 검색어 저장 목록에 추가된다. +- 저장된 검색어 목록은 가로 스크롤이 된다. +- 저장된 검색어는 X를 눌러서 삭제할 수 있다. +- 저장된 검색어는 앱을 재실행하여도 유지된다 \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9932d6bb..4e64009a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,17 +36,23 @@ android { buildFeatures { viewBinding = true + dataBinding = true } } dependencies { - implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.datastore:datastore-preferences:1.0.0") + + // 추가된 종속성 + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + implementation("androidx.activity:activity-ktx:1.7.2") + testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6bca2f54..e8ab6793 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ android:theme="@style/Theme.Map" tools:targetApi="31"> diff --git a/app/src/main/java/campus/tech/kakao/map/MainActivity.kt b/app/src/main/java/campus/tech/kakao/map/MainActivity.kt deleted file mode 100644 index 95b43803..00000000 --- a/app/src/main/java/campus/tech/kakao/map/MainActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package campus.tech.kakao.map - -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) - } -} diff --git a/app/src/main/java/campus/tech/kakao/map/database/HistoryContract.kt b/app/src/main/java/campus/tech/kakao/map/database/HistoryContract.kt new file mode 100644 index 00000000..da18961a --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/database/HistoryContract.kt @@ -0,0 +1,6 @@ +package campus.tech.kakao.map.database + +object HistoryContract { + const val TABLE_NAME = "History" + const val COLUMN_NAME = "name" +} diff --git a/app/src/main/java/campus/tech/kakao/map/database/HistoryDBHelper.kt b/app/src/main/java/campus/tech/kakao/map/database/HistoryDBHelper.kt new file mode 100644 index 00000000..76f58636 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/database/HistoryDBHelper.kt @@ -0,0 +1,24 @@ +package campus.tech.kakao.map.database + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class HistoryDBHelper( + context: Context?, + name: String?, + factory: SQLiteDatabase.CursorFactory?, + version: Int +) : SQLiteOpenHelper(context, name, factory, version) { + + override fun onCreate(db: SQLiteDatabase?) { + val createTableQuery = "CREATE TABLE ${HistoryContract.TABLE_NAME} (" + + "${HistoryContract.COLUMN_NAME} TEXT)" + db?.execSQL(createTableQuery) + } + + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + db?.execSQL("DROP TABLE IF EXISTS ${HistoryContract.TABLE_NAME}") + onCreate(db) + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/database/PlaceContract.kt b/app/src/main/java/campus/tech/kakao/map/database/PlaceContract.kt new file mode 100644 index 00000000..4102fbf1 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/database/PlaceContract.kt @@ -0,0 +1,8 @@ +package campus.tech.kakao.map.database + +object PlaceContract { + const val TABLE_NAME = "places" + const val COLUMN_CATEGORY = "category" + const val COLUMN_TITLE = "title" + const val COLUMN_LOCATION = "location" +} diff --git a/app/src/main/java/campus/tech/kakao/map/database/PlaceDBHelper.kt b/app/src/main/java/campus/tech/kakao/map/database/PlaceDBHelper.kt new file mode 100644 index 00000000..49e7f9bb --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/database/PlaceDBHelper.kt @@ -0,0 +1,64 @@ +package campus.tech.kakao.map.database + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class PlaceDBHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { + + override fun onCreate(db: SQLiteDatabase?) { + db?.execSQL(SQL_CREATE_TABLE) + insertInitialRecords(db) + } + + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + db?.execSQL(SQL_DROP_TABLE) + onCreate(db) + } + + private fun insertInitialRecords(db: SQLiteDatabase?) { + db?.let { + insertCafeRecords(it) + insertPharmacyRecords(it) + } + } + + private fun insertCafeRecords(db: SQLiteDatabase) { + val category = "카페" + val titleBase = "카페" + val locationBase = "서울 성동구 성수동" + + for (i in 1..20) { + val title = "$titleBase$i" + val location = "$locationBase $i" + db.execSQL( + "INSERT INTO ${PlaceContract.TABLE_NAME} (${PlaceContract.COLUMN_CATEGORY}, ${PlaceContract.COLUMN_TITLE}, ${PlaceContract.COLUMN_LOCATION}) VALUES ('$category', '$title', '$location');" + ) + } + } + + private fun insertPharmacyRecords(db: SQLiteDatabase) { + val category = "약국" + val titleBase = "약국" + val locationBase = "서울 강남구 대치동" + + for (i in 1..20) { + val title = "$titleBase$i" + val location = "$locationBase $i" + db.execSQL( + "INSERT INTO ${PlaceContract.TABLE_NAME} (${PlaceContract.COLUMN_CATEGORY}, ${PlaceContract.COLUMN_TITLE}, ${PlaceContract.COLUMN_LOCATION}) VALUES ('$category', '$title', '$location');" + ) + } + } + + companion object { + private const val DB_VERSION = 1 + private const val DB_NAME = "location.db" + private const val SQL_CREATE_TABLE = + "CREATE TABLE ${PlaceContract.TABLE_NAME} (" + + "${PlaceContract.COLUMN_CATEGORY} TEXT NOT NULL, " + + "${PlaceContract.COLUMN_TITLE} TEXT NOT NULL, " + + "${PlaceContract.COLUMN_LOCATION} TEXT NOT NULL);" + private const val SQL_DROP_TABLE = "DROP TABLE IF EXISTS ${PlaceContract.TABLE_NAME}" + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/model/HistoryItem.kt b/app/src/main/java/campus/tech/kakao/map/model/HistoryItem.kt new file mode 100644 index 00000000..c965d97d --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/model/HistoryItem.kt @@ -0,0 +1,3 @@ +package campus.tech.kakao.map.model + +data class HistoryItem(val name: String) diff --git a/app/src/main/java/campus/tech/kakao/map/model/PlaceItem.kt b/app/src/main/java/campus/tech/kakao/map/model/PlaceItem.kt new file mode 100644 index 00000000..b9ec919d --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/model/PlaceItem.kt @@ -0,0 +1,3 @@ +package campus.tech.kakao.map.model + +data class PlaceItem(val name: String, val address: String, val category: String) diff --git a/app/src/main/java/campus/tech/kakao/map/repository/PlaceRepository.kt b/app/src/main/java/campus/tech/kakao/map/repository/PlaceRepository.kt new file mode 100644 index 00000000..c2d1e167 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/repository/PlaceRepository.kt @@ -0,0 +1,96 @@ +package campus.tech.kakao.map.repository + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import campus.tech.kakao.map.database.HistoryContract +import campus.tech.kakao.map.database.HistoryDBHelper +import campus.tech.kakao.map.database.PlaceContract +import campus.tech.kakao.map.database.PlaceDBHelper +import campus.tech.kakao.map.model.HistoryItem +import campus.tech.kakao.map.model.PlaceItem + +class PlaceRepository(context: Context) { + private val placeDBHelper: PlaceDBHelper = PlaceDBHelper(context) + private val historyDBHelper: HistoryDBHelper = HistoryDBHelper(context, "history.db", null, 2) + + private val _places = MutableLiveData>() + val places: LiveData> get() = _places + + private val _history = MutableLiveData>() + val history: LiveData> get() = _history + + fun searchPlaces(keyword: String) { + val selection = "${PlaceContract.COLUMN_CATEGORY} LIKE ?" + val selectionArgs = arrayOf("%$keyword%") + val db = placeDBHelper.readableDatabase + val cursor = db.query( + PlaceContract.TABLE_NAME, + null, + selection, + selectionArgs, + null, + null, + null + ) + _places.value = cursorToPlaceList(cursor) + } + + fun addHistory(name: String) { + val db = historyDBHelper.writableDatabase + val values = ContentValues() + values.put(HistoryContract.COLUMN_NAME, name) + db.insert(HistoryContract.TABLE_NAME, null, values) + loadAllHistory() + } + + fun removeHistory(name: String) { + val db = historyDBHelper.writableDatabase + val selection = "${HistoryContract.COLUMN_NAME} = ?" + val selectionArgs = arrayOf(name) + db.delete(HistoryContract.TABLE_NAME, selection, selectionArgs) + loadAllHistory() + } + + fun loadAllHistory() { + val db = historyDBHelper.readableDatabase + val cursor = db.query( + HistoryContract.TABLE_NAME, + null, + null, + null, + null, + null, + null + ) + _history.value = cursorToHistoryList(cursor) + } + + private fun cursorToPlaceList(cursor: Cursor?): List { + val list = mutableListOf() + if (cursor != null) { + while (cursor.moveToNext()) { + val name = cursor.getString(cursor.getColumnIndexOrThrow(PlaceContract.COLUMN_TITLE)) + val address = cursor.getString(cursor.getColumnIndexOrThrow(PlaceContract.COLUMN_LOCATION)) + val category = cursor.getString(cursor.getColumnIndexOrThrow(PlaceContract.COLUMN_CATEGORY)) + list.add(PlaceItem(name, address, category)) + } + cursor.close() + } + return list + } + + private fun cursorToHistoryList(cursor: Cursor?): List { + val list = mutableListOf() + if (cursor != null) { + while (cursor.moveToNext()) { + val name = cursor.getString(cursor.getColumnIndexOrThrow(HistoryContract.COLUMN_NAME)) + list.add(HistoryItem(name)) + } + cursor.close() + } + return list + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/ui/MainActivity.kt b/app/src/main/java/campus/tech/kakao/map/ui/MainActivity.kt new file mode 100644 index 00000000..71293fb4 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/ui/MainActivity.kt @@ -0,0 +1,95 @@ +package campus.tech.kakao.map.ui + +import android.os.Bundle +import android.view.View +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import campus.tech.kakao.map.databinding.ActivityMainBinding +import campus.tech.kakao.map.ui.adapter.HistoryAdapter +import campus.tech.kakao.map.ui.adapter.PlaceAdapter +import campus.tech.kakao.map.utils.clearTextWatcher +import campus.tech.kakao.map.utils.doOnTextChanged +import campus.tech.kakao.map.viewmodel.PlaceViewModel + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private val viewModel: PlaceViewModel by viewModels() + private lateinit var placeAdapter: PlaceAdapter + private lateinit var historyAdapter: HistoryAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupListeners() + setupView() + observeViewModel() + } + + private fun setupListeners() { + binding.btnDelete.setOnClickListener { + binding.etSearch.setText(null) + } + + binding.etSearch.doOnTextChanged { text -> + handleSearchTextChanged(text.toString()) + } + } + + private fun handleSearchTextChanged(searchText: String) { + if (searchText.trim().isEmpty()) { + binding.tvNoResults.visibility = View.VISIBLE + binding.recyclerViewPlaces.visibility = View.INVISIBLE + } else { + binding.tvNoResults.visibility = View.INVISIBLE + binding.recyclerViewPlaces.visibility = View.VISIBLE + viewModel.searchPlaces(searchText) + } + } + + private fun setupView() { + placeAdapter = PlaceAdapter { name -> + viewModel.addHistory(name) + } + + historyAdapter = HistoryAdapter { name -> + viewModel.removeHistory(name) + } + + binding.recyclerViewPlaces.apply { + adapter = placeAdapter + layoutManager = LinearLayoutManager(this@MainActivity) + } + + binding.recyclerViewHistory.apply { + adapter = historyAdapter + layoutManager = LinearLayoutManager(this@MainActivity, LinearLayoutManager.HORIZONTAL, false) + } + } + + private fun observeViewModel() { + observePlaces() + observeHistory() + } + + private fun observePlaces() { + viewModel.places.observe(this, Observer { places -> + placeAdapter.submitList(places) + }) + } + + private fun observeHistory() { + viewModel.history.observe(this, Observer { history -> + historyAdapter.submitList(history) + }) + } + + override fun onDestroy() { + super.onDestroy() + binding.etSearch.clearTextWatcher() + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/ui/adapter/HistoryAdapter.kt b/app/src/main/java/campus/tech/kakao/map/ui/adapter/HistoryAdapter.kt new file mode 100644 index 00000000..cb1c11e2 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/ui/adapter/HistoryAdapter.kt @@ -0,0 +1,48 @@ +package campus.tech.kakao.map.ui.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.databinding.ItemHistoryBinding +import campus.tech.kakao.map.model.HistoryItem + +class HistoryAdapter(private val onDelete: (String) -> Unit) : ListAdapter( + HistoryDiffCallback() +) { + + inner class ViewHolder(private val binding: ItemHistoryBinding) : RecyclerView.ViewHolder(binding.root) { + init { + binding.ibDelete.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onDelete(getItem(position).name) + } + } + } + + fun bind(historyItem: HistoryItem) { + binding.tvName.text = historyItem.name + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class HistoryDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean { + return oldItem.name == newItem.name + } + + override fun areContentsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/ui/adapter/PlaceAdapter.kt b/app/src/main/java/campus/tech/kakao/map/ui/adapter/PlaceAdapter.kt new file mode 100644 index 00000000..d4b03c8f --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/ui/adapter/PlaceAdapter.kt @@ -0,0 +1,50 @@ +package campus.tech.kakao.map.ui.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.databinding.ItemPlaceBinding +import campus.tech.kakao.map.model.PlaceItem + +class PlaceAdapter(private val onClick: (String) -> Unit) : ListAdapter( + PlaceDiffCallback() +) { + + inner class ViewHolder(private val binding: ItemPlaceBinding) : RecyclerView.ViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onClick(getItem(position).name) + } + } + } + + fun bind(placeItem: PlaceItem) { + binding.tvName.text = placeItem.name + binding.tvAddress.text = placeItem.address + binding.tvCategory.text = placeItem.category + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemPlaceBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class PlaceDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: PlaceItem, newItem: PlaceItem): Boolean { + return oldItem.name == newItem.name + } + + override fun areContentsTheSame(oldItem: PlaceItem, newItem: PlaceItem): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/utils/ExtendedTextWatcher.kt b/app/src/main/java/campus/tech/kakao/map/utils/ExtendedTextWatcher.kt new file mode 100644 index 00000000..b1268008 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/utils/ExtendedTextWatcher.kt @@ -0,0 +1,29 @@ +package campus.tech.kakao.map.utils + +import android.text.Editable +import android.text.TextWatcher +import android.widget.EditText + +fun EditText.doOnTextChanged(onTextChanged: (text: CharSequence?) -> Unit) { + val textWatcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // Do nothing + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + onTextChanged(s) + } + + override fun afterTextChanged(s: Editable?) { + // Do nothing + } + } + addTextChangedListener(textWatcher) + tag = textWatcher +} + +fun EditText.clearTextWatcher() { + (tag as? TextWatcher)?.let { + removeTextChangedListener(it) + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/viewmodel/PlaceViewModel.kt b/app/src/main/java/campus/tech/kakao/map/viewmodel/PlaceViewModel.kt new file mode 100644 index 00000000..1740a576 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/viewmodel/PlaceViewModel.kt @@ -0,0 +1,31 @@ +package campus.tech.kakao.map.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import campus.tech.kakao.map.model.HistoryItem +import campus.tech.kakao.map.model.PlaceItem +import campus.tech.kakao.map.repository.PlaceRepository + +class PlaceViewModel(application: Application) : AndroidViewModel(application) { + private val repository: PlaceRepository = PlaceRepository(application) + + val places: LiveData> get() = repository.places + val history: LiveData> get() = repository.history + + init { + repository.loadAllHistory() + } + + fun searchPlaces(keyword: String) { + repository.searchPlaces(keyword) + } + + fun addHistory(name: String) { + repository.addHistory(name) + } + + fun removeHistory(name: String) { + repository.removeHistory(name) + } +} diff --git a/app/src/main/res/drawable/cancel.png b/app/src/main/res/drawable/cancel.png new file mode 100644 index 00000000..69428cf1 Binary files /dev/null and b/app/src/main/res/drawable/cancel.png differ diff --git a/app/src/main/res/drawable/icon.png b/app/src/main/res/drawable/icon.png new file mode 100644 index 00000000..732bf8ae Binary files /dev/null and b/app/src/main/res/drawable/icon.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 24d17df2..1c7f48c7 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,70 @@ - + tools:context=".ui.MainActivity"> - + - + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_history.xml b/app/src/main/res/layout/item_history.xml new file mode 100644 index 00000000..f1d0a3db --- /dev/null +++ b/app/src/main/res/layout/item_history.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_place.xml b/app/src/main/res/layout/item_place.xml new file mode 100644 index 00000000..664bc6bb --- /dev/null +++ b/app/src/main/res/layout/item_place.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 768b058a..3dc64278 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,4 +2,5 @@ #FF000000 #FFFFFFFF + #FF808080