diff --git a/README.md b/README.md index df2a6477..5e33e3b7 100644 --- a/README.md +++ b/README.md @@ -1 +1,41 @@ # android-map-keyword +# 1단계 + +## 기능 요구 사항 +- 카카오맵 클론 코딩을 위한 시작입니다. +- 검색어 입력 및 검색 결과를 표시할 기본 레이아웃을 구현한다. +- 검색에 사용될 데이터를 로컬 데이터베이스에 생성한다. + +## 프로그래밍 요구 사항 +- 검색 데이터는 저장은 SQLite를 사용한다. +- 가능한 MVVM 아키텍처 패턴을 적용하도록 한다. +- 코드 컨벤션을 준수하며 프로그래밍한다. + +# 2단계 + +## 기능 요구 사항 +- 검색어를 입력하면 검색 결과 목록이 표시된다. +- 검색 결과 목록은 세로 스크롤이 된다. +- 입력한 검색어는 X를 눌러서 삭제할 수 있다. +- 검색 결과 목록에서 하나의 항목을 선택할 수 있다. +- 선택된 항목은 검색어 저장 목록에 추가된다. + - 목록에 있는 검색 결과를 다시 선택하면 기존 저장 목록에서 지워지고 가장 끝에 다시 추가된다. +- 저장된 검색어 목록은 가로 스크롤이 된다. +- 저장된 검색어는 X를 눌러서 삭제할 수 있다. +- 저장된 검색어는 앱을 재실행하여도 유지된다. + +## 프로그래밍 요구 사항 +- 검색 결과 목록은 리사이클러뷰를 사용한다. +- 가능한 MVVM 아키텍처 패턴을 적용하도록 한다. + +# 3단계 + +## 기능 요구 사항 +- 저장된 검색어 목록은 RecyclerView로 구현하고 검색 결과는 ListView로 구현해 보세요. + +## 프로그래밍 요구 사항 +- ListView 사용 시 재사용 가능한 뷰를 최대한 재사용할 수 있도록 구현한다. + +## 이미지 출처 +- 위치 마커 아이콘 제작자: bsd - Flaticon +- 문자 x 아이콘 제작자: Freepik - Flaticon diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9932d6bb..f79cc952 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,7 +40,9 @@ android { } dependencies { - + val lifecycle_version = "2.8.3" + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version") implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") diff --git a/app/src/main/java/campus/tech/kakao/map/MainActivity.kt b/app/src/main/java/campus/tech/kakao/map/MainActivity.kt index 95b43803..cf86a821 100644 --- a/app/src/main/java/campus/tech/kakao/map/MainActivity.kt +++ b/app/src/main/java/campus/tech/kakao/map/MainActivity.kt @@ -1,11 +1,89 @@ package campus.tech.kakao.map import android.os.Bundle +import android.view.View +import android.widget.EditText +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView class MainActivity : AppCompatActivity() { + + private lateinit var model: MainViewModel + private lateinit var search:EditText + private lateinit var clear: TextView + private lateinit var noResult: TextView + private lateinit var searchResult: RecyclerView + private lateinit var searchWordResult: RecyclerView + private lateinit var placeAdapter: PlaceAdapter + private lateinit var wordAdapter: WordAdapter + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + setupUI() + searchResult.layoutManager = LinearLayoutManager(this) + searchWordResult.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + + search.doOnTextChanged { text, start, before, count -> + val query = text.toString() + if (query.isEmpty()){ + noResult.visibility = View.VISIBLE + searchResult.visibility = View.GONE + }else{ + noResult.visibility = View.GONE + searchResult.visibility = View.VISIBLE + model.search(query) + } + } + model = ViewModelProvider(this)[MainViewModel::class.java] + model.placeList.observe(this, Observer { + if (it.isNullOrEmpty()){ + noResult.visibility = View.VISIBLE + searchResult.visibility = View.GONE + }else{ + noResult.visibility = View.GONE + searchResult.visibility = View.VISIBLE + placeAdapter = PlaceAdapter(){ Place -> + model.addWord(Place) + } + placeAdapter.submitList(it) + searchResult.adapter = placeAdapter + } + }) + model.loadWord() + model.wordList.observe(this, Observer { + if (it.isNullOrEmpty()){ + searchWordResult.visibility = View.GONE + } + else{ + searchWordResult.visibility = View.VISIBLE + wordAdapter = WordAdapter() { SearchWord -> + model.deleteWord( + SearchWord + ) + } + wordAdapter.submitList(it) + searchWordResult.adapter = wordAdapter + } + }) + } + + fun setupUI(){ + search = findViewById(R.id.search) + clear = findViewById(R.id.search_clear) + noResult = findViewById(R.id.no_search_result) + searchResult = findViewById(R.id.search_result_recycler_view) + searchWordResult = findViewById(R.id.search_word_recycler_view) + clear.setOnClickListener { + search.setText("") + } + } + + } diff --git a/app/src/main/java/campus/tech/kakao/map/MainViewModel.kt b/app/src/main/java/campus/tech/kakao/map/MainViewModel.kt new file mode 100644 index 00000000..fd7e3919 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/MainViewModel.kt @@ -0,0 +1,43 @@ +package campus.tech.kakao.map + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData + +class MainViewModel(application: Application): AndroidViewModel(application) { + private val placeDbHelper = PlaceDbHelper(application) + val placeList: LiveData> get() = placeDbHelper.getPlace() + + private val wordDbHelper = SearchWordDbHelper(application) + val wordList: LiveData> get() = wordDbHelper.getSearchWords() + + //초기에 데이터 삽입을 위해 1번 사용 + fun insertInitData(){ + if (!placeDbHelper.existData()){ + for(i in 1..10){ + placeDbHelper.addPlace(Place("카페 $i", "남양주 $i", "카페")) + placeDbHelper.addPlace(Place("약국 $i", "남양주 $i", "약국")) + } + } + + } + + fun search(query: String) { + placeDbHelper.searchPlaceName(query) + } + + fun addWord(place: Place){ + wordDbHelper.addWord(wordfromPlace(place)) + } + + private fun wordfromPlace(place: Place):SearchWord{ + return SearchWord(place.name, place.address, place.type) + } + fun deleteWord(word: SearchWord){ + wordDbHelper.deleteWord(word) + } + + fun loadWord(){ + wordDbHelper.updateSearchWords() + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/Place.kt b/app/src/main/java/campus/tech/kakao/map/Place.kt new file mode 100644 index 00000000..904256a6 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/Place.kt @@ -0,0 +1,3 @@ +package campus.tech.kakao.map + +data class Place(val name: String, val address: String, val type: String) diff --git a/app/src/main/java/campus/tech/kakao/map/PlaceAdapter.kt b/app/src/main/java/campus/tech/kakao/map/PlaceAdapter.kt new file mode 100644 index 00000000..6be747ca --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/PlaceAdapter.kt @@ -0,0 +1,56 @@ +package campus.tech.kakao.map + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView + +class PlaceAdapter( + val onItemClicked: (Place) -> Unit +): ListAdapter( + object : DiffUtil.ItemCallback(){ + override fun areItemsTheSame(oldItem: Place, newItem: Place): Boolean { + return (oldItem.name == newItem.name) + && (oldItem.address == newItem.address) + && (oldItem.type == newItem.type) + } + + override fun areContentsTheSame(oldItem: Place, newItem: Place): Boolean { + return oldItem == newItem + } + + } +) { + private var placeClicked = { position:Int -> + val place:Place = getItem(position) + onItemClicked(place) + } + inner class ViewHolder( + itemView: View + ): RecyclerView.ViewHolder(itemView) { + val name:TextView = itemView.findViewById(R.id.name) + val address:TextView = itemView.findViewById(R.id.address) + val type:TextView = itemView.findViewById(R.id.type) + init { + itemView.setOnClickListener { + placeClicked(bindingAdapterPosition) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.place_item, parent, false)) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val place:Place = getItem(position) + holder.name.text = place.name + holder.address.text = place.address + holder.type.text = place.type + } +} + + diff --git a/app/src/main/java/campus/tech/kakao/map/PlaceContract.kt b/app/src/main/java/campus/tech/kakao/map/PlaceContract.kt new file mode 100644 index 00000000..44c0cd4d --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/PlaceContract.kt @@ -0,0 +1,12 @@ +package campus.tech.kakao.map + +import android.provider.BaseColumns + +object PlaceContract:BaseColumns { + const val DB_NAME = "place.db" + const val DB_VERSION = 2 + const val TABLE_NAME = "place" + const val COLUMN_NAME_NAME = "name" + const val COLUMN_NAME_ADDRESS = "address" + const val COLUMN_NAME_TYPE = "type" +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/PlaceDbHelper.kt b/app/src/main/java/campus/tech/kakao/map/PlaceDbHelper.kt new file mode 100644 index 00000000..8c19c4e6 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/PlaceDbHelper.kt @@ -0,0 +1,108 @@ +package campus.tech.kakao.map + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.lifecycle.MutableLiveData +import campus.tech.kakao.map.PlaceContract.DB_VERSION + +class PlaceDbHelper(context: Context):SQLiteOpenHelper( + context, PlaceContract.DB_NAME, null, DB_VERSION) { + private val _place = MutableLiveData>() + override fun onCreate(db: SQLiteDatabase?) { + createTable(db) + } + private fun createTable(db: SQLiteDatabase?) { + db?.execSQL("CREATE TABLE ${PlaceContract.TABLE_NAME} " + + "(${PlaceContract.COLUMN_NAME_NAME} TEXT, " + + "${PlaceContract.COLUMN_NAME_ADDRESS} TEXT, " + + "${PlaceContract.COLUMN_NAME_TYPE} TEXT)") + } + + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + db?.execSQL("DROP TABLE IF EXISTS ${PlaceContract.TABLE_NAME}") + createTable(db) + } + + fun getPlace(): MutableLiveData> { + return _place + } + + fun addPlace(place: Place) { + val db = writableDatabase + if (!existPlace(place, db)){ + val values = ContentValues() + values.put(PlaceContract.COLUMN_NAME_NAME, place.name) + values.put(PlaceContract.COLUMN_NAME_ADDRESS, place.address) + values.put(PlaceContract.COLUMN_NAME_TYPE, place.type) + db.insert(PlaceContract.TABLE_NAME, null, values) + db.close() + } + } + + fun existData(): Boolean{ + val db = readableDatabase + val cursor = db.query( + PlaceContract.TABLE_NAME, + arrayOf(PlaceContract.COLUMN_NAME_NAME), + null, + null, + null, + null, + null + ) + val result = cursor.moveToFirst() + cursor.close() + return result + } + + fun existPlace(place: Place, db: SQLiteDatabase): Boolean{ + val selection = "${PlaceContract.COLUMN_NAME_NAME} = ? AND " + + "${PlaceContract.COLUMN_NAME_ADDRESS} = ? AND " + + "${PlaceContract.COLUMN_NAME_TYPE} = ?" + val cursor = db.query( + PlaceContract.TABLE_NAME, + arrayOf(PlaceContract.COLUMN_NAME_NAME), + selection, + arrayOf(place.name, place.address, place.type), + null, + null, + "${PlaceContract.COLUMN_NAME_NAME} DESC" + ) + + val result = cursor.moveToFirst() + cursor.close() + return if (result) true else false + } + + fun searchPlaceName(name: String){ + val resultList = mutableListOf() + val searchResult = "%${name}%" + val cursor = readableDatabase.query( + PlaceContract.TABLE_NAME, + arrayOf(PlaceContract.COLUMN_NAME_NAME, + PlaceContract.COLUMN_NAME_ADDRESS, + PlaceContract.COLUMN_NAME_TYPE), + "${PlaceContract.COLUMN_NAME_NAME} like ?", + arrayOf(searchResult), + null, + null, + "${PlaceContract.COLUMN_NAME_NAME} ASC" + ) + + while (cursor.moveToNext()) { + val name = cursor.getString( + cursor.getColumnIndexOrThrow(PlaceContract.COLUMN_NAME_NAME) + ) + val address = cursor.getString( + cursor.getColumnIndexOrThrow(PlaceContract.COLUMN_NAME_ADDRESS) + ) + val type = cursor.getString( + cursor.getColumnIndexOrThrow(PlaceContract.COLUMN_NAME_TYPE)) + resultList.add(Place(name, address, type)) + } + cursor.close() + _place.value = resultList + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/SearchWord.kt b/app/src/main/java/campus/tech/kakao/map/SearchWord.kt new file mode 100644 index 00000000..980b1625 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/SearchWord.kt @@ -0,0 +1,3 @@ +package campus.tech.kakao.map + +data class SearchWord(val name: String, val address: String, val type: String) diff --git a/app/src/main/java/campus/tech/kakao/map/SearchWordContract.kt b/app/src/main/java/campus/tech/kakao/map/SearchWordContract.kt new file mode 100644 index 00000000..9e28ec13 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/SearchWordContract.kt @@ -0,0 +1,11 @@ +package campus.tech.kakao.map + +object SearchWordContract { + const val DB_NAME = "search_word.db" + const val DB_VERSION = 1 + const val TABLE_NAME = "SearchWord" + const val COLUMN_NAME_NAME = "name" + const val COLUMN_NAME_ADDRESS = "address" + const val COLUMN_NAME_TYPE = "type" + +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/SearchWordDbHelper.kt b/app/src/main/java/campus/tech/kakao/map/SearchWordDbHelper.kt new file mode 100644 index 00000000..cb98b5d6 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/SearchWordDbHelper.kt @@ -0,0 +1,119 @@ +package campus.tech.kakao.map + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.lifecycle.MutableLiveData +import campus.tech.kakao.map.SearchWordContract.DB_VERSION + +class SearchWordDbHelper(context: Context): SQLiteOpenHelper( + context, SearchWordContract.DB_NAME, null, DB_VERSION) { + private val _searchWords = MutableLiveData>() + val searchSameSelection = "${SearchWordContract.COLUMN_NAME_NAME} = ? AND " + + "${SearchWordContract.COLUMN_NAME_ADDRESS} = ? AND " + + "${SearchWordContract.COLUMN_NAME_TYPE} = ?" + override fun onCreate(db: SQLiteDatabase?) { + createTable(db) + } + + private fun createTable(db: SQLiteDatabase?) { + db?.execSQL( + "CREATE TABLE ${SearchWordContract.TABLE_NAME} " + + "(${SearchWordContract.COLUMN_NAME_NAME} TEXT, " + + "${SearchWordContract.COLUMN_NAME_ADDRESS} TEXT, " + + "${SearchWordContract.COLUMN_NAME_TYPE} TEXT)" + ) + } + + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + db?.execSQL("DROP TABLE IF EXISTS ${SearchWordContract.TABLE_NAME}") + createTable(db) + } + + fun getSearchWords(): MutableLiveData> { + return _searchWords + } + + fun addWord(word: SearchWord) { + val db = writableDatabase + if (existWord(word, db)){ + deleteWord(word) + } + val values = ContentValues() + values.put(SearchWordContract.COLUMN_NAME_NAME, word.name) + values.put(SearchWordContract.COLUMN_NAME_ADDRESS, word.address) + values.put(SearchWordContract.COLUMN_NAME_TYPE, word.type) + db.insert(SearchWordContract.TABLE_NAME, null, values) + db.close() + updateSearchWords() + } + + fun existData(): Boolean{ + val db = readableDatabase + val cursor = db.query( + SearchWordContract.TABLE_NAME, + arrayOf(SearchWordContract.COLUMN_NAME_NAME), + null, + null, + null, + null, + null + ) + val result = cursor.moveToFirst() + cursor.close() + return result + } + + fun existWord(word: SearchWord, db: SQLiteDatabase): Boolean{ + val selection = searchSameSelection + val cursor = db.query( + SearchWordContract.TABLE_NAME, + arrayOf(SearchWordContract.COLUMN_NAME_NAME), + selection, + arrayOf(word.name, word.address, word.type), + null, + null, + "${SearchWordContract.COLUMN_NAME_NAME} DESC" + ) + + val result = cursor.moveToFirst() + cursor.close() + return if (result) true else false + } + + fun deleteWord(word: SearchWord){ + val db = writableDatabase + val selection = searchSameSelection + val selectionArgs = arrayOf(word.name, word.address, word.type) + db.delete(SearchWordContract.TABLE_NAME, selection, selectionArgs) + updateSearchWords() + } + + fun updateSearchWords(){ + val db = readableDatabase + val resultList = mutableListOf() + val cursor = db.query( + SearchWordContract.TABLE_NAME, + arrayOf(SearchWordContract.COLUMN_NAME_NAME, SearchWordContract.COLUMN_NAME_ADDRESS, SearchWordContract.COLUMN_NAME_TYPE), + null, + null, + null, + null, + null + ) + while (cursor.moveToNext()) { + val name = cursor.getString( + cursor.getColumnIndexOrThrow(SearchWordContract.COLUMN_NAME_NAME) + ) + val address = cursor.getString( + cursor.getColumnIndexOrThrow(SearchWordContract.COLUMN_NAME_ADDRESS) + ) + val type = cursor.getString( + cursor.getColumnIndexOrThrow(SearchWordContract.COLUMN_NAME_TYPE)) + resultList.add(SearchWord(name, address, type)) + } + cursor.close() + _searchWords.value = resultList + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/WordAdapter.kt b/app/src/main/java/campus/tech/kakao/map/WordAdapter.kt new file mode 100644 index 00000000..b1c910e5 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/WordAdapter.kt @@ -0,0 +1,53 @@ +package campus.tech.kakao.map + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView + +class WordAdapter( + val onItemClicked: (SearchWord) -> Unit +): ListAdapter( + object : DiffUtil.ItemCallback(){ + override fun areItemsTheSame(oldItem: SearchWord, newItem: SearchWord): Boolean { + return (oldItem.name == newItem.name) + && (oldItem.address == newItem.address) + && (oldItem.type == newItem.type) + } + + override fun areContentsTheSame(oldItem: SearchWord, newItem: SearchWord): Boolean { + return oldItem == newItem + } + + } +) { + inner class ViewHolder( + itemView: View + ): RecyclerView.ViewHolder(itemView) { + val searchWord: TextView = itemView.findViewById(R.id.search_word) + val delete: ImageView = itemView.findViewById(R.id.x) + init { + delete.setOnClickListener { + deletedWords(bindingAdapterPosition) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.word_item, parent, false)) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val word = getItem(position) + holder.searchWord.text = word.name + } + + private val deletedWords = { position:Int -> + val word = getItem(position) + onItemClicked(word) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/marker.png b/app/src/main/res/drawable/marker.png new file mode 100644 index 00000000..5519a07d Binary files /dev/null and b/app/src/main/res/drawable/marker.png differ diff --git a/app/src/main/res/drawable/x.png b/app/src/main/res/drawable/x.png new file mode 100644 index 00000000..15dcc1b3 Binary files /dev/null and b/app/src/main/res/drawable/x.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 24d17df2..f1ac7119 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,13 +7,55 @@ android:layout_height="match_parent" tools:context=".MainActivity"> + + + + + + + + + + diff --git a/app/src/main/res/layout/place_item.xml b/app/src/main/res/layout/place_item.xml new file mode 100644 index 00000000..fa5da065 --- /dev/null +++ b/app/src/main/res/layout/place_item.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/word_item.xml b/app/src/main/res/layout/word_item.xml new file mode 100644 index 00000000..a14c1c28 --- /dev/null +++ b/app/src/main/res/layout/word_item.xml @@ -0,0 +1,23 @@ + + + + + + + + \ 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 e5ba5b9c..290224da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ Map + 검색어를 입력해 주세요. + 검색 결과가 없습니다. \ No newline at end of file