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 99676874..87e5cbb3 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ # android-map-notification +## step1 구현할 기능 목록 + +- [ ] serviceState 값이 ON_SERVICE인 경우 지도화면으로 넘어가는 기능 +- [ ] serviceState 값이 ON_SERVICE가 아닌 경우 serviceMessage 값을 띄우는 기능 +- [ ] 처음 앱을 접속할 때 초기 진입 화면을 띄우는 기능 + +------------------------------------ +## step2 구현할 기능 목록 + +- [ ] Notification을 발생시키능 기능 구현 +- [ ] 앱의 상태에 따라 커스텀 Notification을 발생시키는 기능 구현 +- [ ] Notification 창을 터치하면 초기 진입 화면이 호출하는 기능 구현. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 803085bd..7fd44c0e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,13 @@ +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-parcelize") id("com.google.gms.google-services") } @@ -12,6 +15,7 @@ android { namespace = "campus.tech.kakao.map" compileSdk = 34 + defaultConfig { applicationId = "campus.tech.kakao.map" minSdk = 26 @@ -19,7 +23,12 @@ android { versionCode = 1 versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "KAKAO_LOCAL_API_KEY", gradleLocalProperties(rootDir, providers).getProperty("KAKAO_LOCAL_API_KEY")) + buildConfigField("String", "KAKAO_BASE_URL", gradleLocalProperties(rootDir, providers).getProperty("KAKAO_BASE_URL")) + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", gradleLocalProperties(rootDir, providers).getProperty("KAKAO_NATIVE_APP_KEY")) + } buildTypes { @@ -41,12 +50,13 @@ android { buildFeatures { dataBinding = true + viewBinding = 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") @@ -55,26 +65,32 @@ dependencies { 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.activity:activity-ktx:1.9.1") 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") + implementation("androidx.activity:activity:1.8.0") kapt("androidx.room:room-compiler:2.6.1") 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.datastore:datastore-preferences:1.1.1") implementation(platform("com.google.firebase:firebase-bom:33.1.2")) implementation("com.google.firebase:firebase-analytics-ktx") implementation("com.google.firebase:firebase-config-ktx:22.0.0") implementation("com.google.firebase:firebase-messaging-ktx:24.0.0") - testImplementation("androidx.room:room-testing:2.6.1") 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") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test:rules:1.5.0") + androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1") + androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1") androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") @@ -82,4 +98,4 @@ dependencies { 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") -} +} \ No newline at end of file diff --git a/app/src/androidTest/java/campus/tech/kakao/map/view/MapActivityTest.kt b/app/src/androidTest/java/campus/tech/kakao/map/view/MapActivityTest.kt new file mode 100644 index 00000000..f1fc9670 --- /dev/null +++ b/app/src/androidTest/java/campus/tech/kakao/map/view/MapActivityTest.kt @@ -0,0 +1,69 @@ +package campus.tech.kakao.map.view + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.idling.CountingIdlingResource +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.Visibility +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.intent.Intents + +import org.junit.Test +import campus.tech.kakao.map.R +import org.junit.After +import org.junit.Before +import org.junit.Rule + + +class MapActivityTest{ + + + @get:Rule + val activityRule = ActivityScenarioRule(MapActivity::class.java) + + + @Before + fun setUp() { + Intents.init() + testInit() + } + + @After + fun tearDown() { + Intents.release() + } + @Test + // 초기 화면이 잘 구성되는지 + fun testInit() { + onView(withId(R.id.input_search_field)).check( + matches(isDisplayed())) + onView(withId(R.id.search_icon)).check( + matches(isDisplayed())) + onView(withId(R.id.error_text)).check( + matches(withEffectiveVisibility(Visibility.GONE))) + onView(withId(R.id.map_view)).check( + matches(isDisplayed())) + } + + @Test + // 오류가 났을 때 오류화면이 잘 띄워지는지 + fun testError(){ + activityRule.scenario.onActivity { activity -> + activity.showErrorMessageView("인증 과정 중 원인을 알 수 없는 에러가 발생했습니다") + } + onView(withId(R.id.error_text)).check(matches(isDisplayed())) + } + + @Test + // 액티비티가 잘 이동하는지 + fun testMoveSearchActivity(){ + onView(withId(R.id.input_search_field)).perform(click()) + intended(hasComponent("campus.tech.kakao.map.view.SearchActivity")) + } +} diff --git a/app/src/androidTest/java/campus/tech/kakao/map/view/SearchActivityTest.kt b/app/src/androidTest/java/campus/tech/kakao/map/view/SearchActivityTest.kt new file mode 100644 index 00000000..a21a03e8 --- /dev/null +++ b/app/src/androidTest/java/campus/tech/kakao/map/view/SearchActivityTest.kt @@ -0,0 +1,196 @@ +package campus.tech.kakao.map.view + + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.view.View +import android.widget.EditText +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.* +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.* +import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.* +import androidx.test.espresso.intent.matcher.IntentMatchers.* +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.rules.ActivityScenarioRule +import campus.tech.kakao.map.R +import campus.tech.kakao.map.utilities.Constants +import campus.tech.kakao.map.data.Place +import campus.tech.kakao.map.data.SavedPlace +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + + +class SearchActivityTest { + + @get:Rule + var activityRule = ActivityScenarioRule(SearchActivity::class.java) + + @Before + fun setUp() { + Intents.init() + } + + @After + fun tearDown() { + Intents.release() + } + + @Test + // 초기 화면이 잘 구성되는지 + fun testInit() { + onView(withId(R.id.search_result_recyclerView)).check( + matches(isDisplayed()) + ) + onView(withId(R.id.input_search_field)).check( + matches(isDisplayed()) + ) + onView(withId(R.id.no_search_result)).check( + matches(isDisplayed()) + ) + onView(withId(R.id.saved_search_recyclerView)).check( + matches(isDisplayed()) + ) + } + + @Test + // PlaceRecyclerView가 검색어를 입력했을 때 최대 15개의 데이터를 불러오는 지 확인 + fun testPlaceRecyclerView() { + + onView(withId(R.id.input_search_field)).check(matches(isDisplayed())) + onView(withId(R.id.search_result_recyclerView)).check(matches(isDisplayed())) + onView(withId(R.id.input_search_field)).perform(replaceText("전남대")) + Thread.sleep(1000) + + activityRule.scenario.onActivity { activity -> + val inputSearchField = activity.findViewById(R.id.input_search_field) + val placeRecyclerView = + activity.findViewById(R.id.search_result_recyclerView) + val itemCount = placeRecyclerView.adapter?.getItemCount() ?: 0 + assert(itemCount in 1..15) + } + } + + @Test + // recyclerview의 아이템을 클릭했을 때 이동이 잘 되는지 + fun testMoveToMapActivity() { + onView(withId(R.id.input_search_field)).check(matches(isDisplayed())) + onView(withId(R.id.search_result_recyclerView)).check(matches(isDisplayed())) + onView(withId(R.id.input_search_field)).perform(replaceText("전남대")) + Thread.sleep(1000) + lateinit var place: Place + + activityRule.scenario.onActivity { activity -> + val searchRecyclerView = + activity.findViewById(R.id.search_result_recyclerView) + val adapter = searchRecyclerView.adapter as PlaceViewAdapter + place = (adapter.getPlaceAtPosition(0)) + } + + val intent = Intent() + intent.putExtra(Constants.Keys.KEY_PLACE, place) + val result = Instrumentation.ActivityResult(Activity.RESULT_OK, intent) + onView(withId(R.id.search_result_recyclerView)).perform( + actionOnItemAtPosition( + 0, + click() + ) + ) + + intending(toPackage("campus.tech.kakao.map.view.MapActivity")).respondWith(result) + } + + @Test + // 아이템 클릭시 데이터가 저장되는지 + fun testSavePlace() { + onView(withId(R.id.input_search_field)).check(matches(isDisplayed())) + onView(withId(R.id.search_result_recyclerView)).check(matches(isDisplayed())) + onView(withId(R.id.input_search_field)).perform(replaceText("전남대")) + Thread.sleep(1000) + + var existTaget: Boolean + lateinit var place: Place + activityRule.scenario.onActivity { activity -> + val searchRecyclerView = + activity.findViewById(R.id.search_result_recyclerView) + val placeViewAdapter = searchRecyclerView.adapter as PlaceViewAdapter + place = (placeViewAdapter.getPlaceAtPosition(0)) + } + + onView(withId(R.id.search_result_recyclerView)).perform( + actionOnItemAtPosition(0,click()) + ) + + val newScenario = ActivityScenario.launch(SearchActivity::class.java) + + newScenario.onActivity { activity -> + val savedPlaceRecyclerView = + activity.findViewById(R.id.saved_search_recyclerView) + val savedPlaceAdapter = savedPlaceRecyclerView.adapter as SavedPlaceViewAdapter + existTaget = savedPlaceAdapter.existPlace(SavedPlace(place.name)) + assertTrue(existTaget) + } + } + + @Test + // 삭제 버튼 클릭시 저장된 데이터가 삭제되는지 + fun testRemoveSavedPlace() { + onView(withId(R.id.input_search_field)).check(matches(isDisplayed())) + onView(withId(R.id.search_result_recyclerView)).check(matches(isDisplayed())) + Thread.sleep(1000) + + var existTaget: Boolean + lateinit var savedPlace: SavedPlace + lateinit var savedPlaceRecyclerView : RecyclerView + lateinit var savedPlaceAdapter : SavedPlaceViewAdapter + + activityRule.scenario.onActivity { activity -> + savedPlaceRecyclerView = + activity.findViewById(R.id.saved_search_recyclerView) + savedPlaceAdapter = savedPlaceRecyclerView.adapter as SavedPlaceViewAdapter + + savedPlace = (savedPlaceAdapter.getSavedPlaceAtPosition(0)) + existTaget = savedPlaceAdapter.existPlace(savedPlace) + assertTrue(existTaget) + } + + onView(withId(R.id.saved_search_recyclerView)).perform( + actionOnItemAtPosition(0, MyViewAction.clickChildViewWithId(R.id.button_saved_delete)) + ) + Thread.sleep(1000) + + activityRule.scenario.onActivity { activity -> + existTaget = savedPlaceAdapter.existPlace(savedPlace) + assertFalse(existTaget) + } + + + } + object MyViewAction { + fun clickChildViewWithId(id: Int): ViewAction { + return object : ViewAction { + override fun getConstraints(): org.hamcrest.Matcher { + return isAssignableFrom(View::class.java) + } + + override fun getDescription(): String { + return "Click on a child view with specified id." + } + + override fun perform(uiController: UiController?, view: View) { + val v = view.findViewById(id) + v.performClick() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2e7b45a..57e2e2f1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + @@ -25,6 +33,28 @@ + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/FirebaseMessagingService.kt b/app/src/main/java/campus/tech/kakao/map/FirebaseMessagingService.kt new file mode 100644 index 00000000..c630cc24 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/FirebaseMessagingService.kt @@ -0,0 +1,70 @@ +package campus.tech.kakao.map + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationCompat +import campus.tech.kakao.map.utilities.Constants +import campus.tech.kakao.map.view.SplashActivity +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +class AppFirebaseMessagingService : FirebaseMessagingService() { + private lateinit var notificationManager: NotificationManager + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + Log.d("testtt", "From: ${remoteMessage.from}") + + remoteMessage.notification?.let { + Log.d("testtt", "Message Notification Body: ${it.body}") + } + notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + createNotificationChannel() + sendNotification() + } + + private fun createNotificationChannel() { + val descriptionText = "알림 테스트" + val channel = NotificationChannel( + Constants.FirebaseMessage.CHANNEL_ID, + Constants.FirebaseMessage.CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = descriptionText + } + notificationManager.createNotificationChannel(channel) + } + + private fun sendNotification(){ + + val intent = Intent(this, SplashActivity::class.java).apply { // 클릭하면 출력될 Activity + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + val builder = NotificationCompat.Builder( + this, + Constants.FirebaseMessage.CHANNEL_ID + ) + .setSmallIcon(R.drawable.map_app_splash_screen) // 필수 + .setContentTitle("포그라운드 알림") + .setContentText("앱이 실행 중입니다.") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText("포그라운드 발생.") + ) + .setAutoCancel(true) // 만약 없으면 notification 눌러도 사라지지 않음 + + + notificationManager.notify(Constants.FirebaseMessage.NOTIFICATION_ID, builder.build()) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/MainApplication.kt b/app/src/main/java/campus/tech/kakao/map/MainApplication.kt new file mode 100644 index 00000000..efc54cb1 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/MainApplication.kt @@ -0,0 +1,13 @@ +package campus.tech.kakao.map + +import android.app.Application +import com.kakao.vectormap.KakaoMapSdk +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + KakaoMapSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/api/KakaoApiDataSource.kt b/app/src/main/java/campus/tech/kakao/map/api/KakaoApiDataSource.kt new file mode 100644 index 00000000..5abae54c --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/api/KakaoApiDataSource.kt @@ -0,0 +1,53 @@ +package campus.tech.kakao.map.api + +import android.util.Log +import campus.tech.kakao.map.BuildConfig +import campus.tech.kakao.map.data.Place +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.create + +class KakaoApiDataSource { + object KakaoRetrofitInstance { + + val kakaoLocalApi : KakaoLocalApi = getApiClient().create() + + private fun getApiClient(): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.KAKAO_BASE_URL) + .client(provideOkHttpClient(AppInterceptor())) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + private fun provideOkHttpClient(interceptor: AppInterceptor): OkHttpClient + = OkHttpClient.Builder().run { + addInterceptor(interceptor) + build() + } + + class AppInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain) : okhttp3.Response = with(chain) { + val newRequest = request().newBuilder() + .addHeader("Authorization", BuildConfig.KAKAO_LOCAL_API_KEY) + .build() + proceed(newRequest) + } + } + } + + suspend fun getPlaceData(text: String) : List { + val emptyList = listOf() + val kakaoApi = KakaoRetrofitInstance.kakaoLocalApi + return try{ + val placeList = kakaoApi.getPlaceData(text) + Log.d("coroutineTest", "return") + placeList.documents ?: emptyList + } catch (e : Exception){ + Log.d("coroutineTest", e.toString()) + emptyList + } + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/api/KakaoLocalApi.kt b/app/src/main/java/campus/tech/kakao/map/api/KakaoLocalApi.kt new file mode 100644 index 00000000..f0000403 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/api/KakaoLocalApi.kt @@ -0,0 +1,13 @@ +package campus.tech.kakao.map.api + +import campus.tech.kakao.map.data.ResultSearch +import retrofit2.http.GET +import retrofit2.http.Query + +interface KakaoLocalApi { + @GET("v2/local/search/keyword.json") + suspend fun getPlaceData( + @Query("query") query: String + ): ResultSearch + +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/CoroutineDispatcher.kt b/app/src/main/java/campus/tech/kakao/map/data/CoroutineDispatcher.kt new file mode 100644 index 00000000..c3b7148b --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/CoroutineDispatcher.kt @@ -0,0 +1,6 @@ +package campus.tech.kakao.map.data + +import javax.inject.Qualifier + +@Qualifier +annotation class CoroutineIoDispatcher \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/Place.kt b/app/src/main/java/campus/tech/kakao/map/data/Place.kt new file mode 100644 index 00000000..458ea00e --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/Place.kt @@ -0,0 +1,17 @@ +package campus.tech.kakao.map.data + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +data class ResultSearch( + val documents: List +) +@Parcelize +data class Place( + @SerializedName("place_name") val name : String, + @SerializedName("road_address_name") val location : String?, + @SerializedName("category_group_name") val category : String?, + @SerializedName("x") val x: String? = "", + @SerializedName("y") val y: String? = "" +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/PlaceRepository.kt b/app/src/main/java/campus/tech/kakao/map/data/PlaceRepository.kt new file mode 100644 index 00000000..b43e8f05 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/PlaceRepository.kt @@ -0,0 +1,24 @@ +package campus.tech.kakao.map.data + +import campus.tech.kakao.map.api.KakaoApiDataSource +import campus.tech.kakao.map.data.Place +import com.kakao.vectormap.LatLng +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class PlaceRepository @Inject constructor(private val kakaoApiDataSource : KakaoApiDataSource, private val positionDataSource : PositionDataSource){ + suspend fun getKakaoLocalPlaceData(text : String) : List{ + val placeList = kakaoApiDataSource.getPlaceData(text) + return placeList + } + + suspend fun saveCurrentPosToDataStore(latitude : Double, longitude : Double){ + positionDataSource.putPos(latitude, longitude) + } + + suspend fun fetchLastPosFromDataStore() : LatLng{ + val lastPos = positionDataSource.pos.first() + return lastPos + } + +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/PositionDataSource.kt b/app/src/main/java/campus/tech/kakao/map/data/PositionDataSource.kt new file mode 100644 index 00000000..faa50818 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/PositionDataSource.kt @@ -0,0 +1,34 @@ +package campus.tech.kakao.map.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import campus.tech.kakao.map.utilities.Constants +import campus.tech.kakao.map.view.MapActivity +import com.kakao.vectormap.LatLng +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PositionDataSource @Inject constructor(private val dataStore: DataStore) { + + companion object { + val KEY_LATITUDE = doublePreferencesKey("latitude") + val KEY_LONGITUDE = doublePreferencesKey("longitude") + } + + suspend fun putPos(latitude : Double, longitude : Double){ + dataStore.edit { preferences -> + preferences[KEY_LATITUDE] = latitude + preferences[KEY_LONGITUDE] = longitude + } + } + + val pos = dataStore.data.map { preferences -> + val latitude = preferences[KEY_LATITUDE] ?: Constants.ChonnamUnivLocation.LATITUDE.toDouble() + val longitude = preferences[KEY_LONGITUDE] ?: Constants.ChonnamUnivLocation.LONGITUDE.toDouble() + LatLng.from(latitude, longitude) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/RemoteConfigRepository.kt b/app/src/main/java/campus/tech/kakao/map/data/RemoteConfigRepository.kt new file mode 100644 index 00000000..a9cea67a --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/RemoteConfigRepository.kt @@ -0,0 +1,25 @@ +package campus.tech.kakao.map.data + +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.view.isVisible +import campus.tech.kakao.map.utilities.Constants +import campus.tech.kakao.map.view.MapActivity +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.tasks.await +import java.util.concurrent.Flow +import javax.inject.Inject + +class RemoteConfigRepository @Inject constructor(private val remoteConfig: FirebaseRemoteConfig) { + + suspend fun getRemoteConfig() = flow { + Log.d("testtt", "3번") + val result = remoteConfig.fetchAndActivate().await() + val state = remoteConfig.getString(Constants.RemoteConfigKeys.KEY_SERVICE_STATE) + val serviceMessage = remoteConfig.getString(Constants.RemoteConfigKeys.KEY_SERVICE_MESSAGE) + emit(RemoteConfigs(state, serviceMessage)) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/RemoteConfigs.kt b/app/src/main/java/campus/tech/kakao/map/data/RemoteConfigs.kt new file mode 100644 index 00000000..f49a3f7c --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/RemoteConfigs.kt @@ -0,0 +1,6 @@ +package campus.tech.kakao.map.data + +data class RemoteConfigs( + val state : String, + val message : String +) \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/SavedPlace.kt b/app/src/main/java/campus/tech/kakao/map/data/SavedPlace.kt new file mode 100644 index 00000000..2adddc44 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/SavedPlace.kt @@ -0,0 +1,10 @@ +package campus.tech.kakao.map.data + +import androidx.room.Entity +import androidx.room.PrimaryKey +import campus.tech.kakao.map.utilities.PlaceContract + +@Entity(tableName = PlaceContract.SavedPlaceEntry.TABLE_NAME) +data class SavedPlace( + @PrimaryKey val name: String +) \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceDao.kt b/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceDao.kt new file mode 100644 index 00000000..709c1d5e --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceDao.kt @@ -0,0 +1,21 @@ +package campus.tech.kakao.map.data + +import androidx.room.Dao +import androidx.room.Query +import campus.tech.kakao.map.utilities.PlaceContract + +@Dao +interface SavedPlaceDao { + + @Query("SELECT * FROM ${PlaceContract.SavedPlaceEntry.TABLE_NAME}") + fun readSavedPlaceData(): List + + @Query("SELECT * FROM ${PlaceContract.SavedPlaceEntry.TABLE_NAME} WHERE ${PlaceContract.SavedPlaceEntry.COLUMN_NAME} = :name") + fun readSavedPlaceDataWithSamedName(name: String): List + + @Query("DELETE FROM ${PlaceContract.SavedPlaceEntry.TABLE_NAME} WHERE ${PlaceContract.SavedPlaceEntry.COLUMN_NAME} = :name") + fun deleteSavedPlace(name: String) + + @Query("INSERT INTO ${PlaceContract.SavedPlaceEntry.TABLE_NAME} (${PlaceContract.SavedPlaceEntry.COLUMN_NAME}) VALUES (:name)") + fun insertSavedPlaceData(name:String) +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceDatabase.kt b/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceDatabase.kt new file mode 100644 index 00000000..154718a6 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceDatabase.kt @@ -0,0 +1,12 @@ +package campus.tech.kakao.map.data + +import androidx.room.Database +import androidx.room.RoomDatabase +import campus.tech.kakao.map.utilities.PlaceContract + +@Database(entities = [SavedPlace::class], version = PlaceContract.VERSION, exportSchema = false) +abstract class SavedPlaceDatabase : RoomDatabase() { + + abstract fun savedPlaceDao(): SavedPlaceDao +} + diff --git a/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceRepository.kt b/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceRepository.kt new file mode 100644 index 00000000..181596c0 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/data/SavedPlaceRepository.kt @@ -0,0 +1,27 @@ +package campus.tech.kakao.map.data + +import android.util.Log +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SavedPlaceRepository @Inject constructor(private val savedPlaceDao : SavedPlaceDao){ + suspend fun getAllSavedPlace() : List = savedPlaceDao.readSavedPlaceData() + + + suspend fun writePlace(place: Place){ + val savedList = savedPlaceDao.readSavedPlaceDataWithSamedName(place.name) + if (savedList.isNotEmpty()) { + Log.d("testt", "데이터 중복") + // 입력의 시간순대로 정렬되기 떄문에 레코드 삭제후 다시 집어넣기 + savedPlaceDao.deleteSavedPlace(place.name) + savedPlaceDao.insertSavedPlaceData(place.name) + } else { + savedPlaceDao.insertSavedPlaceData(place.name) + } + } + + suspend fun deleteSavedPlace(savedPlace: SavedPlace){ + savedPlaceDao.deleteSavedPlace(savedPlace.name) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/di/DispatcherModule.kt b/app/src/main/java/campus/tech/kakao/map/di/DispatcherModule.kt new file mode 100644 index 00000000..272fb9e0 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/di/DispatcherModule.kt @@ -0,0 +1,18 @@ +package campus.tech.kakao.map.di + +import campus.tech.kakao.map.data.CoroutineIoDispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@Module +@InstallIn(SingletonComponent::class) +internal object DispatchersModule { + + @Provides + @CoroutineIoDispatcher + fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/di/LocalDataSourceModule.kt b/app/src/main/java/campus/tech/kakao/map/di/LocalDataSourceModule.kt new file mode 100644 index 00000000..3a5e33f7 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/di/LocalDataSourceModule.kt @@ -0,0 +1,51 @@ +package campus.tech.kakao.map.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import androidx.room.Room +import campus.tech.kakao.map.utilities.Constants +import campus.tech.kakao.map.data.SavedPlaceDao +import campus.tech.kakao.map.data.SavedPlaceDatabase +import campus.tech.kakao.map.utilities.PlaceContract +import com.google.firebase.Firebase +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings +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 + +@InstallIn(SingletonComponent::class) +@Module +class LocalDataSourceModule { + + @Singleton + @Provides + fun provideRoomDatabase(@ApplicationContext context: Context): SavedPlaceDatabase { + return Room.databaseBuilder( + context, SavedPlaceDatabase::class.java, PlaceContract.DATABASE_NAME + ).build() + } + + @Provides + fun providePlantDao(savedPlaceDatabase: SavedPlaceDatabase): SavedPlaceDao { + return savedPlaceDatabase.savedPlaceDao() + } + + private val Context.dataStore: DataStore by preferencesDataStore(name = Constants.DataStore.PREFERENCES_NAME) + + @Singleton + @Provides + fun provideDataStore(@ApplicationContext context: Context) : DataStore{ + return context.dataStore + } + + + + +} \ 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..dd399c41 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/di/NetworkModule.kt @@ -0,0 +1,18 @@ +package campus.tech.kakao.map.di + +import campus.tech.kakao.map.api.KakaoApiDataSource +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class NetworkModule{ + @Singleton + @Provides + fun provideKakaoApiDataSource(): KakaoApiDataSource { + return KakaoApiDataSource() + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/di/firebaseModule.kt b/app/src/main/java/campus/tech/kakao/map/di/firebaseModule.kt new file mode 100644 index 00000000..98f5d883 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/di/firebaseModule.kt @@ -0,0 +1,26 @@ +package campus.tech.kakao.map.di + +import com.google.firebase.Firebase +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class FirebaseModule { + @Singleton + @Provides + fun provideRemoteConfig() : FirebaseRemoteConfig { + val configSettings = remoteConfigSettings { + minimumFetchIntervalInSeconds = 0 + } + val firebase = Firebase.remoteConfig + firebase.setConfigSettingsAsync(configSettings) + return firebase + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/utilities/Constants.kt b/app/src/main/java/campus/tech/kakao/map/utilities/Constants.kt new file mode 100644 index 00000000..7e8dfc7b --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/utilities/Constants.kt @@ -0,0 +1,27 @@ +package campus.tech.kakao.map.utilities + +class Constants { + object Keys{ + const val KEY_PLACE = "place" + } + + object DataStore{ + const val PREFERENCES_NAME = "location_preferences" + } + + object ChonnamUnivLocation { + const val LATITUDE = "35.175487" + const val LONGITUDE = "126.907163" + } + + object RemoteConfigKeys{ + const val KEY_SERVICE_STATE = "serviceState" + const val KEY_SERVICE_MESSAGE = "serviceMessage" + } + + object FirebaseMessage{ + const val NOTIFICATION_ID = 1 + const val CHANNEL_ID = "main_default_channel" + const val CHANNEL_NAME = "main channelName" + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/utilities/PlaceContract.kt b/app/src/main/java/campus/tech/kakao/map/utilities/PlaceContract.kt new file mode 100644 index 00000000..742f885a --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/utilities/PlaceContract.kt @@ -0,0 +1,13 @@ +package campus.tech.kakao.map.utilities + +import android.provider.BaseColumns + +object PlaceContract{ + const val DATABASE_NAME = "PLACE.DB" + const val VERSION = 1 + + object SavedPlaceEntry : BaseColumns{ + const val TABLE_NAME = "SAVED_PLACE" + const val COLUMN_NAME = "name" + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/view/ClickListener.kt b/app/src/main/java/campus/tech/kakao/map/view/ClickListener.kt new file mode 100644 index 00000000..29feefae --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/view/ClickListener.kt @@ -0,0 +1,13 @@ +package campus.tech.kakao.map.view + +import campus.tech.kakao.map.data.Place +import campus.tech.kakao.map.data.SavedPlace + +interface OnClickPlaceListener { + fun savePlace(place: Place) +} + +interface OnClickSavedPlaceListener { + fun deleteSavedPlace(savedPlace: SavedPlace) + fun loadPlace(savedPlace: SavedPlace) +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/view/MapActivity.kt b/app/src/main/java/campus/tech/kakao/map/view/MapActivity.kt new file mode 100644 index 00000000..ec2e4112 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/view/MapActivity.kt @@ -0,0 +1,214 @@ +package campus.tech.kakao.map.view + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.activity.result.ActivityResult +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.core.app.ActivityOptionsCompat +import androidx.core.view.isVisible +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.ActivityMapBinding +import campus.tech.kakao.map.databinding.BottomSheetBinding +import campus.tech.kakao.map.utilities.Constants +import campus.tech.kakao.map.data.Place +import campus.tech.kakao.map.viewmodel.MapActivityViewModel +import com.google.android.material.bottomsheet.BottomSheetBehavior +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 bottomSheetBinding : BottomSheetBinding + lateinit var map: MapView + lateinit var kakaoMap : KakaoMap + lateinit var resultLauncher : ActivityResultLauncher + lateinit var bottomSheetBehavior : BottomSheetBehavior + private val viewModel : MapActivityViewModel by viewModels() + var isMapDisplay = false + var isInitState = true + + enum class ErrorCode(val code: String, val errorMessage : String){ + UNKNOWN_ERROR("-1", "인증 과정 중 원인을 알 수 없는 에러가 발생했습니다"), + CONNECTION_ERROR("-2", "통신 연결 시도 중 에러가 발생하였습니다"), + SOCKET_TIMEOUT("-3", "통신 연결 중 SocketTimeoutException 에러가 발생하였습니다"), + CONNECT_TIMEOUT("-4", "통신 시도 중 ConnectTimeoutException 에러가 발생하였습니다"), + BAD_REQUEST("400", "요청을 처리하지 못하였습니다"), + AUTHORIZED_FAILURE("401", "인증 오류가 발생하였습니다. 인증 자격 증명이 충분치 않습니다"), + FORBIDDEN("403", "권한 오류가 발생하였습니다"), + TOO_MANY_REQUESTS("429", "정해진 사용량이나, 초당 요청 한도를 초과하였습니다"), + CONNECTION_FAILURE("499", "통신이 실패하였습니다. 인터넷 연결을 확인해주십시오"), + UNKNOWN("X", "오류 코드 X"); + + companion object { + fun getErrorMessage(errorText: String): ErrorCode { + return entries.find { errorText == it.code } ?: UNKNOWN + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + initVar() + initMapView() + initBottomSheet() + initClickListener() + initResultLauncher() + initObserver() + + } + + + private fun initVar() { + binding = DataBindingUtil.setContentView(this, R.layout.activity_map) + binding.viewModel = viewModel + binding.lifecycleOwner = this + bringFrontSearchField() + } + + + private fun initBottomSheet(){ + bottomSheetBinding = binding.bottomSheetInclude + bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetBinding.bottomSheet) + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + + private fun initMapView() { + map = findViewById(R.id.map_view) + map.start(object : MapLifeCycleCallback() { + override fun onMapDestroy() { + Log.d("testt", "MapDestroy") + } + + override fun onMapError(error: Exception) { + Log.d("testt", "string : " + error.toString()) + Log.d("testt", "message : " + error.message.toString()) + Log.d("testt", "hashCode : " + error.localizedMessage) + showErrorMessageView(error.message.toString()) + } + }, object : KakaoMapReadyCallback() { + override fun onMapReady(kakaoMap: KakaoMap) { + Log.d("testt", "MapReady") + binding.errorText.isVisible = false + this@MapActivity.kakaoMap = kakaoMap + isMapDisplay = true + viewModel.getRecentPos() + } + }) + } + + private fun bringFrontSearchField() { + binding.inputSearchField.bringToFront() + binding.searchIcon.bringToFront() + } + + private fun initClickListener() { + binding.inputSearchField.setOnClickListener { + moveSearchPage(it) + } + } + + private fun initResultLauncher(){ + resultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val place = getPlaceToResult(result) + val latitude = place?.y?.toDouble() ?: Constants.ChonnamUnivLocation.LATITUDE.toDouble() + val longitude = place?.x?.toDouble()?: Constants.ChonnamUnivLocation.LONGITUDE.toDouble() + val pos = LatLng.from(latitude, longitude) + Log.d("testtt", "LAT" + pos.toString()) + isInitState = false + bottomSheetBinding.place = place + Log.d("placeTest", "Place : ${place}, Binding : ${bottomSheetBinding.place}") + viewModel.setRecentPos(latitude, longitude) + } + } + } + + fun initObserver(){ + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED){ + viewModel.recentPos.collect{ + Log.d("testtt", "밖" + it.toString()) + if(isMapDisplay and !isInitState) { + Log.d("testtt", "안" + it.toString()) + moveMapCamera(it) + createLabel(it) + bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + } + else if (isMapDisplay) moveMapCamera(it) + } + } + } + } + + private fun moveSearchPage(view: View) { + val intent = Intent(this, SearchActivity::class.java) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + this, view, "inputFieldTransition" + ) + resultLauncher.launch(intent, options) + } + + fun showErrorMessageView(error: String) { + binding.errorText.isVisible = true + val errorCode = getErrorCode(error) + val errorText = ErrorCode.getErrorMessage(errorCode).errorMessage + "\n\n" + error + binding.errorText.text = errorText + } + + private fun getErrorCode(errorText: String): String { + val regex = Regex("\\((\\d+)\\)") + val code = regex.find(errorText) + Log.d("testt", "errorcode" + code?.groups?.get(1)?.value) + return code?.groups?.get(1)?.value ?: "" + } + + private fun getPlaceToResult(result: ActivityResult): Place? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result.data?.getParcelableExtra(Constants.Keys.KEY_PLACE, Place::class.java) + } else { + result.data?.getParcelableExtra(Constants.Keys.KEY_PLACE) + } + } + + private fun moveMapCamera(pos : LatLng){ + kakaoMap.moveCamera(CameraUpdateFactory.newCenterPosition(pos)) + } + + private fun removeAllLabel(){ + kakaoMap.labelManager?.clearAll() + } + + private fun createLabel(pos : LatLng){ + val labelManager = kakaoMap.labelManager + removeAllLabel() + val style = labelManager + ?.addLabelStyles(LabelStyles.from(LabelStyle.from(R.drawable.ic_location_marker_2).setAnchorPoint(0.5f, 1f))) + var label = kakaoMap.getLabelManager()?.getLayer()?.addLabel(LabelOptions.from("center",pos).setStyles(style).setRank(1)) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/view/PlaceViewAdapter.kt b/app/src/main/java/campus/tech/kakao/map/view/PlaceViewAdapter.kt new file mode 100644 index 00000000..8f9e0b95 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/view/PlaceViewAdapter.kt @@ -0,0 +1,54 @@ +package campus.tech.kakao.map.view + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.databinding.DataBindingUtil +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.PlaceItemBinding +import campus.tech.kakao.map.data.Place + +class PlaceViewAdapter( + val listener: OnClickPlaceListener +) : ListAdapter(PlaceDiffCallBack()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlaceViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = DataBindingUtil.inflate(inflater, R.layout.place_item, parent, false) + return PlaceViewHolder(binding, listener) + } + + override fun onBindViewHolder(holder: PlaceViewHolder, position: Int) { + val currentPlace = getItem(position) + holder.bind(currentPlace) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun getPlaceAtPosition(position : Int) : Place{ + return getItem(position) + } +} + +class PlaceDiffCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Place, newItem: Place): Boolean { + return oldItem === newItem + } + + override fun areContentsTheSame(oldItem: Place, newItem: Place): Boolean { + return oldItem == newItem + } +} + +class PlaceViewHolder(private val binding : PlaceItemBinding, listener : OnClickPlaceListener) : + RecyclerView.ViewHolder(binding.root) { + init { + binding.listener = listener + } + + fun bind(place : Place){ + binding.place = place + } +} diff --git a/app/src/main/java/campus/tech/kakao/map/view/SavedPlaceViewAdapter.kt b/app/src/main/java/campus/tech/kakao/map/view/SavedPlaceViewAdapter.kt new file mode 100644 index 00000000..783f8ec4 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/view/SavedPlaceViewAdapter.kt @@ -0,0 +1,62 @@ +package campus.tech.kakao.map.view + +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.databinding.DataBindingUtil +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.SavedPlaceItemBinding +import campus.tech.kakao.map.data.SavedPlace + +class SavedPlaceViewAdapter( + val listener: OnClickSavedPlaceListener +) : ListAdapter(SavedPlaceDiffCallBack()) { + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SavedPlaceViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = DataBindingUtil.inflate(inflater, R.layout.saved_place_item, parent, false) + Log.d("testt", "저장된 장소를 띄우는 뷰 홀더 생성") + return SavedPlaceViewHolder(binding, listener) + } + + override fun onBindViewHolder(holder: SavedPlaceViewHolder, position: Int) { + val currentSavedPlace = getItem(position) + holder.bind(currentSavedPlace) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun existPlace(savedPlace: SavedPlace) : Boolean = currentList.contains(savedPlace) + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun getSavedPlaceAtPosition(position : Int) : SavedPlace{ + return getItem(position) + } +} + +class SavedPlaceDiffCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SavedPlace, newItem: SavedPlace): Boolean { + return oldItem === newItem + } + + override fun areContentsTheSame(oldItem: SavedPlace, newItem: SavedPlace): Boolean { + return oldItem.name == newItem.name + } +} + +class SavedPlaceViewHolder(private val binding: SavedPlaceItemBinding, listener: OnClickSavedPlaceListener) : + RecyclerView.ViewHolder(binding.root) { + + init { + binding.listener = listener + } + + fun bind(savedPlace: SavedPlace) { + binding.savedPlace = savedPlace + } + +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/view/SearchActivity.kt b/app/src/main/java/campus/tech/kakao/map/view/SearchActivity.kt new file mode 100644 index 00000000..3a291919 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/view/SearchActivity.kt @@ -0,0 +1,154 @@ +package campus.tech.kakao.map.view + +import android.content.Context +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.ImageView +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.ActivitySearchBinding +import campus.tech.kakao.map.utilities.Constants +import campus.tech.kakao.map.data.Place +import campus.tech.kakao.map.data.SavedPlace +import campus.tech.kakao.map.viewmodel.SearchActivityViewModel +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SearchActivity : AppCompatActivity(), OnClickPlaceListener, OnClickSavedPlaceListener { + private lateinit var binding : ActivitySearchBinding + lateinit var savedPlaceRecyclerViewAdapter: SavedPlaceViewAdapter + lateinit var searchRecyclerViewAdapter: PlaceViewAdapter + private val viewModel: SearchActivityViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + initBinding() + initListeners() + initRecyclerViews() + initObserver() + binding.inputSearchField.requestFocus() + } + + override fun deleteSavedPlace(savedPlace: SavedPlace) { + Log.d("testt", "삭제 콜백함수 처리") + viewModel.deleteSavedPlace(savedPlace) + } + + override fun loadPlace(savedPlace: SavedPlace){ + binding.inputSearchField.setText(savedPlace.name) + } + + override fun savePlace(place: Place) { + Log.d("testt", "콜백함수 처리") + viewModel.savePlace(place) + intent.putExtra(Constants.Keys.KEY_PLACE, place) + Log.d("testt", "Intent" + place.toString()) + setResult(RESULT_OK, intent) + finish() + } + + fun initBinding() { + binding = DataBindingUtil.setContentView(this, R.layout.activity_search) + binding.viewModel = viewModel + binding.lifecycleOwner = this + } + + fun initListeners() { + initDeleteButtonListener() + initInputFieldListener() + } + + fun initDeleteButtonListener() { + binding.buttonX.setOnClickListener { + binding.inputSearchField.setText("") + binding.inputSearchField.clearFocus() + binding.inputSearchField.parent.clearChildFocus(binding.inputSearchField) + val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(window.decorView.applicationWindowToken, 0) + } + } + + fun initInputFieldListener() { + binding.inputSearchField.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { + } + + override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { + } + + override fun afterTextChanged(searchText: Editable?) { + val text = searchText.toString() + Log.d("inputField", "text : ${text}") + Log.d("coroutine", "입력변경") + lifecycleScope.launch { + viewModel.getKakaoLocalData(text) + } + } + }) + } + + fun initRecyclerViews() { + initSearchRecyclerView() + initSavedPlaceRecyclerView() + } + + fun initSearchRecyclerView() { + searchRecyclerViewAdapter = PlaceViewAdapter(this) + binding.searchResultRecyclerView.layoutManager = LinearLayoutManager(this) + binding.searchResultRecyclerView.adapter = searchRecyclerViewAdapter + } + + fun initSavedPlaceRecyclerView() { + savedPlaceRecyclerViewAdapter = + SavedPlaceViewAdapter(this) + binding.savedSearchRecyclerView.layoutManager = + LinearLayoutManager(this, RecyclerView.HORIZONTAL, false) + binding.savedSearchRecyclerView.adapter = savedPlaceRecyclerViewAdapter + } + + fun initObserver(){ + initPlaceObserver() + initSavedPlaceObserver() + } + + fun initPlaceObserver(){ + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.place.collect { placeList -> + searchRecyclerViewAdapter.submitList(placeList) + } + } + } + } + + fun initSavedPlaceObserver(){ + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.savedPlace.collect { savedPlaceList -> + savedPlaceRecyclerViewAdapter.submitList(savedPlaceList) + if (savedPlaceList.isEmpty()) binding.savedSearchRecyclerView.visibility = View.GONE + else binding.savedSearchRecyclerView.visibility = View.VISIBLE + } + } + } + } +} + + + + + diff --git a/app/src/main/java/campus/tech/kakao/map/view/SplashActivity.kt b/app/src/main/java/campus/tech/kakao/map/view/SplashActivity.kt new file mode 100644 index 00000000..6d8e4dbc --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/view/SplashActivity.kt @@ -0,0 +1,152 @@ +package campus.tech.kakao.map.view + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import campus.tech.kakao.map.R +import campus.tech.kakao.map.databinding.ActivitySearchBinding +import campus.tech.kakao.map.databinding.ActivitySplashBinding +import campus.tech.kakao.map.viewmodel.SearchActivityViewModel +import campus.tech.kakao.map.viewmodel.SplashActivityViewModel +import campus.tech.kakao.map.viewmodel.SplashUIState +import campus.tech.kakao.map.viewmodel.UiState +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.Firebase +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.get +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SplashActivity : AppCompatActivity() { + private lateinit var binding: ActivitySplashBinding + private val viewModel: SplashActivityViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + askNotificationPermission() + + binding = ActivitySplashBinding.inflate(layoutInflater) + setContentView(binding.root) + initObserve() + + getFCMToken() + + + } + + fun initObserve() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.splashUiState.collect { uiState -> + Log.d("testtt", uiState.toString()) + UpdateSplash(uiState) + } + } + } + } + + fun UpdateSplash(uiState : SplashUIState){ + when (uiState) { + is SplashUIState.Loading -> { + binding.serviceMessage.isVisible = true + binding.serviceMessage.text = "로딩" + } + + is SplashUIState.OnService -> { + Handler(Looper.getMainLooper()).postDelayed({ + val mainIntent = + Intent(this@SplashActivity, MapActivity::class.java) + startActivity(mainIntent) + finish() + }, 2000) + } + + is SplashUIState.OffService -> { + binding.serviceMessage.isVisible = true + binding.serviceMessage.text = uiState.message + } + } + } + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted: Boolean -> + if (isGranted) { + Toast.makeText(this, "notification이 전송됩니다.", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "notification이 전송되지 않습니다.", Toast.LENGTH_SHORT).show() + } + } + + private fun askNotificationPermission() { + // This is only necessary for API level >= 33 (TIRAMISU) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == + PackageManager.PERMISSION_GRANTED + ) { + } else if (shouldShowRequestPermissionRationale(android.Manifest.permission.POST_NOTIFICATIONS)) { + // TODO 사용자에게 버튼 제공 + showNotificationPermissionDialog() + } else { + requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + private fun showNotificationPermissionDialog() { + AlertDialog.Builder(this@SplashActivity).apply { + setTitle("권한 확인") + setMessage( + String.format( + "다양한 알림 소식을 받기 위해 권한을 허용하시겠어요?\n(알림 에서 %s의 알림 권한을 허용해주세요.)", + getString(R.string.app_name) + ) + ) + setPositiveButton("yes") { _, _ -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", packageName, null) + intent.data = uri + startActivity(intent) + } + setNegativeButton("no") { _, _ -> } + show() + } + } + + fun getFCMToken(){ + FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + return@OnCompleteListener + } + Log.d("testtt", task.result.toString()) + + }) + } + + +} + diff --git a/app/src/main/java/campus/tech/kakao/map/viewmodel/MapActivityViewModel.kt b/app/src/main/java/campus/tech/kakao/map/viewmodel/MapActivityViewModel.kt new file mode 100644 index 00000000..9a2c3975 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/viewmodel/MapActivityViewModel.kt @@ -0,0 +1,45 @@ +package campus.tech.kakao.map.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import campus.tech.kakao.map.data.PlaceRepository +import campus.tech.kakao.map.data.PositionDataSource +import campus.tech.kakao.map.utilities.Constants +import com.kakao.vectormap.LatLng +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MapActivityViewModel @Inject constructor( + private val placeRepository: PlaceRepository +) : ViewModel() { + private val _recentPos = MutableStateFlow( + LatLng.from( + Constants.ChonnamUnivLocation.LATITUDE.toDouble(), + Constants.ChonnamUnivLocation.LONGITUDE.toDouble() + ) + ) + val recentPos: StateFlow get() = _recentPos + + fun getRecentPos(){ + viewModelScope.launch{ + _recentPos.value = placeRepository.fetchLastPosFromDataStore() + Log.d("testtt", "recent" + recentPos.value.toString()) + + } + } + + fun setRecentPos(latitude : Double, longitude : Double){ + viewModelScope.launch { + placeRepository.saveCurrentPosToDataStore(latitude, longitude) + getRecentPos() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/viewmodel/SearchActivityViewModel.kt b/app/src/main/java/campus/tech/kakao/map/viewmodel/SearchActivityViewModel.kt new file mode 100644 index 00000000..d5ed4133 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/viewmodel/SearchActivityViewModel.kt @@ -0,0 +1,79 @@ +package campus.tech.kakao.map.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import campus.tech.kakao.map.data.CoroutineIoDispatcher +import campus.tech.kakao.map.data.Place +import campus.tech.kakao.map.data.SavedPlace +import campus.tech.kakao.map.data.PlaceRepository +import campus.tech.kakao.map.data.SavedPlaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class UiState( + val isPlaceListEmpty : Boolean = true +) + + +@HiltViewModel +class SearchActivityViewModel @Inject constructor( + private val placeRepository: PlaceRepository, + private val savedPlaceRepository: SavedPlaceRepository, + @CoroutineIoDispatcher private val IoDispatcher : CoroutineDispatcher +) : ViewModel() { + private val _place = MutableStateFlow>(emptyList()) + private val _savedPlace = MutableStateFlow>(emptyList()) + val place: StateFlow> get() = _place.asStateFlow() + val savedPlace: StateFlow> get() = _savedPlace.asStateFlow() + private val _uiState = MutableStateFlow(UiState()) + val uiState : StateFlowget() = _uiState.asStateFlow() + + init { + getSavedPlace() + + } + + fun getSavedPlace() { + viewModelScope.launch(IoDispatcher) { + _savedPlace.value = savedPlaceRepository.getAllSavedPlace() + } + } + + fun savePlace(place: Place) { + viewModelScope.launch(IoDispatcher) { + savedPlaceRepository.writePlace(place) + getSavedPlace() + } + } + + fun deleteSavedPlace(savedPlace: SavedPlace) { + viewModelScope.launch(IoDispatcher){ + savedPlaceRepository.deleteSavedPlace(savedPlace) + getSavedPlace() + } + } + + suspend fun getKakaoLocalData(text: String) { + if (text.isNotEmpty()) { + val placeList = placeRepository.getKakaoLocalPlaceData(text) + _place.value = (placeList) + } else _place.value = listOf() + + _uiState.update {currentState -> + currentState.copy( + isPlaceListEmpty = if(_place.value.isEmpty()) true else false + ) + } + Log.d("testt", "value : ${uiState.value}") + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/viewmodel/SplashActivityViewModel.kt b/app/src/main/java/campus/tech/kakao/map/viewmodel/SplashActivityViewModel.kt new file mode 100644 index 00000000..916af958 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/viewmodel/SplashActivityViewModel.kt @@ -0,0 +1,52 @@ +package campus.tech.kakao.map.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import campus.tech.kakao.map.data.CoroutineIoDispatcher +import campus.tech.kakao.map.data.Place +import campus.tech.kakao.map.data.PlaceRepository +import campus.tech.kakao.map.data.RemoteConfigRepository +import campus.tech.kakao.map.data.SavedPlace +import campus.tech.kakao.map.data.SavedPlaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed class SplashUIState { + object Loading : SplashUIState() + object OnService : SplashUIState() + data class OffService(val message: String) : SplashUIState() +} + +@HiltViewModel +class SplashActivityViewModel @Inject constructor( + private val remoteConfigRepository: RemoteConfigRepository, +) : ViewModel() { + + private val _splashUiState = MutableStateFlow(SplashUIState.Loading) + val splashUiState : StateFlow + get() = _splashUiState.asStateFlow() + + init{ + getRemoteConfigs() + } + + fun getRemoteConfigs(){ + Log.d("testtt", "2번") + viewModelScope.launch { + remoteConfigRepository.getRemoteConfig().collect{ + if(it.state == "ON_SERVICE"){ + _splashUiState.value = SplashUIState.OnService + } else{ + _splashUiState.value = SplashUIState.OffService(it.message) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_location_marker.png b/app/src/main/res/drawable/ic_location_marker.png new file mode 100644 index 00000000..13a18c86 Binary files /dev/null and b/app/src/main/res/drawable/ic_location_marker.png differ diff --git a/app/src/main/res/drawable/ic_location_marker_2.png b/app/src/main/res/drawable/ic_location_marker_2.png new file mode 100644 index 00000000..5af03930 Binary files /dev/null and b/app/src/main/res/drawable/ic_location_marker_2.png differ diff --git a/app/src/main/res/drawable/map_app_splash_screen.png b/app/src/main/res/drawable/map_app_splash_screen.png new file mode 100644 index 00000000..7bb81ecb Binary files /dev/null and b/app/src/main/res/drawable/map_app_splash_screen.png differ diff --git a/app/src/main/res/drawable/map_app_splash_screen_final_resized.png b/app/src/main/res/drawable/map_app_splash_screen_final_resized.png new file mode 100644 index 00000000..2dc9c4e9 Binary files /dev/null and b/app/src/main/res/drawable/map_app_splash_screen_final_resized.png differ 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..21225175 --- /dev/null +++ b/app/src/main/res/layout/activity_map.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml new file mode 100644 index 00000000..253b0b79 --- /dev/null +++ b/app/src/main/res/layout/activity_search.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 00000000..800e54bd --- /dev/null +++ b/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,31 @@ + + + + + + + + + \ 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..48a2ecdf --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file 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..fb8ef55a --- /dev/null +++ b/app/src/main/res/layout/place_item.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/saved_place_item.xml b/app/src/main/res/layout/saved_place_item.xml new file mode 100644 index 00000000..e7357cfe --- /dev/null +++ b/app/src/main/res/layout/saved_place_item.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 768b058a..53d8fe9d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,6 @@ #FF000000 + #7C7C7C #FFFFFFFF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e5ba5b9c..79f73188 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ Map + 검색어를 입력해 주세요. + 검색 결과가 없습니다. + fcm_default_channel \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 05ed4b9e..e00c56c1 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,6 +3,7 @@