diff --git a/.gitignore b/.gitignore index b4959436..d658d9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -.DS_Store -### Android template # Gradle files .gradle/ build/ @@ -33,5 +31,6 @@ google-services.json # Android Profiling *.hprof -/keyStore -/app/release + +# Mac OS +.DS_Store diff --git a/README.md b/README.md index 73ba54e1..18986352 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ -# android-map-refactoring +# android-map-location + +카카오 맵 클론 코딩 +카카오로컬 API 사용 + +## 기능 요구 사항 +- 저장된 검색어를 선택하면 해당 검색어의 검색 결과가 표시된다. +- 검색 결과 목록 중 하나의 항목을 선택하면 해당 항목의 위치를 지도에 표시한다. +- 앱 종료 시 마지막 위치를 저장하여 다시 앱 실행 시 해당 위치로 포커스 한다. +- 카카오지도 onMapError() 호출 시 에러 화면을 보여준다. +- +## 프로그래밍 요구 사항 +- BottomSheet를 사용한다. +- 카카오 API 사용을 위한 앱 키를 외부에 노출하지 않는다. +- 가능한 MVVM 아키텍처 패턴을 적용하도록 한다. +- 코드 컨벤션을 준수하며 프로그래밍한다. + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c1a15f2..e3447cdf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,22 +1,29 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") - id("org.jlleitschuh.gradle.ktlint") - id("kotlin-parcelize") - id("kotlin-kapt") id("com.google.dagger.hilt.android") + id("kotlin-kapt") } android { namespace = "campus.tech.kakao.map" compileSdk = 34 + defaultConfig { + resValue("string", "kakao_api_key", getApiKey("KAKAO_API_KEY")) + buildConfigField("String", "KAKAO_REST_API_KEY", getApiKey("KAKAO_REST_API_KEY")) applicationId = "campus.tech.kakao.map" minSdk = 26 targetSdk = 34 versionCode = 1 versionName = "1.0" + ndk { + abiFilters.add("arm64-v8a") + abiFilters.add("armeabi-v7a")} + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -39,42 +46,43 @@ android { } buildFeatures { + viewBinding = true dataBinding = true buildConfig = true } } dependencies { - - implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.appcompat:appcompat:1.7.0") - implementation("com.google.android.material:material:1.12.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.recyclerview:recyclerview:1.3.2") - implementation("com.squareup.retrofit2:retrofit:2.11.0") - implementation("com.squareup.retrofit2:converter-gson:2.11.0") - implementation("com.kakao.maps.open:android:2.9.5") - implementation("androidx.activity:activity-ktx:1.9.0") - implementation("androidx.test:core-ktx:1.6.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3") - implementation("androidx.room:room-runtime:2.6.1") + kapt("com.google.dagger:hilt-compiler:2.48.1") kapt("androidx.room:room-compiler:2.6.1") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + implementation ("androidx.activity:activity-ktx:1.1.0") + implementation ("androidx.fragment:fragment-ktx:1.2.5") implementation("com.google.dagger:hilt-android:2.48.1") - kapt("com.google.dagger:hilt-compiler:2.48.1") - implementation("androidx.activity:activity-ktx:1.9.0") implementation("androidx.room:room-ktx:2.6.1") - testImplementation("androidx.room:room-testing:2.6.1") + implementation("androidx.room:room-runtime:2.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") + implementation("com.kakao.sdk:v2-all:2.20.3") + implementation("com.kakao.maps.open:android:2.9.5") + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + 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.activity:activity:1.8.0") testImplementation("junit:junit:4.13.2") - testImplementation("io.mockk:mockk-android:1.13.11") - testImplementation("io.mockk:mockk-agent:1.13.11") - testImplementation("androidx.arch.core:core-testing:2.2.0") - testImplementation("org.robolectric:robolectric:4.11.1") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") - androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("io.mockk:mockk:1.13.12") androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - androidTestImplementation("androidx.test:rules:1.6.1") - androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1") - androidTestImplementation("com.google.dagger:hilt-android-testing:2.48.1") - kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.48.1") + implementation("androidx.test:core-ktx:1.6.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") + androidTestImplementation("androidx.test.espresso:espresso-contrib:3.3.0") + androidTestImplementation("androidx.test.espresso:espresso-intents:3.3.0") } + +fun getApiKey(key: String): String = gradleLocalProperties(rootDir, providers).getProperty(key) \ No newline at end of file diff --git a/app/build.gradle.kts.rej b/app/build.gradle.kts.rej new file mode 100644 index 00000000..75749712 --- /dev/null +++ b/app/build.gradle.kts.rej @@ -0,0 +1,21 @@ +diff a/app/build.gradle.kts b/app/build.gradle.kts (rejected hunks) +@@ -69,10 +69,17 @@ + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("androidx.datastore:datastore-preferences:1.0.0") +- implementation("androidx.activity:activity:1.8.0") ++ implementation("androidx.activity:activity-ktx:1.8.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") ++ androidTestImplementation("androidx.test:rules:1.4.0") ++ androidTestImplementation("androidx.test:runner:1.4.0") ++ androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1") ++ androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1") ++ androidTestImplementation("io.mockk:mockk-android:1.13.3") ++ androidTestImplementation("androidx.arch.core:core-testing:2.1.0") + } + ++ + fun getApiKey(key: String): String = gradleLocalProperties(rootDir, providers).getProperty(key) +\ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6bca2f54..04e70f2a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + @@ -21,6 +24,14 @@ + + + + - + \ No newline at end of file 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/PlaceApplication.kt b/app/src/main/java/campus/tech/kakao/map/PlaceApplication.kt new file mode 100644 index 00000000..3cbf6189 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/PlaceApplication.kt @@ -0,0 +1,40 @@ +package campus.tech.kakao.map + +import android.app.Application +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.view.View +import com.kakao.vectormap.KakaoMapSdk +import dagger.hilt.android.HiltAndroidApp + + +@HiltAndroidApp +class PlaceApplication: Application() { + + override fun onCreate() { + super.onCreate() + appInstance = this + + initKakaoMapSdk() + } + + private fun initKakaoMapSdk(){ + val key = getString(R.string.kakao_api_key) + KakaoMapSdk.init(this, key) + } + companion object { + @Volatile + private lateinit var appInstance: PlaceApplication + fun isNetworkActive(): Boolean { + val connectivityManager: ConnectivityManager = + appInstance.getSystemService(ConnectivityManager::class.java) + val network: Network = connectivityManager.activeNetwork ?: return false + val actNetwork: NetworkCapabilities = + connectivityManager.getNetworkCapabilities(network) ?: return false + + return actNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + actNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/data/LastVisitedPlaceManager.kt b/app/src/main/java/campus/tech/kakao/map/data/LastVisitedPlaceManager.kt new file mode 100644 index 00000000..96b3832c --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/LastVisitedPlaceManager.kt @@ -0,0 +1,34 @@ +package campus.tech.kakao.map.data + +import android.content.Context +import campus.tech.kakao.map.domain.model.Place +import javax.inject.Inject + +class LastVisitedPlaceManager @Inject constructor(context: Context) { + + private val sharedPreferences = context.getSharedPreferences("LastVisitedPlace", Context.MODE_PRIVATE) + + fun saveLastVisitedPlace(place: Place) { + val editor = sharedPreferences.edit() + editor.putString("placeName", place.place) + editor.putString("roadAddressName", place.address) + editor.putString("categoryName", place.category) + editor.putString("yPos", place.yPos) + editor.putString("xPos", place.xPos) + editor.apply() + } + + fun getLastVisitedPlace(): Place? { + val placeName = sharedPreferences.getString("placeName", null) + val roadAddressName = sharedPreferences.getString("roadAddressName", null) + val categoryName = sharedPreferences.getString("categoryName", null) + val yPos = sharedPreferences.getString("yPos", null) + val xPos = sharedPreferences.getString("xPos", null) + + return if (placeName != null && roadAddressName != null && categoryName != null && yPos != null && xPos != null) { + Place("", placeName, roadAddressName, categoryName, xPos, yPos) + } else { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/PlaceLocalDataRepository.kt b/app/src/main/java/campus/tech/kakao/map/data/PlaceLocalDataRepository.kt new file mode 100644 index 00000000..417526a6 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/PlaceLocalDataRepository.kt @@ -0,0 +1,42 @@ +package campus.tech.kakao.map.data + +import campus.tech.kakao.map.data.dao.PlaceDao +import campus.tech.kakao.map.data.entity.PlaceEntity +import campus.tech.kakao.map.data.entity.PlaceLogEntity +import campus.tech.kakao.map.domain.model.Place +import campus.tech.kakao.map.domain.repository.PlaceRepository +import javax.inject.Inject + +open class PlaceLocalDataRepository @Inject constructor( + private val placeDao: PlaceDao, +) : PlaceRepository { + + override suspend fun getPlaces(placeName: String): List { + return placeDao.getPlaces(placeName).map { it.toPlace() } + } + + override suspend fun updatePlaces(places: List) { + placeDao.deleteAllPlaces() + placeDao.insertPlaces(places.map { + PlaceEntity(it.id, it.place, it.address, it.category, it.xPos, it.yPos) + }) + } + + override suspend fun getPlaceById(id: String): Place? { + return placeDao.getPlaceById(id)?.toPlace() + } + + override suspend fun updateLogs(logs: List) { + placeDao.deleteAllLogs() + placeDao.insertLogs(logs.map { PlaceLogEntity(it.id, it.place) }) + } + + override suspend fun removeLog(id: String) { + placeDao.removeLog(id) + + } + + override suspend fun getLogs(): List { + return placeDao.getLogs().map { it.toPlace() } + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/data/PlaceRemoteDataRepository.kt b/app/src/main/java/campus/tech/kakao/map/data/PlaceRemoteDataRepository.kt new file mode 100644 index 00000000..b5ef6485 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/PlaceRemoteDataRepository.kt @@ -0,0 +1,35 @@ +package campus.tech.kakao.map.data + +import android.content.Context +import campus.tech.kakao.map.BuildConfig +import campus.tech.kakao.map.data.dao.PlaceDao +import campus.tech.kakao.map.data.net.KakaoApi +import campus.tech.kakao.map.domain.model.Place +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class PlaceRemoteDataRepository @Inject constructor( + private val placeDao: PlaceDao, + private val kakaoApi: KakaoApi +) : PlaceLocalDataRepository(placeDao){ + override suspend fun getPlaces(placeName: String): List { + return withContext(Dispatchers.IO) { + val resultPlaces = mutableListOf() + for (page in 1..3) { + val response = kakaoApi.getSearchKeyword( + key = BuildConfig.KAKAO_REST_API_KEY, + query = placeName, + size = 15, + page = page + ) + if (response.isSuccessful) { + response.body()?.documents?.let { resultPlaces.addAll(it) } + } else throw RuntimeException("통신 에러 발생") + } + updatePlaces(resultPlaces) + resultPlaces + } + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/data/dao/PlaceDao.kt b/app/src/main/java/campus/tech/kakao/map/data/dao/PlaceDao.kt new file mode 100644 index 00000000..26821b78 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/dao/PlaceDao.kt @@ -0,0 +1,34 @@ +package campus.tech.kakao.map.data.dao + +import androidx.room.* +import androidx.room.Insert +import androidx.room.Query +import campus.tech.kakao.map.data.entity.PlaceEntity +import campus.tech.kakao.map.data.entity.PlaceLogEntity + +@Dao +interface PlaceDao { + @Query("SELECT * FROM places WHERE place LIKE :keyword") + suspend fun getPlaces(keyword: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPlaces(places: List) + + @Query("SELECT * FROM places WHERE id = :id") + suspend fun getPlaceById(id: String): PlaceEntity? + + @Query("DELETE FROM places") + suspend fun deleteAllPlaces() + + @Query("SELECT * FROM logs") + suspend fun getLogs(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLogs(logs: List) + + @Query("DELETE FROM logs WHERE id = :id") + suspend fun removeLog(id: String) + + @Query("DELETE FROM logs") + suspend fun deleteAllLogs() +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/database/PlaceDatabase.kt b/app/src/main/java/campus/tech/kakao/map/data/database/PlaceDatabase.kt new file mode 100644 index 00000000..5b0d9a28 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/database/PlaceDatabase.kt @@ -0,0 +1,11 @@ +package campus.tech.kakao.map.data.database + +import androidx.room.* +import campus.tech.kakao.map.data.dao.PlaceDao +import campus.tech.kakao.map.data.entity.PlaceEntity +import campus.tech.kakao.map.data.entity.PlaceLogEntity + +@Database(entities = [PlaceEntity::class, PlaceLogEntity::class], version = 1) +abstract class PlaceDatabase : RoomDatabase() { + abstract fun placeDao(): PlaceDao +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/entity/Entity.kt b/app/src/main/java/campus/tech/kakao/map/data/entity/Entity.kt new file mode 100644 index 00000000..e191bad6 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/entity/Entity.kt @@ -0,0 +1,25 @@ +package campus.tech.kakao.map.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import campus.tech.kakao.map.domain.model.Place + +@Entity(tableName = "places") +data class PlaceEntity( + @PrimaryKey val id: String, + val place: String, + val address: String, + val type: String, + val xPos: String, + val yPos: String +) { + fun toPlace() = Place(id, place, address, type, xPos, yPos) +} + +@Entity(tableName = "logs") +data class PlaceLogEntity( + @PrimaryKey val id: String, + val place: String +) { + fun toPlace() = Place(id, place, "", "", "", "") +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/net/KakaoApi.kt b/app/src/main/java/campus/tech/kakao/map/data/net/KakaoApi.kt new file mode 100644 index 00000000..1a4264d7 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/net/KakaoApi.kt @@ -0,0 +1,18 @@ +package campus.tech.kakao.map.data.net + +import campus.tech.kakao.map.BuildConfig +import campus.tech.kakao.map.domain.model.ResultSearchKeyword +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query + +interface KakaoApi { + @GET("v2/local/search/keyword.json") + suspend fun getSearchKeyword( + @Header("Authorization") key: String, + @Query("query") query: String, + @Query("size") size: Int = 15, + @Query("page") page: Int = 1 + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/di/DatabaseModule.kt b/app/src/main/java/campus/tech/kakao/map/di/DatabaseModule.kt new file mode 100644 index 00000000..67f817b8 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/di/DatabaseModule.kt @@ -0,0 +1,33 @@ +package campus.tech.kakao.map.di + +import android.content.Context +import androidx.room.Room +import campus.tech.kakao.map.data.dao.PlaceDao +import campus.tech.kakao.map.data.database.PlaceDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext context: Context): PlaceDatabase { + return Room.databaseBuilder( + context.applicationContext, + PlaceDatabase::class.java, + "place_database" + ).build() + } + + @Provides + @Singleton + fun providePlaceDao(placeDatabase: PlaceDatabase): PlaceDao { + return placeDatabase.placeDao() + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/di/NetworkModule.kt b/app/src/main/java/campus/tech/kakao/map/di/NetworkModule.kt new file mode 100644 index 00000000..8857152c --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/di/NetworkModule.kt @@ -0,0 +1,30 @@ +package campus.tech.kakao.map.di + +import campus.tech.kakao.map.data.net.KakaoApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + private const val BASE_URL = "https://dapi.kakao.com/" + @Singleton + @Provides + fun provideRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Singleton + @Provides + fun provideKakaoApi(retrofit: Retrofit): KakaoApi { + return retrofit.create(KakaoApi::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/di/ViewModelModule.kt b/app/src/main/java/campus/tech/kakao/map/di/ViewModelModule.kt new file mode 100644 index 00000000..d8eec735 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/di/ViewModelModule.kt @@ -0,0 +1,37 @@ +package campus.tech.kakao.map.di + +import android.content.Context +import campus.tech.kakao.map.PlaceApplication +import campus.tech.kakao.map.data.* +import campus.tech.kakao.map.data.dao.PlaceDao +import campus.tech.kakao.map.data.net.KakaoApi +import campus.tech.kakao.map.domain.repository.PlaceRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(ViewModelComponent::class) +object ViewModelModule { + // SearchViewModel + @Provides + fun providePlaceRepository( + placeDao: PlaceDao, kakaoApi: KakaoApi): PlaceRepository { + return if (PlaceApplication.isNetworkActive()) { + PlaceRemoteDataRepository(placeDao,kakaoApi) + } else { + PlaceLocalDataRepository(placeDao) + } + } + + // MapViewModel + @Provides + fun provideLastVisitedPlaceManager(@ApplicationContext context: Context): LastVisitedPlaceManager{ + return LastVisitedPlaceManager(context) + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/domain/model/Place.kt b/app/src/main/java/campus/tech/kakao/map/domain/model/Place.kt new file mode 100644 index 00000000..643e5f4a --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/domain/model/Place.kt @@ -0,0 +1,14 @@ +package campus.tech.kakao.map.domain.model + +import com.google.gson.annotations.SerializedName +import java.io.Serializable + + +data class Place( + @SerializedName("id") var id: String, + @SerializedName("place_name") var place: String, + @SerializedName("address_name") var address: String, + @SerializedName("category_name")var category: String, + @SerializedName("x") var xPos: String, + @SerializedName("y") var yPos: String +): Serializable diff --git a/app/src/main/java/campus/tech/kakao/map/domain/model/ResultSearchKeyword.kt b/app/src/main/java/campus/tech/kakao/map/domain/model/ResultSearchKeyword.kt new file mode 100644 index 00000000..8fadfa99 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/domain/model/ResultSearchKeyword.kt @@ -0,0 +1,10 @@ +package campus.tech.kakao.map.domain.model + +import com.google.gson.annotations.SerializedName + +data class ResultSearchKeyword( + var documents: List +) + + + diff --git a/app/src/main/java/campus/tech/kakao/map/domain/repository/PlaceRepository.kt b/app/src/main/java/campus/tech/kakao/map/domain/repository/PlaceRepository.kt new file mode 100644 index 00000000..d5426c30 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/domain/repository/PlaceRepository.kt @@ -0,0 +1,12 @@ +package campus.tech.kakao.map.domain.repository + +import campus.tech.kakao.map.domain.model.Place + +interface PlaceRepository { + suspend fun getPlaces(placeName: String): List + suspend fun updatePlaces(places:List) + suspend fun getPlaceById(id: String):Place? + suspend fun getLogs(): List + suspend fun updateLogs(logs: List) + suspend fun removeLog(id: String) +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/adapter/LogAdapter.kt b/app/src/main/java/campus/tech/kakao/map/presentation/adapter/LogAdapter.kt new file mode 100644 index 00000000..d972bfef --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/adapter/LogAdapter.kt @@ -0,0 +1,33 @@ +package campus.tech.kakao.map.presentation.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.util.DiffUtilCallback +import campus.tech.kakao.map.databinding.LogItemBinding +import campus.tech.kakao.map.domain.model.Place + +class LogAdapter( + private val onRemoveLog: (String) -> Unit +) + : ListAdapter(DiffUtilCallback()) { + inner class LogViewHolder(private val binding: LogItemBinding) + : RecyclerView.ViewHolder(binding.root){ + fun bind(place: Place){ + binding.place = place + binding.btnLogDel.setOnClickListener { + onRemoveLog(place.id) + } + } + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { + val binding = LogItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) + return LogViewHolder(binding) + } + + override fun onBindViewHolder(holder: LogViewHolder, position: Int) { + val location = getItem(position) + holder.bind(location) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/adapter/SearchedPlaceAdapter.kt b/app/src/main/java/campus/tech/kakao/map/presentation/adapter/SearchedPlaceAdapter.kt new file mode 100644 index 00000000..1d49c77f --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/adapter/SearchedPlaceAdapter.kt @@ -0,0 +1,34 @@ +package campus.tech.kakao.map.presentation.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.util.DiffUtilCallback +import campus.tech.kakao.map.domain.model.Place +import campus.tech.kakao.map.databinding.ListItemBinding + + +class SearchedPlaceAdapter( + private var onItemClicked: (Place) -> Unit +): ListAdapter(DiffUtilCallback()) { + + inner class LocationViewHolder(private val binding: ListItemBinding ) + :RecyclerView.ViewHolder(binding.root){ + fun bind(place: Place){ + binding.place = place + binding.root.setOnClickListener { + onItemClicked(place) + } + } + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LocationViewHolder { + val binding = ListItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) + return LocationViewHolder(binding) + } + + override fun onBindViewHolder(holder: LocationViewHolder, position: Int) { + val location = getItem(position) + holder.bind(location) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/map/MapActivity.kt b/app/src/main/java/campus/tech/kakao/map/presentation/map/MapActivity.kt new file mode 100644 index 00000000..57d65e7a --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/map/MapActivity.kt @@ -0,0 +1,187 @@ +package campus.tech.kakao.map.presentation.map + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.WindowInsets +import android.view.WindowInsetsController +import android.widget.TextView +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.lifecycleScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import campus.tech.kakao.map.PlaceApplication +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.ActivityMapBinding +import campus.tech.kakao.map.domain.model.Place +import campus.tech.kakao.map.presentation.search.SearchActivity +import com.kakao.vectormap.KakaoMap +import com.kakao.vectormap.KakaoMapReadyCallback +import com.kakao.vectormap.LatLng +import com.kakao.vectormap.MapLifeCycleCallback +import com.kakao.vectormap.MapView +import com.kakao.vectormap.camera.CameraUpdateFactory +import com.kakao.vectormap.label.LabelOptions +import com.kakao.vectormap.label.LabelStyle +import com.kakao.vectormap.label.LabelStyles +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class MapActivity : AppCompatActivity() { + private lateinit var binding: ActivityMapBinding + private lateinit var resultLauncher: ActivityResultLauncher + private lateinit var mapBottomSheet: MapBottomSheet + private lateinit var kakaoMap: KakaoMap + private val mapViewModel: MapViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setStatusBarTransparent() + initBinding() + collectViewModel() + initSwipeRefreshLayout() + initMapView() + initSearchView() + setResultLauncher() + } + + private fun setStatusBarTransparent() { + this.window?.apply { + this.statusBarColor = Color.TRANSPARENT + decorView.systemUiVisibility = + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } + } + + private fun initBinding(){ + binding = DataBindingUtil.setContentView(this, R.layout.activity_map) + binding.lifecycleOwner = this + binding.viewModel = mapViewModel + } + + private fun collectViewModel() { + lifecycleScope.launch { + mapViewModel.lastVisitedPlace.collect { place -> + place?.let { + updateMapWithPlaceData(it) + showBottomSheet(it) + } + } + } + } + private fun initSwipeRefreshLayout() { + binding.swipeRefreshLayout.setOnRefreshListener { + if (!isNetworkAvailable()) { + showErrorPage(Exception("네트워크 연결 오류")) + }else{ + showMapPage() + showBottomSheet(mapViewModel.lastVisitedPlace.value) + binding.swipeRefreshLayout.isEnabled = false + } + binding.swipeRefreshLayout.isRefreshing = false + } + } + private fun initMapView() { + binding.mapView.start(object : MapLifeCycleCallback() { + override fun onMapDestroy() {} + override fun onMapError(error: Exception) { + showErrorPage(error) + } + }, object : KakaoMapReadyCallback() { + override fun onMapReady(map: KakaoMap) { + kakaoMap = map + if (!isNetworkAvailable()) { + showErrorPage(Exception("네트워크 연결 오류")) + }else{ + binding.swipeRefreshLayout.isEnabled = false + initMapPage() + } + } + }) + } + + private fun isNetworkAvailable(): Boolean { + return PlaceApplication.isNetworkActive() + } + + private fun initMapPage(){ + showMapPage() + mapViewModel.loadLastVisitedPlace() + } + + private fun showMapPage(){ + binding.tvErrorMessage.visibility = View.GONE + binding.searchView.visibility = View.VISIBLE + binding.mapView.visibility = View.VISIBLE + } + + private fun showErrorPage(error: Exception) { + binding.tvErrorMessage.visibility = View.VISIBLE + binding.mapView.visibility = View.GONE + binding.searchView.visibility = View.GONE + binding.tvErrorMessage.text = "지도 인증에 실패했습니다.\n다시 시도해주세요.\n" + error.message + } + + private fun initSearchView() { + binding.searchView.setOnClickListener { + val intent = Intent(this, SearchActivity::class.java) + resultLauncher.launch(intent) + } + } + + private fun setResultLauncher() { + resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val placeData = result.data?.getSerializableExtra("placeData") as? Place + placeData?.let { + mapViewModel.saveLastVisitedPlace(it) + } + } + } + } + + private fun updateMapWithPlaceData(place: Place) { + val cameraUpdate = CameraUpdateFactory.newCenterPosition( + LatLng.from(place.yPos.toDouble(), place.xPos.toDouble()), 15 + ) + kakaoMap.moveCamera(cameraUpdate) + + val styles = kakaoMap.labelManager?.addLabelStyles( + LabelStyles.from(LabelStyle.from(R.drawable.icon_location3)) + ) + val options = LabelOptions.from( + LatLng.from(place.yPos.toDouble(), place.xPos.toDouble()) + ).setStyles(styles) + + val layer = kakaoMap.labelManager?.layer + layer?.addLabel(options) + } + + private fun showBottomSheet(place: Place?) { + val bottomSheet = MapBottomSheet() + place?.let { + val args = Bundle() + args.putSerializable("place", it) + bottomSheet.arguments = args } + bottomSheet.show(supportFragmentManager, bottomSheet.tag) + } + + override fun onResume() { + super.onResume() + binding.mapView.resume() + } + + override fun onPause() { + super.onPause() + binding.mapView.pause() + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/map/MapBottomSheet.kt b/app/src/main/java/campus/tech/kakao/map/presentation/map/MapBottomSheet.kt new file mode 100644 index 00000000..7f1acf17 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/map/MapBottomSheet.kt @@ -0,0 +1,32 @@ +package campus.tech.kakao.map.presentation.map + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.BottomSheetBinding +import campus.tech.kakao.map.domain.model.Place +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class MapBottomSheet : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetBinding + private var place: Place? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = BottomSheetBinding.inflate(inflater, container, false) + place = arguments?.getSerializable("place") as? Place + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.tvPlaceName.text = place?.place ?: "알림" + binding.tvPlaceAddress.text = place?.address ?: "원하는 장소를 검색해 주세요" + } + +} diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/map/MapViewModel.kt b/app/src/main/java/campus/tech/kakao/map/presentation/map/MapViewModel.kt new file mode 100644 index 00000000..064b73e0 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/map/MapViewModel.kt @@ -0,0 +1,34 @@ +package campus.tech.kakao.map.presentation.map + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import campus.tech.kakao.map.data.LastVisitedPlaceManager +import campus.tech.kakao.map.domain.model.Place +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MapViewModel +@Inject +constructor(private val manager: LastVisitedPlaceManager): ViewModel() { + + private val _lastVisitedPlace = MutableStateFlow(null) + val lastVisitedPlace: StateFlow get() = _lastVisitedPlace.asStateFlow() + fun loadLastVisitedPlace() { + viewModelScope.launch(Dispatchers.IO) { + val place = manager.getLastVisitedPlace() + _lastVisitedPlace.value = place + } + } + fun saveLastVisitedPlace(place: Place) { + viewModelScope.launch(Dispatchers.IO) { + manager.saveLastVisitedPlace(place) + _lastVisitedPlace.value = place + } + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchActivity.kt b/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchActivity.kt new file mode 100644 index 00000000..5cdb63e0 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchActivity.kt @@ -0,0 +1,119 @@ +package campus.tech.kakao.map.presentation.search + +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.View +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.ActivityMainBinding +import campus.tech.kakao.map.presentation.adapter.SearchedPlaceAdapter +import campus.tech.kakao.map.presentation.adapter.LogAdapter +import campus.tech.kakao.map.domain.model.Place +import campus.tech.kakao.map.presentation.map.MapActivity +import campus.tech.kakao.map.util.PlaceMapper +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SearchActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var searchedPlaceAdapter: SearchedPlaceAdapter + private lateinit var logAdapter: LogAdapter + private val viewModel: SearchViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + init() + } + + private fun init() { + setStatusBarTransparent() + initBinding() + setupRecyclerViews() + observeViewModel() + } + + private fun setStatusBarTransparent() { + this.window?.apply { + this.statusBarColor = Color.TRANSPARENT + decorView.systemUiVisibility = + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } + } + + private fun initBinding() { + binding = DataBindingUtil.setContentView(this, R.layout.activity_main) + binding.lifecycleOwner = this + binding.viewModel = viewModel + } + + private fun setupRecyclerViews() { + setupSearchedPlaceRecyclerView() + setupLogRecyclerView() + } + + private fun setupSearchedPlaceRecyclerView() { + val searchedPlaceRecyclerView = binding.recyclerPlace + searchedPlaceAdapter = SearchedPlaceAdapter { place -> + viewModel.updateLogs(place) + handlePlaceClick(place) + } + + searchedPlaceRecyclerView.apply { + layoutManager = LinearLayoutManager(this@SearchActivity) + addItemDecoration(DividerItemDecoration(this@SearchActivity, DividerItemDecoration.VERTICAL )) + adapter = searchedPlaceAdapter + } + } + + private fun handlePlaceClick(place: Place) { + lifecycleScope.launch { + val selectedPlace = viewModel.getPlaceById(place.id) + val intent = Intent(this@SearchActivity, MapActivity::class.java).apply { + putExtra("placeData", selectedPlace) + } + setResult(RESULT_OK, intent) + finish() + } + } + + private fun setupLogRecyclerView() { + val logRecyclerView = binding.recyclerLog + logAdapter = LogAdapter { id -> + lifecycleScope.launch { + viewModel.removeLog(id) + } + } + + logRecyclerView.apply { + layoutManager = LinearLayoutManager(this@SearchActivity, RecyclerView.HORIZONTAL, false) + adapter = logAdapter + } + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewModel.searchedPlaces.collect { places -> + updateSearchedPlaceList(places) + binding.tvHelpMessage.visibility = if (places.isEmpty()) View.VISIBLE else View.GONE + } + } + + lifecycleScope.launch { + viewModel.logList.collect { logList -> + logAdapter.submitList(PlaceMapper.mapPlaces(logList)) + } + } + } + + private fun updateSearchedPlaceList(places: List) { + searchedPlaceAdapter.submitList(PlaceMapper.mapPlaces(places)) + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchUiState.kt b/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchUiState.kt new file mode 100644 index 00000000..9c3db290 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchUiState.kt @@ -0,0 +1,10 @@ +package campus.tech.kakao.map.presentation.search + +import campus.tech.kakao.map.domain.model.Place + +data class SearchUiState( + val isloading: Boolean = false, + val isError: Boolean = false, + val Places: List = emptyList() + +) diff --git a/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchViewModel.kt b/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchViewModel.kt new file mode 100644 index 00000000..f4e314ba --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/presentation/search/SearchViewModel.kt @@ -0,0 +1,77 @@ +package campus.tech.kakao.map.presentation.search + +import androidx.lifecycle.* +import campus.tech.kakao.map.domain.model.Place +import campus.tech.kakao.map.domain.repository.PlaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel +@Inject +constructor( private val repository: PlaceRepository) : ViewModel() { + + val searchText = MutableLiveData() + + private val _logList = MutableStateFlow>(emptyList()) + val logList: StateFlow> get() = _logList.asStateFlow() + + private val _searchedPlaces = searchText.asFlow() + .debounce(500L) + .flatMapLatest { keyword -> + if (keyword.isNotBlank()) { + flowOf(getPlaces(keyword)) + } else { + flowOf(emptyList()) + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val searchedPlaces: StateFlow> get() = _searchedPlaces + + init { + viewModelScope.launch(Dispatchers.IO) { + _logList.value = getLogs() + } + } + + fun clearSearch() { + searchText.value = "" + } + + private suspend fun getPlaces(keyword: String): List { + return repository.getPlaces(keyword) + } + + suspend fun getPlaceById(id: String): Place? { + return repository.getPlaceById(id) + } + + private suspend fun getLogs(): List { + return repository.getLogs() + } + + fun updateLogs(place: Place) { + viewModelScope.launch(Dispatchers.IO) { + val updatedList = _logList.value.toMutableList() + val existingLog = updatedList.find { it.id == place.id } + if (existingLog != null) { + updatedList.remove(existingLog) + updatedList.add(0, existingLog) + } else { + updatedList.add(0, place) + } + _logList.value = updatedList + repository.updateLogs(updatedList) + } + } + + fun removeLog(id: String) { + viewModelScope.launch(Dispatchers.IO) { + repository.removeLog(id) + _logList.value = getLogs() + } + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/util/DiffUtilCalback.kt b/app/src/main/java/campus/tech/kakao/map/util/DiffUtilCalback.kt new file mode 100644 index 00000000..7694ea34 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/util/DiffUtilCalback.kt @@ -0,0 +1,14 @@ +package campus.tech.kakao.map.util + +import androidx.recyclerview.widget.DiffUtil +import campus.tech.kakao.map.domain.model.Place + +class DiffUtilCallback: DiffUtil.ItemCallback(){ + override fun areItemsTheSame(oldItem: Place, newItem: Place): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Place, newItem: Place): Boolean { + return oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/util/PlaceMapper.kt b/app/src/main/java/campus/tech/kakao/map/util/PlaceMapper.kt new file mode 100644 index 00000000..77d8f211 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/util/PlaceMapper.kt @@ -0,0 +1,21 @@ +package campus.tech.kakao.map.util + +import campus.tech.kakao.map.domain.model.Place + +class PlaceMapper { + companion object{ + fun mapPlaces(places: List): List { + return places.map { place -> + place.copy(category = setCategoryName(place.category), + place = setPlaceName(place.place) + ) + } + } + private fun setPlaceName(placeName: String): String { + return if (placeName.length > 12){ placeName.take(10)+"..." } else placeName + } + private fun setCategoryName(categoryName: String): String { + return categoryName.split(" ").lastOrNull() ?: "" + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_cancel.xml b/app/src/main/res/drawable/icon_cancel.xml new file mode 100644 index 00000000..eae1d2b5 --- /dev/null +++ b/app/src/main/res/drawable/icon_cancel.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/icon_location.png b/app/src/main/res/drawable/icon_location.png new file mode 100644 index 00000000..b3782a34 Binary files /dev/null and b/app/src/main/res/drawable/icon_location.png differ diff --git a/app/src/main/res/drawable/icon_location3.png b/app/src/main/res/drawable/icon_location3.png new file mode 100644 index 00000000..365df3ae Binary files /dev/null and b/app/src/main/res/drawable/icon_location3.png differ diff --git a/app/src/main/res/drawable/icon_location_resize.xml b/app/src/main/res/drawable/icon_location_resize.xml new file mode 100644 index 00000000..f8c25aa8 --- /dev/null +++ b/app/src/main/res/drawable/icon_location_resize.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_search.xml b/app/src/main/res/drawable/icon_search.xml new file mode 100644 index 00000000..4b45fe81 --- /dev/null +++ b/app/src/main/res/drawable/icon_search.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_search2.xml b/app/src/main/res/drawable/icon_search2.xml new file mode 100644 index 00000000..0c7b81b5 --- /dev/null +++ b/app/src/main/res/drawable/icon_search2.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_x.png b/app/src/main/res/drawable/icon_x.png new file mode 100644 index 00000000..0f88813d Binary files /dev/null and b/app/src/main/res/drawable/icon_x.png differ diff --git a/app/src/main/res/drawable/searchview_background.xml b/app/src/main/res/drawable/searchview_background.xml new file mode 100644 index 00000000..2e1ea812 --- /dev/null +++ b/app/src/main/res/drawable/searchview_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/searchview_background2.xml b/app/src/main/res/drawable/searchview_background2.xml new file mode 100644 index 00000000..780edbb2 --- /dev/null +++ b/app/src/main/res/drawable/searchview_background2.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 24d17df2..b128ead5 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,104 @@ - - - - - + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_map.xml b/app/src/main/res/layout/activity_map.xml new file mode 100644 index 00000000..0872aad5 --- /dev/null +++ b/app/src/main/res/layout/activity_map.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/bottom_sheet.xml b/app/src/main/res/layout/bottom_sheet.xml new file mode 100644 index 00000000..d3c7b78b --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/layout/list_item.xml b/app/src/main/res/layout/list_item.xml new file mode 100644 index 00000000..86b8ce55 --- /dev/null +++ b/app/src/main/res/layout/list_item.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/log_item.xml b/app/src/main/res/layout/log_item.xml new file mode 100644 index 00000000..1615af83 --- /dev/null +++ b/app/src/main/res/layout/log_item.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 05ed4b9e..55490cf9 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,6 +2,8 @@ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c0..7f93135c 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2927e499..3fa8f862 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Sat Jun 15 19:44:23 KST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..1aa94a42 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,99 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +119,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f9..6689b85b 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle.kts b/settings.gradle.kts index 48bba0fc..bb703c82 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,12 +1,6 @@ pluginManagement { repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } + google() mavenCentral() gradlePluginPortal() } @@ -17,6 +11,7 @@ dependencyResolutionManagement { google() mavenCentral() maven("https://devrepo.kakao.com/nexus/repository/kakaomap-releases/") + maven("https://devrepo.kakao.com/nexus/content/groups/public/") } }