diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b06a0f8e0..0903a35b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + diff --git a/core/preferences/src/main/java/com/ku_stacks/ku_ring/preferences/PreferenceUtil.kt b/core/preferences/src/main/java/com/ku_stacks/ku_ring/preferences/PreferenceUtil.kt index 9de2c6724..579ec8f77 100644 --- a/core/preferences/src/main/java/com/ku_stacks/ku_ring/preferences/PreferenceUtil.kt +++ b/core/preferences/src/main/java/com/ku_stacks/ku_ring/preferences/PreferenceUtil.kt @@ -59,6 +59,10 @@ class PreferenceUtil(@ApplicationContext context: Context) { get() = prefs.getInt(NOTIFICATION_PERMISSION_DIALOG_COUNT, 0) set(value) = prefs.edit { putInt(NOTIFICATION_PERMISSION_DIALOG_COUNT, value) } + var hasRequestedCampusMapLocationPermission: Boolean + get() = prefs.getBoolean(CAMPUS_MAP_LOCATION_PERMISSION_REQUESTED, false) + set(value) = prefs.edit { putBoolean(CAMPUS_MAP_LOCATION_PERMISSION_REQUESTED, value) } + var clubInitialCategory: String get() = prefs.getString(CLUB_INITIAL_CATEGORY, null) ?: "" set(value) = run { @@ -105,6 +109,7 @@ class PreferenceUtil(@ApplicationContext context: Context) { const val SURVEY_2024_COMPLETE = "SURVEY_2024_COMPLETE" const val LAST_DATE_ACADEMIC_EVENT_SHEET_SHOWN = "LAST_DATE_ACADEMIC_EVENT_SHEET_SHOWN" const val NOTIFICATION_PERMISSION_DIALOG_COUNT = "NOTIFICATION_PERMISSION_DIALOG_COUNT" + const val CAMPUS_MAP_LOCATION_PERMISSION_REQUESTED = "CAMPUS_MAP_LOCATION_PERMISSION_REQUESTED" const val CLUB_INITIAL_CATEGORY = "CLUB_INITIAL_CATEGORY" } } diff --git a/core/util/src/main/java/com/ku_stacks/ku_ring/util/PermissionUtil.kt b/core/util/src/main/java/com/ku_stacks/ku_ring/util/PermissionUtil.kt index da5a6a8d8..a602deada 100644 --- a/core/util/src/main/java/com/ku_stacks/ku_ring/util/PermissionUtil.kt +++ b/core/util/src/main/java/com/ku_stacks/ku_ring/util/PermissionUtil.kt @@ -1,9 +1,14 @@ package com.ku_stacks.ku_ring.util import android.Manifest +import android.app.Activity import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build +import android.provider.Settings +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat fun Context.checkHasNotificationPermission(): Boolean { @@ -13,3 +18,33 @@ fun Context.checkHasNotificationPermission(): Boolean { Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED } + +fun Context.checkHasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + +fun Activity.isLocationPermissionPermanentlyDenied( + hasRequestedPermission: Boolean, +): Boolean = hasRequestedPermission && + !ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.ACCESS_FINE_LOCATION, + ) && + !ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + +fun Context.openAppSettings() { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + }, + ) +} diff --git a/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/Place.kt b/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/Place.kt index cb995855c..34b56a8e2 100644 --- a/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/Place.kt +++ b/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/Place.kt @@ -8,6 +8,13 @@ data class Place ( val latitude: Double, val longitude: Double, val priority: Priority, + val number: Int? = null, + val iconUrl: String? = null, + val phone: String? = null, + val data: String? = null, + val imageUrl: String? = null, + val operationHours: PlaceOperationHours? = null, + val facilities: List = emptyList(), ) { enum class Priority { HIGH, MIDDLE, LOW @@ -20,4 +27,17 @@ data class Place ( } } } -} \ No newline at end of file +} + +data class PlaceOperationHours( + val current: String? = null, + val semester: String? = null, + val vacation: String? = null, +) + +data class PlaceFacility( + val name: String, + val category: String, + val location: String? = null, + val operationHours: PlaceOperationHours? = null, +) diff --git a/data/place/src/main/assets/KonkukPlaces.json b/data/place/src/main/assets/KonkukPlaces.json index f6836fa75..fab186699 100644 --- a/data/place/src/main/assets/KonkukPlaces.json +++ b/data/place/src/main/assets/KonkukPlaces.json @@ -109,7 +109,14 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "무인 프린터기", + "category": "프린터", + "location": "1층" + } + ] }, { "id": "경영관", @@ -125,7 +132,37 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "CU", + "category": "편의점", + "location": "1층", + "operationHours": { + "semester": "00:00-00:00", + "vacation": "00:00-00:00" + } + }, + { + "name": "카페 레스티오", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 08:30-17:00 / 주말 휴무", + "vacation": "평일 08:30-17:00 / 주말 휴무" + } + }, + { + "name": "휴게실", + "category": "휴게실", + "location": "203-1호" + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "CU 뒷문 앞" + } + ] }, { "id": "공대별관", @@ -173,7 +210,33 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "K-Cube", + "category": "k-cube", + "location": "공학관" + }, + { + "name": "카페 레스티오", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 08:30-18:00 / 주말 휴무", + "vacation": "평일 08:30-18:00 / 주말 휴무" + } + }, + { + "name": "휴게실", + "category": "휴게실", + "location": "여 : A동 201-2호" + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "공학관 안뜰" + } + ] }, { "id": "공학관B동", @@ -221,7 +284,14 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "middle" + "priority": "middle", + "facilities": [ + { + "name": "휴게실", + "category": "휴게실", + "location": "여 : B-123호" + } + ] }, { "id": "교육과학관", @@ -284,10 +354,38 @@ "phone": null, "data": null, "places": { - "카페": ["동생대레스티오"] + "카페": [ + "동생대레스티오" + ] }, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "K-Cube", + "category": "k-cube", + "location": "동물생명과학관" + }, + { + "name": "카페 레스티오", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "08:30-17:00", + "vacation": "08:30-17:00" + } + }, + { + "name": "동물생명과학관 복사실", + "category": "복사실", + "location": "1층" + }, + { + "name": "휴게실", + "category": "휴게실", + "location": "여 : 310호" + } + ] }, { "id": "박물관", @@ -319,7 +417,19 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "휴게실", + "category": "휴게실", + "location": "남:310호, 여:311호" + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "1층 길가" + } + ] }, { "id": "산학협동관", @@ -335,7 +445,42 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "이마트24", + "category": "편의점", + "location": "1층", + "operationHours": { + "semester": "00:00-00:00", + "vacation": "00:00-00:00" + } + }, + { + "name": "카페 레스티오", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 08:30-19:00 / 주말 휴무", + "vacation": "평일 08:30-19:00 / 주말 휴무" + } + }, + { + "name": "산학협동관 복사실", + "category": "복사실", + "location": "2층" + }, + { + "name": "여직원 휴게실", + "category": "휴게실", + "location": "108호" + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "1층 주차장" + } + ] }, { "id": "상허기념도서관", @@ -350,12 +495,80 @@ "phone": null, "data": null, "places": { - "식당": ["상허마루"], - "휴게실": ["상허쉼터"], - "카페": ["도서관레스티오"] + "식당": [ + "상허마루" + ], + "휴게실": [ + "상허쉼터" + ], + "카페": [ + "도서관레스티오" + ] }, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "K-Cube", + "category": "k-cube", + "location": "상허기념도서관" + }, + { + "name": "구시아 푸드마켓", + "category": "식당", + "location": "지하1층", + "operationHours": { + "semester": "평일 08:30-19:00 / 주말 10:30-15:00 / 일요일 휴무", + "vacation": "평일 08:30-19:00 / 주말 10:30-15:00 / 일요일 휴무" + } + }, + { + "name": "CU", + "category": "편의점", + "location": "3층", + "operationHours": { + "semester": "00:00-00:00", + "vacation": "00:00-00:00" + } + }, + { + "name": "카페 레스티오", + "category": "카페", + "location": "3층", + "operationHours": { + "semester": "평일 09:00-17:00 / 주말 휴무", + "vacation": "평일 09:00-17:00 / 주말 휴무" + } + }, + { + "name": "신한 건국대학교 ATM 건대도서관", + "category": "은행/ATM", + "operationHours": { + "semester": "08:00-22:00", + "vacation": "08:00-22:00" + } + }, + { + "name": "무인 프린터기", + "category": "프린터", + "location": "1층" + }, + { + "name": "무인 프린터기", + "category": "프린터", + "location": "2층" + }, + { + "name": "휴게실", + "category": "휴게실", + "location": "남 :4층 / 여 : 2층" + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "3층 출입구 앞" + } + ] }, { "id": "상허연구관", @@ -371,7 +584,28 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "middle" + "priority": "middle", + "facilities": [ + { + "name": "K-Cube", + "category": "k-cube", + "location": "상허연구관" + }, + { + "name": "카페 블루포트", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "08:30-20:30", + "vacation": "08:30-20:30" + } + }, + { + "name": "상허연구관 복사실", + "category": "복사실", + "location": "1층" + } + ] }, { "id": "상허유석창박사동상", @@ -403,7 +637,37 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "식당(KU's Dining)", + "category": "식당", + "location": "지하1층", + "operationHours": { + "semester": "평일 11:15-13:15 / 주말 11:15-13:15 / 일요일 휴무", + "vacation": "평일 11:15-13:15 / 주말 11:15-13:15 / 일요일 휴무" + } + }, + { + "name": "신한 건국대학교 ATM 건국대학교새천년관1층", + "category": "은행/ATM", + "location": "1층", + "operationHours": { + "semester": "08:00-22:00", + "vacation": "08:00-22:00" + } + }, + { + "name": "무인 프린터기", + "category": "프린터", + "location": "1층" + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "지하 1층 야외" + } + ] }, { "id": "생명과학관", @@ -419,7 +683,14 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "middle" + "priority": "middle", + "facilities": [ + { + "name": "K-Cube", + "category": "k-cube", + "location": "생명과학관" + } + ] }, { "id": "수의학관", @@ -435,7 +706,14 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "휴게실", + "category": "휴게실", + "location": "224호, 312호, 여:313호" + } + ] }, { "id": "신공학관", @@ -451,7 +729,19 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "무인 프린터기", + "category": "프린터", + "location": "1층" + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "신공학관 마당" + } + ] }, { "id": "실내체육관", @@ -499,7 +789,23 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "middle" + "priority": "middle", + "facilities": [ + { + "name": "카페 레스티오", + "category": "카페", + "location": "지하1층", + "operationHours": { + "semester": "평일 8:30-17:00 / 주말 휴무", + "vacation": "평일 8:30-17:00 / 주말 휴무" + } + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "건물 뒷편" + } + ] }, { "id": "의생명과학연구관", @@ -515,7 +821,18 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "low" + "priority": "low", + "facilities": [ + { + "name": "CU", + "category": "편의점", + "location": "1층", + "operationHours": { + "semester": "00:00-00:00", + "vacation": "00:00-00:00" + } + } + ] }, { "id": "인문학관", @@ -531,7 +848,14 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "middle" + "priority": "middle", + "facilities": [ + { + "name": "흡연부스", + "category": "흡연부스", + "location": "인문학관과 건축관 사이" + } + ] }, { "id": "일감호", @@ -546,7 +870,11 @@ "phone": null, "data": null, "places": { - "랜드마크": ["와우도", "홍예교", "청심대"] + "랜드마크": [ + "와우도", + "홍예교", + "청심대" + ] }, "parentId": "konkuk", "priority": "middle" @@ -596,7 +924,9 @@ "phone": null, "data": null, "places": { - "공연": ["노천극장"] + "공연": [ + "노천극장" + ] }, "parentId": "konkuk", "priority": "low" @@ -631,7 +961,14 @@ "data": "113 학군단", "places": {}, "parentId": "konkuk", - "priority": "middle" + "priority": "middle", + "facilities": [ + { + "name": "무인 프린터기", + "category": "프린터", + "location": "1층" + } + ] }, { "id": "쿨하우스글로벌홀", @@ -647,7 +984,73 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "기숙사식당", + "category": "식당", + "location": "1층" + }, + { + "name": "이마트24", + "category": "편의점", + "location": "지하1층", + "operationHours": { + "semester": "00:00-00:00", + "vacation": "00:00-00:00" + } + }, + { + "name": "GS25", + "category": "편의점", + "location": "지하1층", + "operationHours": { + "semester": "00:00-00:00", + "vacation": "00:00-00:00" + } + }, + { + "name": "데일리베틀주스", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 08:30-19:30 / 주말 휴무", + "vacation": "평일 08:30-19:30 / 주말 휴무" + } + }, + { + "name": "매머드익스프레스 건대레이크홀점", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 08:00-20:00 / 주말 09:00-20:00", + "vacation": "평일 08:00-20:00 / 주말 09:00-20:00" + } + }, + { + "name": "신한 건국대학교 ATM 건국대민자식댕내", + "category": "은행/ATM", + "location": "1층", + "operationHours": { + "semester": "07:00-23:30", + "vacation": "07:00-23:30" + } + }, + { + "name": "국민은행 건국대학교 기숙사동 ATM", + "category": "은행/ATM", + "location": "1층", + "operationHours": { + "semester": "07:00-21:00", + "vacation": "07:00-21:00" + } + }, + { + "name": "흡연부스", + "category": "흡연부스", + "location": "비젼홀과 글로벌홀 사이" + } + ] }, { "id": "쿨하우스드림홀", @@ -727,7 +1130,105 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "식당(KU's Kitchen)", + "category": "식당", + "location": "1층", + "operationHours": { + "semester": "평일 10:30-14:00 / 주말 10:30-14:00 / 일요일 휴무", + "vacation": "평일 10:30-14:00 / 주말 10:30-14:00 / 일요일 휴무" + } + }, + { + "name": "구시아 푸드마켓", + "category": "식당", + "location": "지하1층", + "operationHours": { + "semester": "평일 10:00-20:00 / 주말 10:30-15:30 / 일요일 휴무", + "vacation": "평일 10:00-20:00 / 주말 10:30-15:30 / 일요일 휴무" + } + }, + { + "name": "CU", + "category": "편의점", + "location": "1층", + "operationHours": { + "semester": "00:00-00:00", + "vacation": "00:00-00:00" + } + }, + { + "name": "1847샐러리드카페", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 08:00-19:00 / 주말 휴무", + "vacation": "평일 08:00-18:00 / 주말 휴무" + } + }, + { + "name": "카페요아정 건국대학생회관점", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 10:00-19:00 / 주말 휴무", + "vacation": "평일 10:00-19:00 / 주말 휴무" + } + }, + { + "name": "메가MGC커피 건국대점", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "09:00-16:00", + "vacation": "09:00-16:00" + } + }, + { + "name": "신한은행", + "category": "은행/ATM", + "location": "1층", + "operationHours": { + "semester": "평일 09:00-16:00 / 주말 휴무", + "vacation": "평일 09:00-16:00 / 주말 휴무" + } + }, + { + "name": "신한 건국대학교 ATM", + "category": "은행/ATM", + "location": "1층", + "operationHours": { + "semester": "07:00-23:30", + "vacation": "07:00-23:30" + } + }, + { + "name": "우편취급국", + "category": "우체국", + "location": "1층", + "operationHours": { + "semester": "평일 09:00-18:00 / 주말 휴무", + "vacation": "평일 09:00-18:00 / 주말 휴무" + } + }, + { + "name": "학생회관 복사실", + "category": "복사실", + "location": "1층" + }, + { + "name": "무인 프린터기", + "category": "프린터", + "location": "1층" + }, + { + "name": "휴게실", + "category": "휴게실", + "location": "남 : 302호 / 여 : 305호" + } + ] }, { "id": "해봉부동산학관", @@ -743,7 +1244,23 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "middle" + "priority": "middle", + "facilities": [ + { + "name": "카페 ING", + "category": "카페", + "location": "1층", + "operationHours": { + "semester": "평일 08:00-21:00 / 주말 09:00-18:00", + "vacation": "평일 08:00-21:00 / 주말 09:00-18:00" + } + }, + { + "name": "무인 프린터기", + "category": "프린터", + "location": "4층" + } + ] }, { "id": "행정관", @@ -759,6 +1276,17 @@ "data": null, "places": {}, "parentId": "konkuk", - "priority": "high" + "priority": "high", + "facilities": [ + { + "name": "신한 건국대학교 ATM 건대본관", + "category": "은행/ATM", + "location": "1층", + "operationHours": { + "semester": "평일 09:00-21:00 / 주말 휴무", + "vacation": "평일 09:00-21:00 / 주말 휴무" + } + } + ] } -] \ No newline at end of file +] diff --git a/data/place/src/main/java/com/ku_stacks/ku_ring/place/mapper/JsonToDomain.kt b/data/place/src/main/java/com/ku_stacks/ku_ring/place/mapper/JsonToDomain.kt index 6ea91d75e..a9005094d 100644 --- a/data/place/src/main/java/com/ku_stacks/ku_ring/place/mapper/JsonToDomain.kt +++ b/data/place/src/main/java/com/ku_stacks/ku_ring/place/mapper/JsonToDomain.kt @@ -1,8 +1,12 @@ package com.ku_stacks.ku_ring.place.mapper import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.domain.PlaceFacility +import com.ku_stacks.ku_ring.domain.PlaceOperationHours import com.ku_stacks.ku_ring.domain.Place.Priority import com.ku_stacks.ku_ring.place.model.JsonPlace +import com.ku_stacks.ku_ring.place.model.JsonPlaceFacility +import com.ku_stacks.ku_ring.place.model.JsonPlaceOperationHours internal fun JsonPlace.toDomain() = Place( id = id, @@ -11,5 +15,35 @@ internal fun JsonPlace.toDomain() = Place( address = address, latitude = latitude, longitude = longitude, - priority = Priority.from(priority) -) \ No newline at end of file + priority = Priority.from(priority), + number = number, + iconUrl = iconUrl, + phone = phone, + data = data, + imageUrl = imageUrl, + operationHours = operationHours?.toDomain(), + facilities = facilities?.map { it.toDomain() } ?: places.toFacilities(), +) + +private fun Map>.toFacilities(): List = + flatMap { (category, names) -> + names.map { name -> + PlaceFacility( + name = name, + category = category, + ) + } + } + +private fun JsonPlaceFacility.toDomain() = PlaceFacility( + name = name, + category = category, + location = location, + operationHours = operationHours?.toDomain(), +) + +private fun JsonPlaceOperationHours.toDomain() = PlaceOperationHours( + current = current, + semester = semester, + vacation = vacation, +) diff --git a/data/place/src/main/java/com/ku_stacks/ku_ring/place/model/JsonPlace.kt b/data/place/src/main/java/com/ku_stacks/ku_ring/place/model/JsonPlace.kt index 6d8a59b50..285760c4f 100644 --- a/data/place/src/main/java/com/ku_stacks/ku_ring/place/model/JsonPlace.kt +++ b/data/place/src/main/java/com/ku_stacks/ku_ring/place/model/JsonPlace.kt @@ -15,7 +15,25 @@ internal data class JsonPlace( val longitude: Double, val phone: String? = null, val data: String? = null, + val imageUrl: String? = null, + val operationHours: JsonPlaceOperationHours? = null, + val facilities: List? = null, val places: Map> = emptyMap(), val parentId: String, val priority: String, ) + +@Serializable +internal data class JsonPlaceOperationHours( + val current: String? = null, + val semester: String? = null, + val vacation: String? = null, +) + +@Serializable +internal data class JsonPlaceFacility( + val name: String, + val category: String, + val location: String? = null, + val operationHours: JsonPlaceOperationHours? = null, +) diff --git a/feature/main/src/main/assets/campusmap/empty_search_image.svg b/feature/main/src/main/assets/campusmap/empty_search_image.svg new file mode 100644 index 000000000..103e41cb8 --- /dev/null +++ b/feature/main/src/main/assets/campusmap/empty_search_image.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/MainScreen.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/MainScreen.kt index 723ca3878..d5e2eee9c 100644 --- a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/MainScreen.kt +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/MainScreen.kt @@ -6,7 +6,10 @@ import android.content.pm.PackageManager import androidx.activity.compose.LocalActivity import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -19,11 +22,15 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -34,7 +41,13 @@ import com.ku_stacks.ku_ring.compose.locals.LocalNavigator import com.ku_stacks.ku_ring.compose.locals.LocalPreferences import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme import com.ku_stacks.ku_ring.main.calendar.compose.AcademicCalendarScreen +import com.ku_stacks.ku_ring.main.campusmap.CampusMapViewModel import com.ku_stacks.ku_ring.main.campusmap.compose.CampusMapScreen +import com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet.CampusMapDetailSheetHost +import com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet.CampusMapSearchResultSheetContent +import com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet.CampusMapSearchResultSheetHost +import com.ku_stacks.ku_ring.main.campusmap.compose.inner_screen.CampusMapSearchDestination +import com.ku_stacks.ku_ring.main.campusmap.compose.inner_screen.CampusMapSearchRoute import com.ku_stacks.ku_ring.main.club.compose.inner_screen.ClubListScreen import com.ku_stacks.ku_ring.main.notice.compose.NoticeScreen import com.ku_stacks.ku_ring.main.setting.SettingViewModel @@ -51,63 +64,114 @@ fun MainScreen( startDestination: MainScreenRoute = MainScreenRoute.Notice, ) { val currentBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = currentBackStackEntry?.let { MainScreenRoute.of(it) } + val isCampusMapSearch = currentBackStackEntry?.isCampusMapSearch == true + val currentRoute = currentBackStackEntry?.mainScreenRoute() ?: MainScreenRoute.Notice + var campusMapSearchResultSheetContent by remember { + mutableStateOf(null) + } KuringCompositionLocalProvider { val navigator = LocalNavigator.current val activity = LocalActivity.current ?: return@KuringCompositionLocalProvider - Scaffold( - bottomBar = { - MainScreenNavigationBar( - currentRoute = currentRoute, - onNavigationItemClick = { navController.navigate(it) }, - modifier = Modifier.fillMaxWidth(), - ) - }, - contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Start + WindowInsetsSides.End + WindowInsetsSides.Bottom), - containerColor = KuringTheme.colors.background, - modifier = modifier, - ) { - NavHost( - navController = navController, - startDestination = startDestination, - modifier = Modifier - .padding(it) - .consumeWindowInsets(it) - .fillMaxSize(), - enterTransition = { - val initialRoute = MainScreenRoute.of(initialState) - val targetRoute = MainScreenRoute.of(targetState) - val enterDirection = - slideDirection( - initialRoute = initialRoute, - targetRoute = targetRoute, + Box(modifier = modifier.fillMaxSize()) { + Scaffold( + bottomBar = { + if (!isCampusMapSearch) { + MainScreenNavigationBar( + currentRoute = currentRoute, + onNavigationItemClick = { navController.navigate(it) }, + modifier = Modifier.fillMaxWidth(), ) - slideIntoContainer(enterDirection) + } }, - exitTransition = { - val initialRoute = MainScreenRoute.of(initialState) - val targetRoute = MainScreenRoute.of(targetState) - val enterDirection = - slideDirection( - initialRoute = initialRoute, - targetRoute = targetRoute, - ) - slideOutOfContainer(enterDirection) - }, - ) { - mainScreenNavGraph( + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Start + WindowInsetsSides.End + WindowInsetsSides.Bottom), + containerColor = KuringTheme.colors.background, + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + NavHost( navController = navController, - navigator = navigator, - activity = activity, - ) + startDestination = startDestination, + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize(), + enterTransition = { + if (initialState.isCampusMapSearch || targetState.isCampusMapSearch) { + EnterTransition.None + } else { + val enterDirection = slideDirection( + initialRoute = initialState.mainScreenRoute(), + targetRoute = targetState.mainScreenRoute(), + ) + slideIntoContainer(enterDirection) + } + }, + exitTransition = { + if (initialState.isCampusMapSearch || targetState.isCampusMapSearch) { + ExitTransition.None + } else { + val enterDirection = slideDirection( + initialRoute = initialState.mainScreenRoute(), + targetRoute = targetState.mainScreenRoute(), + ) + slideOutOfContainer(enterDirection) + } + }, + ) { + mainScreenNavGraph( + navController = navController, + navigator = navigator, + activity = activity, + onCampusMapSearchResultSheetContentChange = { + campusMapSearchResultSheetContent = it + }, + ) + } + } + + if (shouldShowCampusMapSheetHost(currentRoute, isCampusMapSearch)) { + currentBackStackEntry?.let { campusMapEntry -> + val viewModel = hiltViewModel(campusMapEntry) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val focusedPlace = uiState.focusedPlace + + if (focusedPlace != null) { + CampusMapDetailSheetHost( + place = focusedPlace, + onDismiss = viewModel::clearFocusedPlace, + modifier = Modifier.fillMaxSize(), + ) + } else { + campusMapSearchResultSheetContent?.let { content -> + CampusMapSearchResultSheetHost( + content = content, + modifier = Modifier.fillMaxSize(), + ) + } + } + } } } } } +private val NavBackStackEntry.isCampusMapSearch: Boolean + get() = destination.route == CampusMapSearchDestination.route + +private fun NavBackStackEntry.mainScreenRoute(): MainScreenRoute = + if (isCampusMapSearch) { + MainScreenRoute.CampusMap + } else { + MainScreenRoute.of(this) + } + +internal fun shouldShowCampusMapSheetHost( + currentRoute: MainScreenRoute, + isCampusMapSearch: Boolean, +): Boolean = currentRoute == MainScreenRoute.CampusMap && !isCampusMapSearch + private fun MainScreenRoute.screenOrder() = when (this) { is MainScreenRoute.Notice -> 0 @@ -126,10 +190,11 @@ private fun slideDirection( AnimatedContentTransitionScope.SlideDirection.Left } -fun NavGraphBuilder.mainScreenNavGraph( +internal fun NavGraphBuilder.mainScreenNavGraph( navController: NavHostController, navigator: KuringNavigator, activity: Activity, + onCampusMapSearchResultSheetContentChange: (CampusMapSearchResultSheetContent?) -> Unit, ) { composable { NoticeScreen( @@ -176,7 +241,23 @@ fun NavGraphBuilder.mainScreenNavGraph( CampusMapScreen( onLibrarySeatFabClick = { navigator.navigateToLibrarySeat(activity) - } + }, + onNavigateToSearch = { + navController.navigate(CampusMapSearchDestination) + }, + onSearchResultSheetContentChange = onCampusMapSearchResultSheetContentChange, + ) + } + composable { backStackEntry -> + val campusMapEntry = remember(backStackEntry) { + navController.getBackStackEntry(MainScreenRoute.CampusMap.route) + } + val viewModel = hiltViewModel(campusMapEntry) + + CampusMapSearchRoute( + onNavigateUp = { navController.popBackStack() }, + viewModel = viewModel, + modifier = Modifier.fillMaxSize(), ) } composable { diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapUiState.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapUiState.kt index 15ac4fce9..fdd7e6747 100644 --- a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapUiState.kt +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapUiState.kt @@ -1,17 +1,174 @@ package com.ku_stacks.ku_ring.main.campusmap +import androidx.annotation.DrawableRes import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.domain.PlaceFacility +import com.ku_stacks.ku_ring.domain.PlaceOperationHours +import com.ku_stacks.ku_ring.main.R +import com.ku_stacks.ku_ring.main.campusmap.type.CampusMapCategory +import com.ku_stacks.ku_ring.main.campusmap.type.filterCampusPlaces +import com.ku_stacks.ku_ring.main.campusmap.type.searchCampusPlaces import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -data class CampusMapUiState ( +internal data class CampusMapUiState( val focusedPlace: Place?, + val selectedSearchPlace: Place?, + val submittedSearchQuery: String?, val campusPlaces: ImmutableList, + val selectedCategory: CampusMapCategory?, + val searchInput: String, + val recentSearches: ImmutableList, + val isLocationPermissionDialogVisible: Boolean, ) { + val visiblePlaces: ImmutableList + get() = if (hasSubmittedQuery) { + searchCampusPlaces( + places = campusPlaces, + query = submittedSearchQuery.orEmpty(), + ) + } else { + filterCampusPlaces( + places = campusPlaces, + selectedCategory = selectedCategory, + ) + }.toImmutableList() + + val searchResults: ImmutableList + get() = buildCampusMapSearchResults( + places = visiblePlaces, + selectedCategory = selectedCategory, + ).toImmutableList() + + val liveSearchResults: ImmutableList + get() = if (selectedCategory != null && searchInput == selectedCategory.label) { + searchResults + } else { + buildCampusMapSearchResults( + places = searchCampusPlaces( + places = campusPlaces, + query = searchInput, + ), + selectedCategory = null, + ).toImmutableList() + } + + val activeSearchText: String? + get() = selectedCategory?.label ?: selectedSearchPlace?.name ?: submittedSearchQuery + + val showSearchResultSheet: Boolean + get() = focusedPlace == null && (selectedCategory != null || hasSubmittedQuery) + + private val hasSubmittedQuery: Boolean + get() = !submittedSearchQuery.isNullOrBlank() + companion object { val Empty = CampusMapUiState( focusedPlace = null, - campusPlaces = emptyList().toImmutableList(), + selectedSearchPlace = null, + submittedSearchQuery = null, + campusPlaces = persistentListOf(), + selectedCategory = null, + searchInput = "", + recentSearches = persistentListOf(), + isLocationPermissionDialogVisible = false, ) } -} \ No newline at end of file +} + +internal data class CampusMapSearchResult( + val id: String, + val place: Place, + val title: String, + val category: String, + val location: String, + val operationHours: String, + val imageUrl: String?, +) + +internal sealed interface CampusMapRecentSearch { + val id: String + val label: String + + @get:DrawableRes + val iconRes: Int + + data class Query(val query: String) : CampusMapRecentSearch { + override val id: String = "query:${query.lowercase()}" + override val label: String = query + override val iconRes: Int = R.drawable.ic_campus_map_recent_search + } + + data class PlaceResult(val place: Place) : CampusMapRecentSearch { + override val id: String = "place:${place.id}" + override val label: String = place.name + override val iconRes: Int = R.drawable.ic_campus_map_recent_search + } +} + +internal fun buildCampusMapSearchResults( + places: List, + selectedCategory: CampusMapCategory?, +): List { + if (selectedCategory == null) { + return places.map(Place::toSearchResult) + } + + return places.flatMap { place -> + place.facilities.mapIndexedNotNull { index, facility -> + if (selectedCategory.matches(facility.category)) { + facility.toSearchResult( + place = place, + index = index, + ) + } else { + null + } + } + } +} + +internal fun updateRecentSearches( + current: List, + selected: CampusMapRecentSearch, + maxSize: Int = RECENT_SEARCH_MAX_SIZE, +): List { + return buildList { + add(selected) + current + .filterNot { recentSearch -> recentSearch.id == selected.id } + .take(maxSize - 1) + .forEach(::add) + } +} + +private fun Place.toSearchResult() = CampusMapSearchResult( + id = "place:$id", + place = this, + title = name, + category = category, + location = address, + operationHours = operationHours.displayText() + ?: facilities.firstNotNullOfOrNull { facility -> facility.operationHours.displayText() } + ?: "-", + imageUrl = imageUrl, +) + +private fun PlaceFacility.toSearchResult( + place: Place, + index: Int, +) = CampusMapSearchResult( + id = "facility:${place.id}:$index", + place = place, + title = name, + category = category, + location = location ?: place.address, + operationHours = operationHours.displayText() ?: "-", + imageUrl = place.imageUrl, +) + +private fun PlaceOperationHours?.displayText(): String? = + this?.current ?: this?.semester ?: this?.vacation + +private const val RECENT_SEARCH_MAX_SIZE = 10 diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapViewModel.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapViewModel.kt index 612ece10e..bc951e3c1 100644 --- a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapViewModel.kt +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/CampusMapViewModel.kt @@ -4,21 +4,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ku_stacks.ku_ring.domain.Place import com.ku_stacks.ku_ring.domain.place.repository.PlaceRepository +import com.ku_stacks.ku_ring.main.campusmap.type.CampusMapCategory import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList 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 @HiltViewModel class CampusMapViewModel @Inject constructor( private val placeRepository: PlaceRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(CampusMapUiState.Empty) - val uiState: StateFlow = _uiState.asStateFlow() + internal val uiState: StateFlow = _uiState.asStateFlow() init { fetchCampusPlaces() @@ -37,7 +39,160 @@ class CampusMapViewModel @Inject constructor( _uiState.update { currentState -> currentState.copy( focusedPlace = place, + selectedSearchPlace = null, + submittedSearchQuery = null, + selectedCategory = null, + ) + } + } + + fun clearFocusedPlace() { + _uiState.update { currentState -> + currentState.copy( + focusedPlace = null, + selectedSearchPlace = null, + ) + } + } + + fun clearActiveSelection() { + _uiState.update { currentState -> + currentState.copy( + focusedPlace = null, + selectedSearchPlace = null, + submittedSearchQuery = null, + selectedCategory = null, + searchInput = "", + ) + } + } + + fun showLocationPermissionDialog() { + _uiState.update { currentState -> + currentState.copy(isLocationPermissionDialogVisible = true) + } + } + + fun hideLocationPermissionDialog() { + _uiState.update { currentState -> + currentState.copy(isLocationPermissionDialogVisible = false) + } + } + + internal fun updateSelectedCategory(category: CampusMapCategory) { + _uiState.update { currentState -> + currentState.copy( + selectedCategory = if (currentState.selectedCategory == category) null else category, + focusedPlace = null, + selectedSearchPlace = null, + submittedSearchQuery = null, + searchInput = "", + ) + } + } + + fun prepareSearchInput() { + _uiState.update { currentState -> + currentState.copy( + searchInput = currentState.activeSearchText.orEmpty(), + ) + } + } + + fun updateSearchInput(input: String) { + _uiState.update { currentState -> + currentState.copy(searchInput = input) + } + } + + fun clearSearchInput() { + _uiState.update { currentState -> + currentState.copy(searchInput = "") + } + } + + internal fun selectRecentSearch(recentSearch: CampusMapRecentSearch) { + _uiState.update { currentState -> + when (recentSearch) { + is CampusMapRecentSearch.PlaceResult -> { + currentState.withSelectedSearchPlace(recentSearch.place) + } + + is CampusMapRecentSearch.Query -> { + currentState.withSubmittedSearchQuery(recentSearch.query) + } + } + } + } + + internal fun deleteRecentSearch(recentSearch: CampusMapRecentSearch) { + _uiState.update { currentState -> + currentState.copy( + recentSearches = currentState.recentSearches + .filterNot { item -> item.id == recentSearch.id } + .toImmutableList(), + ) + } + } + + fun clearRecentSearches() { + _uiState.update { currentState -> + currentState.copy( + recentSearches = persistentListOf(), ) } } + + fun selectSearchPlace(place: Place) { + _uiState.update { currentState -> + currentState.withSelectedSearchPlace(place) + } + } + + fun focusSearchResultPlace(place: Place) { + _uiState.update { currentState -> + currentState.copy(focusedPlace = place) + } + } + + fun submitSearch() { + _uiState.update { currentState -> + val submittedQuery = currentState.searchInput.trim() + + if (submittedQuery.isEmpty()) { + currentState + } else { + currentState.withSubmittedSearchQuery(submittedQuery) + } + } + } + + private fun CampusMapUiState.withSubmittedSearchQuery(query: String): CampusMapUiState { + val recentSearch = CampusMapRecentSearch.Query(query) + return copy( + focusedPlace = null, + selectedSearchPlace = null, + submittedSearchQuery = query, + selectedCategory = null, + searchInput = query, + recentSearches = updateRecentSearches( + current = recentSearches, + selected = recentSearch, + ).toImmutableList(), + ) + } + + private fun CampusMapUiState.withSelectedSearchPlace(place: Place): CampusMapUiState { + return copy( + focusedPlace = place, + selectedSearchPlace = place, + submittedSearchQuery = null, + selectedCategory = null, + searchInput = "", + recentSearches = updateRecentSearches( + current = recentSearches, + selected = CampusMapRecentSearch.PlaceResult(place), + ).toImmutableList(), + ) + } } diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/CampusMapScreen.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/CampusMapScreen.kt index 9314561fa..a9eb4bb5f 100644 --- a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/CampusMapScreen.kt +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/CampusMapScreen.kt @@ -1,33 +1,185 @@ package com.ku_stacks.ku_ring.main.campusmap.compose +import android.Manifest +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Scaffold +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.ku_stacks.ku_ring.compose.locals.LocalPreferences +import com.ku_stacks.ku_ring.designsystem.components.KuringAlertDialog +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.main.R import com.ku_stacks.ku_ring.main.campusmap.CampusMapUiState import com.ku_stacks.ku_ring.main.campusmap.CampusMapViewModel -import com.ku_stacks.ku_ring.main.campusmap.compose.component.LibrarySeatFab -import com.ku_stacks.ku_ring.main.campusmap.compose.component.NaverMapSection +import com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet.CampusMapSearchResultSheetContent +import com.ku_stacks.ku_ring.main.campusmap.compose.component.map.CurrentLocationFab +import com.ku_stacks.ku_ring.main.campusmap.compose.component.map.LibrarySeatFab +import com.ku_stacks.ku_ring.main.campusmap.compose.component.map.NaverMapSection +import com.ku_stacks.ku_ring.main.campusmap.type.CampusMapCategory +import com.ku_stacks.ku_ring.util.checkHasLocationPermission +import com.ku_stacks.ku_ring.util.isLocationPermissionPermanentlyDenied +import com.ku_stacks.ku_ring.util.openAppSettings +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.CameraAnimation +import com.naver.maps.map.CameraPosition +import com.naver.maps.map.CameraUpdate +import com.naver.maps.map.compose.CameraPositionState import com.naver.maps.map.compose.ExperimentalNaverMapApi +import com.naver.maps.map.compose.rememberCameraPositionState +private val BottomFabMargin = 16.dp +private const val INITIAL_LATITUDE = 37.542366 +private const val INITIAL_LONGITUDE = 127.076846 +private const val INITIAL_ZOOM_LEVEL = 14.2 + +@OptIn(ExperimentalNaverMapApi::class) @Composable -fun CampusMapScreen( +internal fun CampusMapScreen( onLibrarySeatFabClick: () -> Unit, + onNavigateToSearch: () -> Unit, + onSearchResultSheetContentChange: (CampusMapSearchResultSheetContent?) -> Unit, modifier: Modifier = Modifier, viewModel: CampusMapViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val activity = LocalActivity.current + val preferences = LocalPreferences.current + var hasLocationPermission by remember { mutableStateOf(context.checkHasLocationPermission()) } + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition( + LatLng(INITIAL_LATITUDE, INITIAL_LONGITUDE), + INITIAL_ZOOM_LEVEL, + ) + } + val fusedLocationClient = remember(context) { + LocationServices.getFusedLocationProviderClient(context) + } + val moveToCurrentLocation = { + requestCurrentLocation(fusedLocationClient) { latitude, longitude -> + cameraPositionState.move( + update = CameraUpdate + .scrollTo(LatLng(latitude, longitude)) + .animate(CameraAnimation.Fly), + ) + } + } + + LifecycleResumeEffect(Unit) { + hasLocationPermission = context.checkHasLocationPermission() + onPauseOrDispose {} + } + + val locationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + hasLocationPermission = + permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || + permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true + if (hasLocationPermission) { + moveToCurrentLocation() + } + } CampusMapScreen( uiState = uiState, modifier = modifier, onMapPinClick = viewModel::updateFocusedPlace, + onMapClick = viewModel::clearFocusedPlace, + onPlaceDetailClose = viewModel::clearFocusedPlace, + onSearchResultSheetContentChange = onSearchResultSheetContentChange, + onCategoryClick = viewModel::updateSelectedCategory, + onSearchClick = { + viewModel.prepareSearchInput() + onNavigateToSearch() + }, + onSearchResultClick = viewModel::focusSearchResultPlace, + onActiveSelectionClear = viewModel::clearActiveSelection, + cameraPositionState = cameraPositionState, + hasLocationPermission = hasLocationPermission, + onCurrentLocationClick = { + when { + context.checkHasLocationPermission() -> moveToCurrentLocation() + activity?.isLocationPermissionPermanentlyDenied( + hasRequestedPermission = preferences.hasRequestedCampusMapLocationPermission, + ) == true -> viewModel.showLocationPermissionDialog() + + else -> { + preferences.hasRequestedCampusMapLocationPermission = true + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + } + }, onLibrarySeatFabClick = onLibrarySeatFabClick, ) + + if (uiState.isLocationPermissionDialogVisible) { + KuringAlertDialog( + text = stringResource(R.string.campus_map_location_permission_dialog_text), + confirmText = stringResource(R.string.notification_permission_dialog_confirm), + onConfirm = { + viewModel.hideLocationPermissionDialog() + context.openAppSettings() + }, + onCancel = viewModel::hideLocationPermissionDialog, + ) + } } @OptIn(ExperimentalNaverMapApi::class) @@ -35,21 +187,374 @@ fun CampusMapScreen( private fun CampusMapScreen( uiState: CampusMapUiState, onMapPinClick: (Place) -> Unit, + onMapClick: () -> Unit, + onPlaceDetailClose: () -> Unit, + onSearchResultSheetContentChange: (CampusMapSearchResultSheetContent?) -> Unit, + onCategoryClick: (CampusMapCategory) -> Unit, + onSearchClick: () -> Unit, + onSearchResultClick: (Place) -> Unit, + onActiveSelectionClear: () -> Unit, + cameraPositionState: CameraPositionState, + hasLocationPermission: Boolean, + onCurrentLocationClick: () -> Unit, onLibrarySeatFabClick: () -> Unit, modifier: Modifier = Modifier, ) { - Scaffold( - floatingActionButton = { - LibrarySeatFab( - onClick = onLibrarySeatFabClick, - ) - }, - modifier = modifier.fillMaxSize() + BackHandler( + enabled = uiState.focusedPlace != null || uiState.showSearchResultSheet, + ) { + if (uiState.focusedPlace != null) { + onPlaceDetailClose() + } else { + onActiveSelectionClear() + } + } + + CampusMapSearchResultSheetEffect( + uiState = uiState, + onResultClick = onSearchResultClick, + onDismiss = onActiveSelectionClear, + onContentChange = onSearchResultSheetContentChange, + ) + + Box( + modifier = modifier.fillMaxSize(), ) { NaverMapSection( - campusPlaces = uiState.campusPlaces, + campusPlaces = uiState.visiblePlaces, focusedPlace = uiState.focusedPlace, + selectedCategory = uiState.selectedCategory, + cameraPositionState = cameraPositionState, onMapPinClick = onMapPinClick, + onMapClick = onMapClick, + modifier = Modifier.fillMaxSize(), + ) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding( + start = 20.dp, + end = 20.dp, + bottom = BottomFabMargin, + ), + ) { + LibrarySeatFab(onClick = onLibrarySeatFabClick) + + CurrentLocationFab( + hasLocationPermission = hasLocationPermission, + onClick = onCurrentLocationClick, + ) + } + + CampusMapTopControls( + selectedCategory = uiState.selectedCategory, + activeSearchText = uiState.activeSearchText, + onSearchClick = onSearchClick, + onActiveSearchClear = onActiveSelectionClear, + onCategoryClick = onCategoryClick, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + + } +} + +@Composable +private fun CampusMapTopControls( + selectedCategory: CampusMapCategory?, + activeSearchText: String?, + onSearchClick: () -> Unit, + onActiveSearchClear: () -> Unit, + onCategoryClick: (CampusMapCategory) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + if (activeSearchText != null) { + KuringTheme.colors.background + } else { + Color.Transparent + }, + ) + .statusBarsPadding() + .padding(top = if (activeSearchText == null) 8.dp else 0.dp), + ) { + if (activeSearchText.isNullOrBlank()) { + CampusMapSearchField( + onClick = onSearchClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + } else { + CampusMapSelectedSearchField( + text = activeSearchText, + onClick = onSearchClick, + onClear = onActiveSearchClear, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + CampusMapCategoryChips( + selectedCategory = selectedCategory, + onCategoryClick = onCategoryClick, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun CampusMapSearchField( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(12.dp) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .height(40.dp) + .shadow(elevation = 4.dp, shape = shape) + .clip(shape) + .background(KuringTheme.colors.background) + .clickable( + role = Role.Button, + onClick = onClick, + ) + .padding(start = 16.dp, end = 12.dp), + ) { + Text( + text = stringResource(id = R.string.campus_map_search_placeholder), + style = KuringTheme.typography.body2, + color = KuringTheme.colors.textCaption1, + modifier = Modifier.weight(1f), + ) + + Icon( + painter = painterResource(id = R.drawable.ic_search_v2), + contentDescription = null, + tint = KuringTheme.colors.gray300, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun CampusMapSelectedSearchField( + text: String, + onClick: () -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .background(KuringTheme.colors.background) + .padding(start = 4.dp, end = 20.dp, top = 6.dp, bottom = 6.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(44.dp) + .clickable( + role = Role.Button, + onClick = onClear, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_chevron_left_v2), + contentDescription = stringResource(id = R.string.campus_map_search_navigate_up), + tint = KuringTheme.colors.gray600, + modifier = Modifier.size(24.dp), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(KuringTheme.colors.gray100) + .clickable( + role = Role.Button, + onClick = onClick, + ) + .padding(start = 16.dp, end = 12.dp), + ) { + Text( + text = text, + style = KuringTheme.typography.body2.copy(fontWeight = FontWeight.Medium), + color = KuringTheme.colors.textTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + SelectedSearchClearIcon(onClick = onClear) + } + } +} + +@Composable +private fun SelectedSearchClearIcon( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(24.dp) + .clip(CircleShape) + .background(KuringTheme.colors.gray300) + .clickable( + role = Role.Button, + onClick = onClick, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_x_v2), + contentDescription = stringResource(id = R.string.campus_map_clear_search_query), + tint = KuringTheme.colors.background, + modifier = Modifier.size(14.dp), ) } } + +@Composable +private fun CampusMapCategoryChips( + selectedCategory: CampusMapCategory?, + onCategoryClick: (CampusMapCategory) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + CampusMapCategory.entries.forEach { category -> + CampusMapCategoryChip( + category = category, + isSelected = selectedCategory == category, + onClick = { onCategoryClick(category) }, + ) + } + } +} + +@Composable +private fun CampusMapCategoryChip( + category: CampusMapCategory, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val contentColor = if (isSelected) { + KuringTheme.colors.mainPrimary + } else { + KuringTheme.colors.textBody + } + val shape = RoundedCornerShape(999.dp) + + Box( + contentAlignment = Alignment.Center, + modifier = modifier.defaultMinSize(minHeight = 44.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .heightIn(min = 34.dp) + .shadow(elevation = 4.dp, shape = shape) + .clip(shape) + .background(KuringTheme.colors.background) + .then( + if (isSelected) { + Modifier.border( + width = 1.4.dp, + color = KuringTheme.colors.mainPrimary, + shape = shape, + ) + } else { + Modifier + }, + ) + .clickable( + role = Role.Button, + onClick = onClick, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + painter = painterResource(id = category.iconRes), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(16.dp), + ) + + Text( + text = category.label, + style = KuringTheme.typography.caption1_1, + color = contentColor, + ) + } + } +} + +@Composable +private fun CampusMapSearchResultSheetEffect( + uiState: CampusMapUiState, + onResultClick: (Place) -> Unit, + onDismiss: () -> Unit, + onContentChange: (CampusMapSearchResultSheetContent?) -> Unit, +) { + val currentOnResultClick by rememberUpdatedState(onResultClick) + val currentOnDismiss by rememberUpdatedState(onDismiss) + val currentOnContentChange by rememberUpdatedState(onContentChange) + val results = uiState.searchResults + + DisposableEffect(uiState.showSearchResultSheet, results) { + currentOnContentChange( + if (uiState.showSearchResultSheet) { + CampusMapSearchResultSheetContent( + results = results, + onResultClick = { result -> currentOnResultClick(result.place) }, + onDismiss = { currentOnDismiss() }, + ) + } else { + null + }, + ) + onDispose { + currentOnContentChange(null) + } + } +} + +@SuppressLint("MissingPermission") +private fun requestCurrentLocation( + fusedLocationClient: FusedLocationProviderClient, + onLocationAvailable: (latitude: Double, longitude: Double) -> Unit, +) { + fusedLocationClient + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener { location -> + location?.let { + onLocationAvailable(it.latitude, it.longitude) + } + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/CampusPlaceMarker.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/CampusPlaceMarker.kt deleted file mode 100644 index b6e56d5fd..000000000 --- a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/CampusPlaceMarker.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.ku_stacks.ku_ring.main.campusmap.compose.component - -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import com.ku_stacks.ku_ring.designsystem.R -import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme -import com.ku_stacks.ku_ring.domain.Place -import com.ku_stacks.ku_ring.domain.Place.Priority -import com.naver.maps.geometry.LatLng -import com.naver.maps.map.compose.ExperimentalNaverMapApi -import com.naver.maps.map.compose.Marker -import com.naver.maps.map.compose.MarkerState -import com.naver.maps.map.overlay.Marker -import com.naver.maps.map.overlay.OverlayImage - -private const val ZOOM_LEVEL_PRIORITY_HIGH = 0.0 -private const val ZOOM_LEVEL_PRIORITY_MID = 14.8 -private const val ZOOM_LEVEL_PRIORITY_LOW = 15.1 - -@OptIn(ExperimentalNaverMapApi::class) -@Composable -internal fun CampusPlaceMarker( - place: Place, - isFocused: Boolean, - zIndex: Int, - onClick: (Marker) -> Unit, -) { - val position = LatLng(place.latitude, place.longitude) - val markerState = MarkerState(position) - - val iconSizeDp = if (isFocused) 40.dp else 30.dp - val priority = if (isFocused) Priority.HIGH else place.priority - val minZoom = when (priority) { - Priority.HIGH -> ZOOM_LEVEL_PRIORITY_HIGH - Priority.MIDDLE -> ZOOM_LEVEL_PRIORITY_MID - Priority.LOW -> ZOOM_LEVEL_PRIORITY_LOW - } - - Marker( - state = markerState, - icon = OverlayImage.fromResource(R.drawable.ic_map_pin_fill_v2), - iconTintColor = KuringTheme.colors.mainPrimary, - width = iconSizeDp, - height = iconSizeDp, - captionText = place.name, - zIndex = zIndex, - isHideCollidedSymbols = true, - isHideCollidedCaptions = true, - minZoom = minZoom, - onClick = { - onClick(it) - // true를 반환하면 지도 클릭 이벤트로 전파되지 않습니다. - true - }, - ) -} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/AnchoredSheetNestedScroll.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/AnchoredSheetNestedScroll.kt new file mode 100644 index 000000000..b131618cb --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/AnchoredSheetNestedScroll.kt @@ -0,0 +1,75 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet + +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.TargetedFlingBehavior +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity + +internal fun anchoredSheetNestedScrollConnection( + state: AnchoredDraggableState, + flingBehavior: TargetedFlingBehavior, +): NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return if (available.y < 0f && source == NestedScrollSource.UserInput) { + Offset(x = 0f, y = state.dispatchRawDelta(available.y)) + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return if (source == NestedScrollSource.UserInput) { + Offset(x = 0f, y = state.dispatchRawDelta(available.y)) + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if ( + available.y < 0f && + !state.offset.isNaN() && + state.offset > state.anchors.minPosition() + ) { + state.performAnchoredSheetFling(flingBehavior, available.y) + return available + } + return Velocity.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + if (!state.offset.isNaN() && state.anchors.size > 0) { + state.performAnchoredSheetFling(flingBehavior, available.y) + } + return available + } +} + +private suspend fun AnchoredDraggableState.performAnchoredSheetFling( + flingBehavior: TargetedFlingBehavior, + velocity: Float, +) { + anchoredDrag { latestAnchors -> + val flingScope = object : ScrollScope { + override fun scrollBy(pixels: Float): Float { + val previousOffset = offset + val newOffset = (previousOffset + pixels).coerceIn( + minimumValue = latestAnchors.minPosition(), + maximumValue = latestAnchors.maxPosition(), + ) + dragTo(newOffset) + return newOffset - previousOffset + } + } + with(flingBehavior) { + flingScope.performFling(velocity) + } + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapBottomSheet.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapBottomSheet.kt new file mode 100644 index 000000000..eb4e44145 --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapBottomSheet.kt @@ -0,0 +1,527 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme +import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.domain.PlaceFacility +import com.ku_stacks.ku_ring.domain.PlaceOperationHours +import com.ku_stacks.ku_ring.main.R +import com.ku_stacks.ku_ring.main.campusmap.compose.preview.CampusMapPlacePreviewParameterProvider +import com.ku_stacks.ku_ring.main.campusmap.type.CampusMapCategory + +private val CollapsedSheetHeight = 318.dp + +@Composable +internal fun CampusMapDetailSheetHost( + place: Place, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var isExpanded by rememberSaveable(place.id) { mutableStateOf(false) } + + Box( + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding(), + ) { + CampusMapBottomSheet( + place = place, + isExpanded = isExpanded, + onExpandedChange = { isExpanded = it }, + onDismiss = onDismiss, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +internal fun CampusMapBottomSheet( + place: Place, + isExpanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + CampusMapDraggableBottomSheet( + isExpanded = isExpanded, + collapsedHeight = CollapsedSheetHeight, + onExpandedChange = onExpandedChange, + onDismiss = onDismiss, + modifier = modifier.fillMaxWidth(), + ) { scrollModifier -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .then(scrollModifier) + .padding(start = 20.dp, end = 20.dp, bottom = 24.dp), + ) { + Spacer(modifier = Modifier.height(4.dp)) + + CampusMapPlaceSummary( + place = place, + onDismiss = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(20.dp)) + HorizontalDivider(color = KuringTheme.colors.borderline) + Spacer(modifier = Modifier.height(20.dp)) + + CampusMapFacilityDetails( + facilities = place.facilities, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun CampusMapPlaceSummary( + place: Place, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + PlaceHeader( + place = place, + onDismiss = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PlaceTopInfo( + place = place, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun PlaceHeader( + place: Place, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.height(60.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .padding(end = 32.dp), + ) { + Text( + text = place.name, + style = KuringTheme.typography.noticeTitle, + color = KuringTheme.colors.textTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = place.category.orDash(), + style = KuringTheme.typography.caption1_1, + color = KuringTheme.colors.textCaption1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + place.facilities + .distinctBy { it.category } + .forEach { facility -> + CampusMapFacilityIcon( + category = facility.category, + tint = KuringTheme.colors.textBody, + ) + } + } + } + } + + CloseCircleButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 12.dp, y = (-7).dp), + ) + } +} + +@Composable +private fun PlaceTopInfo( + place: Place, + modifier: Modifier = Modifier, +) { + val clipboardManager = LocalClipboardManager.current + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + AddressRow( + label = stringResource(id = R.string.campus_map_address), + value = place.address.orDash(), + onCopyClick = { + clipboardManager.setText(AnnotatedString(place.address)) + }, + ) + + CampusMapOperationHours(operationHours = place.operationHours) + } + + Spacer(modifier = Modifier.width(10.dp)) + + PlaceImage( + imageUrl = place.imageUrl, + modifier = Modifier.size(80.dp), + ) + } +} + +@Composable +private fun AddressRow( + label: String, + value: String, + onCopyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CampusMapDetailRow( + label = label, + value = value, + trailingContent = { + CopyAddressButton(onClick = onCopyClick) + }, + modifier = modifier, + ) +} + +@Composable +private fun PlaceImage( + imageUrl: String?, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFFD9D9D9)), + ) { + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun CloseCircleButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(44.dp) + .clickable( + role = Role.Button, + onClick = onClick, + ), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(KuringTheme.colors.gray300), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_x_v2), + contentDescription = stringResource(id = R.string.campus_map_close_place_detail), + tint = KuringTheme.colors.background, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Composable +private fun CopyAddressButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(44.dp) + .clickable( + role = Role.Button, + onClick = onClick, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_campus_map_copy), + contentDescription = stringResource(id = R.string.campus_map_copy_address), + tint = KuringTheme.colors.gray300, + modifier = Modifier.size(16.dp), + ) + } +} + +@Composable +private fun CampusMapFacilityDetails( + facilities: List, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + Text( + text = stringResource(id = R.string.campus_map_facility_detail_title), + style = KuringTheme.typography.caption1_1, + color = KuringTheme.colors.textTitle, + ) + + if (facilities.isEmpty()) { + Text( + text = stringResource(id = R.string.campus_map_empty_facility), + style = KuringTheme.typography.caption1, + color = KuringTheme.colors.textCaption1, + ) + } else { + facilities.forEach { facility -> + FacilityInfo(facility = facility) + } + } + } +} + +@Composable +private fun FacilityInfo( + facility: PlaceFacility, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + CampusMapFacilityIcon( + category = facility.category, + tint = KuringTheme.colors.mainPrimary, + ) + + Text( + text = facility.name, + style = KuringTheme.typography.caption1_1, + color = KuringTheme.colors.textBody, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + CampusMapDetailRow( + label = stringResource(id = R.string.campus_map_location), + value = facility.location.orDash(), + ) + + CampusMapOperationHours(operationHours = facility.operationHours) + } +} + +@Composable +private fun CampusMapOperationHours( + operationHours: PlaceOperationHours?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier, + ) { + CampusMapDetailRow( + label = stringResource(id = R.string.campus_map_operation_hours), + value = operationHours?.current.orDash(), + ) + CampusMapDetailRow( + label = stringResource(id = R.string.campus_map_semester), + value = operationHours?.semester.orDash(), + labelWidth = 76.dp, + labelStyle = KuringTheme.typography.caption1_1, + labelColor = KuringTheme.colors.textBody, + valueStyle = KuringTheme.typography.caption1_1, + modifier = Modifier.padding(start = 4.dp), + ) + CampusMapDetailRow( + label = stringResource(id = R.string.campus_map_vacation), + value = operationHours?.vacation.orDash(), + labelWidth = 76.dp, + modifier = Modifier.padding(start = 4.dp), + ) + } +} + +@Composable +private fun CampusMapDetailRow( + label: String, + value: String, + modifier: Modifier = Modifier, + labelWidth: Dp = 80.dp, + labelStyle: TextStyle = KuringTheme.typography.caption1, + labelColor: Color = KuringTheme.colors.textCaption1, + valueStyle: TextStyle = KuringTheme.typography.caption1, + valueColor: Color = KuringTheme.colors.textBody, + trailingContent: (@Composable () -> Unit)? = null, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Text( + text = label, + style = labelStyle, + color = labelColor, + maxLines = 1, + modifier = Modifier.width(labelWidth), + ) + + Text( + text = value, + style = valueStyle, + color = valueColor, + modifier = Modifier.weight(1f, fill = false), + ) + + trailingContent?.invoke() + } +} + +@Composable +private fun CampusMapFacilityIcon( + category: String, + tint: Color, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(KuringTheme.colors.gray100), + ) { + Icon( + painter = painterResource(id = CampusMapCategory.iconRes(category)), + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp), + ) + } +} + +private fun String?.orDash(): String = + takeUnless { it.isNullOrBlank() } ?: "-" + +@LightAndDarkPreview +@Composable +private fun CampusMapBottomSheetExpandedPreview( + @PreviewParameter(CampusMapPlacePreviewParameterProvider::class) place: Place, +) { + KuringTheme { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFE9EEF2)), + ) { + CampusMapBottomSheet( + place = place, + isExpanded = true, + onExpandedChange = {}, + onDismiss = {}, + ) + } + } +} + +@LightAndDarkPreview +@Composable +private fun CampusMapBottomSheetCollapsedPreview( + @PreviewParameter(CampusMapPlacePreviewParameterProvider::class) place: Place, +) { + KuringTheme { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFE9EEF2)), + ) { + CampusMapBottomSheet( + place = place, + isExpanded = false, + onExpandedChange = {}, + onDismiss = {}, + ) + } + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapDraggableBottomSheet.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapDraggableBottomSheet.kt new file mode 100644 index 000000000..14ac5c949 --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapDraggableBottomSheet.kt @@ -0,0 +1,218 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme +import kotlin.math.roundToInt + +private val CampusMapSheetShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) +private val CampusMapSheetHandleBottomSpacing = 8.dp +private val SheetAnimationEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) +private const val SheetAnimationDurationMillis = 300 +private const val SheetPositionalThresholdFraction = 0.25f + +private enum class CampusMapSheetAnchor { + Hidden, + Collapsed, + Expanded, +} + +@Composable +internal fun CampusMapDraggableBottomSheet( + isExpanded: Boolean, + collapsedHeight: Dp, + onExpandedChange: (Boolean) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (scrollModifier: Modifier) -> Unit, +) { + val density = LocalDensity.current + val targetAnchor = if (isExpanded) { + CampusMapSheetAnchor.Expanded + } else { + CampusMapSheetAnchor.Collapsed + } + val collapsedHeightPx = with(density) { collapsedHeight.toPx() } + val anchoredDraggableState = remember { + AnchoredDraggableState(initialValue = targetAnchor) + } + val sheetAnimationSpec = remember { + tween( + durationMillis = SheetAnimationDurationMillis, + easing = SheetAnimationEasing, + ) + } + val flingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = anchoredDraggableState, + positionalThreshold = { distance -> distance * SheetPositionalThresholdFraction }, + animationSpec = sheetAnimationSpec, + ) + val nestedScrollConnection = remember(anchoredDraggableState, flingBehavior) { + anchoredSheetNestedScrollConnection( + state = anchoredDraggableState, + flingBehavior = flingBehavior, + ) + } + + LaunchedEffect(targetAnchor) { + anchoredDraggableState.animateTo( + targetValue = targetAnchor, + animationSpec = sheetAnimationSpec, + ) + } + + val currentIsExpanded by rememberUpdatedState(isExpanded) + LaunchedEffect(anchoredDraggableState.settledValue) { + when (anchoredDraggableState.settledValue) { + CampusMapSheetAnchor.Expanded -> if (!currentIsExpanded) { + onExpandedChange(true) + } + + CampusMapSheetAnchor.Collapsed -> if (currentIsExpanded) { + onExpandedChange(false) + } + + CampusMapSheetAnchor.Hidden -> onDismiss() + } + } + + val scrollState = rememberScrollState() + val scrollModifier = if (isExpanded) { + Modifier + .verticalScroll(scrollState) + } else { + Modifier + } + + Box( + modifier = modifier + .fillMaxSize() + .clipToBounds() + .onSizeChanged { hostSize -> + val positions = calculateCampusMapSheetAnchorPositions( + hostHeightPx = hostSize.height.toFloat(), + collapsedHeightPx = collapsedHeightPx, + ) + anchoredDraggableState.updateAnchors( + newAnchors = DraggableAnchors { + CampusMapSheetAnchor.Expanded at positions.expanded + CampusMapSheetAnchor.Collapsed at positions.collapsed + CampusMapSheetAnchor.Hidden at positions.hidden + }, + newTarget = targetAnchor, + ) + }, + ) { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxSize() + .offset { + IntOffset( + x = 0, + y = anchoredDraggableState.requireOffset().roundToInt(), + ) + } + .clip(CampusMapSheetShape) + .background(KuringTheme.colors.background) + .nestedScroll(nestedScrollConnection) + .anchoredDraggable( + state = anchoredDraggableState, + orientation = Orientation.Vertical, + flingBehavior = flingBehavior, + ), + ) { + DragHandle() + Spacer(modifier = Modifier.height(CampusMapSheetHandleBottomSpacing)) + + content(scrollModifier) + } + } +} + +internal data class CampusMapSheetAnchorPositions( + val expanded: Float, + val collapsed: Float, + val hidden: Float, +) + +internal fun calculateCampusMapSheetAnchorPositions( + hostHeightPx: Float, + collapsedHeightPx: Float, +): CampusMapSheetAnchorPositions { + val height = hostHeightPx.coerceAtLeast(0f) + val collapsedHeight = collapsedHeightPx.coerceIn(0f, height) + + return CampusMapSheetAnchorPositions( + expanded = 0f, + collapsed = height - collapsedHeight, + hidden = height, + ) +} + +@LightAndDarkPreview +@Composable +private fun CampusMapDraggableBottomSheetPreview() { + KuringTheme { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFE9EEF2)), + ) { + CampusMapDraggableBottomSheet( + isExpanded = false, + collapsedHeight = 240.dp, + onExpandedChange = {}, + onDismiss = {}, + ) { scrollModifier -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .then(scrollModifier), + ) { + Text( + text = "Bottom sheet content", + style = KuringTheme.typography.body2, + color = KuringTheme.colors.textBody, + ) + } + } + } + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapSearchResultBottomSheet.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapSearchResultBottomSheet.kt new file mode 100644 index 000000000..d62e00309 --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/CampusMapSearchResultBottomSheet.kt @@ -0,0 +1,394 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme +import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.main.R +import com.ku_stacks.ku_ring.main.campusmap.CampusMapSearchResult +import com.ku_stacks.ku_ring.main.campusmap.buildCampusMapSearchResults +import com.ku_stacks.ku_ring.main.campusmap.compose.preview.CampusMapPlacesPreviewParameterProvider +import kotlin.math.roundToInt +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal data class CampusMapSearchResultSheetContent( + val results: ImmutableList, + val onResultClick: (CampusMapSearchResult) -> Unit, + val onDismiss: () -> Unit, +) + +@Composable +internal fun CampusMapSearchResultSheetHost( + content: CampusMapSearchResultSheetContent, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = modifier + .fillMaxSize() + .navigationBarsPadding() + .clipToBounds(), + ) { + CampusMapSearchResultBottomSheet( + results = content.results, + onResultClick = content.onResultClick, + onDismiss = content.onDismiss, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +private val SearchResultSheetHeight = 359.dp +private val SearchResultSheetBottomPadding = 96.dp +private val EmptySearchResultContentHeight = 180.dp +private val SearchResultSheetAnimationEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) +private const val SearchResultSheetAnimationDurationMillis = 300 +private const val SearchResultSheetPositionalThresholdFraction = 0.25f + +private enum class SearchResultSheetAnchor { + Visible, + Hidden, +} + +@Composable +internal fun CampusMapSearchResultBottomSheet( + results: ImmutableList, + onResultClick: (CampusMapSearchResult) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + val shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + val anchoredDraggableState = remember { + AnchoredDraggableState(initialValue = SearchResultSheetAnchor.Visible) + } + val animationSpec = remember { + tween( + durationMillis = SearchResultSheetAnimationDurationMillis, + easing = SearchResultSheetAnimationEasing, + ) + } + val flingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = anchoredDraggableState, + positionalThreshold = { distance -> + distance * SearchResultSheetPositionalThresholdFraction + }, + animationSpec = animationSpec, + ) + val nestedScrollConnection = remember(anchoredDraggableState, flingBehavior) { + anchoredSheetNestedScrollConnection( + state = anchoredDraggableState, + flingBehavior = flingBehavior, + ) + } + val currentOnDismiss by rememberUpdatedState(onDismiss) + + LaunchedEffect(results) { + scrollState.scrollTo(0) + } + + LaunchedEffect(anchoredDraggableState.settledValue) { + if (anchoredDraggableState.settledValue == SearchResultSheetAnchor.Hidden) { + currentOnDismiss() + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .height(SearchResultSheetHeight) + .onSizeChanged { sheetSize -> + val positions = calculateSearchResultSheetAnchorPositions( + sheetHeightPx = sheetSize.height.toFloat(), + ) + anchoredDraggableState.updateAnchors( + newAnchors = DraggableAnchors { + SearchResultSheetAnchor.Visible at positions.visible + SearchResultSheetAnchor.Hidden at positions.hidden + }, + newTarget = anchoredDraggableState.targetValue, + ) + } + .offset { + IntOffset( + x = 0, + y = anchoredDraggableState.offset + .takeUnless(Float::isNaN) + ?.roundToInt() + ?: 0, + ) + } + .clip(shape) + .background(KuringTheme.colors.background) + .nestedScroll(nestedScrollConnection) + .anchoredDraggable( + state = anchoredDraggableState, + orientation = Orientation.Vertical, + flingBehavior = flingBehavior, + ), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + DragHandle() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .verticalScroll(scrollState) + .padding( + start = 20.dp, + top = 12.dp, + end = 20.dp, + bottom = SearchResultSheetBottomPadding, + ), + ) { + if (results.isEmpty()) { + EmptySearchResultContent(modifier = Modifier.fillMaxWidth()) + } else { + results.forEachIndexed { index, result -> + SearchResultListItem( + title = result.title, + category = result.category, + location = result.location, + operationHours = result.operationHours, + imageUrl = result.imageUrl, + onClick = { onResultClick(result) }, + modifier = Modifier.fillMaxWidth(), + ) + + if (index != results.lastIndex) { + Spacer(modifier = Modifier.height(4.dp)) + HorizontalDivider(color = KuringTheme.colors.borderline) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + } + } + } +} + +internal data class SearchResultSheetAnchorPositions( + val visible: Float, + val hidden: Float, +) + +internal fun calculateSearchResultSheetAnchorPositions( + sheetHeightPx: Float, +): SearchResultSheetAnchorPositions = SearchResultSheetAnchorPositions( + visible = 0f, + hidden = sheetHeightPx.coerceAtLeast(0f), +) + +@Composable +internal fun SearchResultListItem( + title: String, + category: String, + location: String, + operationHours: String, + imageUrl: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = modifier + .background(KuringTheme.colors.background) + .clickable( + role = Role.Button, + onClick = onClick, + ) + .padding(vertical = 10.dp), + ) { + SearchResultThumbnail( + imageUrl = imageUrl, + modifier = Modifier.size(50.dp), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = title, + style = KuringTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold), + color = KuringTheme.colors.textBody, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 170.dp), + ) + + Text( + text = category, + style = KuringTheme.typography.caption1, + color = KuringTheme.colors.textCaption1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = location, + style = KuringTheme.typography.caption1, + color = KuringTheme.colors.textBody, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + + Box( + modifier = Modifier + .size(width = 1.dp, height = 12.dp) + .background(KuringTheme.colors.borderline), + ) + + Text( + text = operationHours, + style = KuringTheme.typography.caption1, + color = KuringTheme.colors.textBody, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SearchResultThumbnail( + imageUrl: String?, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFD9D9D9)), + ) { + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@LightAndDarkPreview +@Composable +private fun SearchResultListItemPreview() { + KuringTheme { + SearchResultListItem( + title = "상허기념도서관", + category = "프린터", + location = "1층 로비", + operationHours = "22:00에 종료", + imageUrl = null, + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + } +} + +@Composable +private fun EmptySearchResultContent( + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.height(EmptySearchResultContentHeight), + ) { + Text( + text = stringResource(id = R.string.campus_map_empty_search_result), + style = KuringTheme.typography.caption1, + color = KuringTheme.colors.textCaption1, + ) + } +} + +@LightAndDarkPreview +@Composable +private fun CampusMapSearchResultBottomSheetPreview( + @PreviewParameter(CampusMapPlacesPreviewParameterProvider::class) + places: ImmutableList, +) { + KuringTheme { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFE9EEF2)), + ) { + CampusMapSearchResultBottomSheet( + results = buildCampusMapSearchResults( + places = places, + selectedCategory = null, + ).toImmutableList(), + onResultClick = {}, + onDismiss = {}, + ) + } + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/DragHandle.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/DragHandle.kt new file mode 100644 index 000000000..f12ae2693 --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/bottomsheet/DragHandle.kt @@ -0,0 +1,48 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.component.bottomsheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme + +@Composable +internal fun DragHandle( + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Box( + modifier = Modifier + .size(width = 35.dp, height = 4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(KuringTheme.colors.gray200), + ) + } +} + +@LightAndDarkPreview +@Composable +private fun DragHandlePreview() { + KuringTheme { + Box( + modifier = Modifier + .background(KuringTheme.colors.background) + .padding(16.dp), + ) { + DragHandle() + } + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/CampusPlaceMarker.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/CampusPlaceMarker.kt new file mode 100644 index 000000000..c19286b4f --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/CampusPlaceMarker.kt @@ -0,0 +1,170 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.component.map + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme +import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.domain.Place.Priority +import com.ku_stacks.ku_ring.main.R +import com.ku_stacks.ku_ring.main.campusmap.type.CampusMapCategory +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.compose.ExperimentalNaverMapApi +import com.naver.maps.map.compose.MarkerComposable +import com.naver.maps.map.compose.rememberUpdatedMarkerState + +private const val ZOOM_LEVEL_PRIORITY_HIGH = 0.0 +private const val ZOOM_LEVEL_PRIORITY_MID = 14.8 +private const val ZOOM_LEVEL_PRIORITY_LOW = 15.1 +private const val FOCUSED_MARKER_Z_INDEX = 100 + +@OptIn(ExperimentalNaverMapApi::class) +@Composable +internal fun CampusPlaceMarker( + place: Place, + isFocused: Boolean, + selectedCategory: CampusMapCategory?, + onClick: () -> Unit, +) { + val position = LatLng(place.latitude, place.longitude) + val markerState = rememberUpdatedMarkerState(position) + val shouldForceVisible = isFocused || selectedCategory != null + val iconRes = selectedCategory?.iconRes ?: R.drawable.ic_campus_map_icon_building + + val iconWidth = if (isFocused) 40.dp else 26.dp + val iconHeight = if (isFocused) 48.dp else 26.dp + val priority = if (shouldForceVisible) Priority.HIGH else place.priority + val minZoom = when (priority) { + Priority.HIGH -> ZOOM_LEVEL_PRIORITY_HIGH + Priority.MIDDLE -> ZOOM_LEVEL_PRIORITY_MID + Priority.LOW -> ZOOM_LEVEL_PRIORITY_LOW + } + MarkerComposable( + place.id, + isFocused, + iconRes, + state = markerState, + width = iconWidth, + height = iconHeight, + anchor = Offset(0.5f, 1f), + captionText = place.name, + captionTextSize = 12.sp, + captionColor = KuringTheme.colors.mainPrimary, + captionHaloColor = KuringTheme.colors.background, + captionOffset = 2.dp, + zIndex = if (isFocused) FOCUSED_MARKER_Z_INDEX else 0, + isHideCollidedSymbols = true, + isHideCollidedCaptions = true, + isForceShowIcon = shouldForceVisible, + minZoom = minZoom, + onClick = { + onClick() + // true를 반환하면 지도 클릭 이벤트로 전파되지 않습니다. + true + }, + ) { + CampusMapMarkerIcon( + iconRes = iconRes, + isFocused = isFocused, + ) + } +} + +@Composable +private fun CampusMapMarkerIcon( + @DrawableRes iconRes: Int, + isFocused: Boolean, +) { + if (isFocused) { + SelectedMarkerIcon(iconRes = iconRes) + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(26.dp) + .shadow(elevation = 2.dp, shape = CircleShape) + .clip(CircleShape) + .background(KuringTheme.colors.mainPrimary) + .border( + width = 1.dp, + color = KuringTheme.colors.background, + shape = CircleShape, + ), + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = KuringTheme.colors.background, + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +private fun SelectedMarkerIcon( + @DrawableRes iconRes: Int, +) { + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier.size(width = 40.dp, height = 48.dp), + ) { + Image( + painter = painterResource(id = R.drawable.ic_campus_map_marker_selected), + contentDescription = null, + modifier = Modifier.size(width = 40.dp, height = 48.dp), + ) + + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = KuringTheme.colors.white, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 10.dp) + .size(20.dp), + ) + } +} + +@LightAndDarkPreview +@Composable +private fun CampusPlaceMarkerIconPreview() { + KuringTheme { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(Color(0xFFE9EEF2)) + .padding(16.dp), + ) { + CampusMapMarkerIcon( + iconRes = R.drawable.ic_campus_map_icon_building, + isFocused = false, + ) + CampusMapMarkerIcon( + iconRes = CampusMapCategory.PRINT.iconRes, + isFocused = true, + ) + } + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/CurrentLocationFab.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/CurrentLocationFab.kt new file mode 100644 index 000000000..30690ef43 --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/CurrentLocationFab.kt @@ -0,0 +1,74 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.component.map + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme +import com.ku_stacks.ku_ring.main.R + +@Composable +internal fun CurrentLocationFab( + hasLocationPermission: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(44.dp) + .shadow(elevation = 4.dp, shape = CircleShape) + .clip(CircleShape) + .background(KuringTheme.colors.background) + .clickable( + role = Role.Button, + onClick = onClick, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_crosshair_v2), + contentDescription = stringResource(id = R.string.campus_map_current_location), + tint = if (hasLocationPermission) { + KuringTheme.colors.gray400 + } else { + KuringTheme.colors.gray300 + }, + modifier = Modifier.size(26.dp), + ) + } +} + +@LightAndDarkPreview +@Composable +private fun CurrentLocationFabWithPermissionPreview() { + KuringTheme { + CurrentLocationFab( + hasLocationPermission = true, + onClick = {}, + ) + } +} + +@LightAndDarkPreview +@Composable +private fun CurrentLocationFabWithoutPermissionPreview() { + KuringTheme { + CurrentLocationFab( + hasLocationPermission = false, + onClick = {}, + ) + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/LibrarySeatFab.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/LibrarySeatFab.kt similarity index 95% rename from feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/LibrarySeatFab.kt rename to feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/LibrarySeatFab.kt index b701454a2..fceef7435 100644 --- a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/LibrarySeatFab.kt +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/LibrarySeatFab.kt @@ -1,4 +1,4 @@ -package com.ku_stacks.ku_ring.main.campusmap.compose.component +package com.ku_stacks.ku_ring.main.campusmap.compose.component.map import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -23,8 +23,8 @@ import com.ku_stacks.ku_ring.main.R @Composable internal fun LibrarySeatFab( - modifier: Modifier = Modifier, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { Button( onClick = onClick, @@ -61,6 +61,6 @@ internal fun LibrarySeatFab( @Composable private fun LibrarySeatFabPreview() { KuringTheme { - LibrarySeatFab {} + LibrarySeatFab(onClick = {}) } } diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/NaverMapSection.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/NaverMapSection.kt similarity index 63% rename from feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/NaverMapSection.kt rename to feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/NaverMapSection.kt index 6486e32ce..f1de977b9 100644 --- a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/NaverMapSection.kt +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/component/map/NaverMapSection.kt @@ -1,4 +1,4 @@ -package com.ku_stacks.ku_ring.main.campusmap.compose.component +package com.ku_stacks.ku_ring.main.campusmap.compose.component.map import android.view.Gravity import androidx.compose.foundation.layout.PaddingValues @@ -10,33 +10,40 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.main.campusmap.type.CampusMapCategory import com.naver.maps.geometry.LatLng +import com.naver.maps.geometry.LatLngBounds import com.naver.maps.map.CameraAnimation -import com.naver.maps.map.CameraPosition import com.naver.maps.map.CameraUpdate import com.naver.maps.map.compose.CameraPositionState import com.naver.maps.map.compose.ExperimentalNaverMapApi import com.naver.maps.map.compose.MapProperties +import com.naver.maps.map.compose.MapType import com.naver.maps.map.compose.MapUiSettings import com.naver.maps.map.compose.NaverMap -import com.naver.maps.map.compose.rememberCameraPositionState import kotlinx.collections.immutable.ImmutableList -private const val INITIAL_LATITUDE = 37.542366 // 지도의 초기 카메라 위치의 위도 (건국대학교 학생회관) -private const val INITIAL_LONGITUDE = 127.076846 // 지도의 초기 카메라 위치의 경도 (건국대학교 학생회관) -private const val INITIAL_ZOOM_LEVEL = 14.2 // 지도의 초기 확대 레벨 -private const val MIN_ZOOM_LEVEL = 5.0 // 사용자가 설정할 수 있는 지도의 최소 확대 레벨 -private const val MAX_ZOOM_LEVEL = 21.0 // 사용자가 설정할 수 있는 지도의 최대 확대 레벨 +private const val MIN_ZOOM_LEVEL = 13.0 // 지도를 축소할 수 있는 한계 +private const val MAX_ZOOM_LEVEL = 21.0 // 지도를 확대할 수 있는 한계 +private val CAMPUS_MAP_EXTENT = LatLngBounds( + LatLng(37.5320, 127.0650), + LatLng(37.5505, 127.0910), +) @OptIn(ExperimentalNaverMapApi::class) @Composable internal fun NaverMapSection( campusPlaces: ImmutableList, focusedPlace: Place?, + selectedCategory: CampusMapCategory?, + cameraPositionState: CameraPositionState, onMapPinClick: (Place) -> Unit, + onMapClick: () -> Unit, modifier: Modifier = Modifier, ) { + val isDarkTheme = !KuringTheme.colors.isLight val mapUiSettings = MapUiSettings( isTiltGesturesEnabled = false, isScaleBarEnabled = false, @@ -49,19 +56,20 @@ internal fun NaverMapSection( ) ) val mapProperties = MapProperties( + mapType = if (isDarkTheme) MapType.Navi else MapType.Basic, + extent = CAMPUS_MAP_EXTENT, minZoom = MIN_ZOOM_LEVEL, maxZoom = MAX_ZOOM_LEVEL, + isNightModeEnabled = isDarkTheme, ) - val cameraPositionState = rememberCameraPositionState { - position = CameraPosition( - LatLng(INITIAL_LATITUDE, INITIAL_LONGITUDE), - INITIAL_ZOOM_LEVEL, - ) - } LaunchedEffect(focusedPlace) { focusedPlace?.let { place -> - cameraPositionState.animateMoveTo(place) + cameraPositionState.move( + update = CameraUpdate + .scrollTo(LatLng(place.latitude, place.longitude)) + .animate(CameraAnimation.Fly), + ) } } @@ -69,27 +77,20 @@ internal fun NaverMapSection( properties = mapProperties, uiSettings = mapUiSettings, cameraPositionState = cameraPositionState, + onMapClick = { _, _ -> + onMapClick() + }, modifier = modifier.fillMaxSize(), ) { - campusPlaces.forEach { place -> val isFocused = place == focusedPlace CampusPlaceMarker( place = place, isFocused = isFocused, - zIndex = if (isFocused) 100 else 0, + selectedCategory = selectedCategory, ) { onMapPinClick(place) } } } } - -@OptIn(ExperimentalNaverMapApi::class) -private fun CameraPositionState.animateMoveTo(place: Place) { - move( - update = CameraUpdate - .scrollTo(LatLng(place.latitude, place.longitude)) - .animate(CameraAnimation.Fly) - ) -} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/inner_screen/CampusMapSearchScreen.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/inner_screen/CampusMapSearchScreen.kt new file mode 100644 index 000000000..9b21298ce --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/inner_screen/CampusMapSearchScreen.kt @@ -0,0 +1,581 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.inner_screen + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview +import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme +import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.main.R +import com.ku_stacks.ku_ring.main.campusmap.CampusMapRecentSearch +import com.ku_stacks.ku_ring.main.campusmap.CampusMapSearchResult +import com.ku_stacks.ku_ring.main.campusmap.CampusMapViewModel +import com.ku_stacks.ku_ring.main.campusmap.buildCampusMapSearchResults +import com.ku_stacks.ku_ring.main.campusmap.compose.preview.CampusMapPlacesPreviewParameterProvider +import com.ku_stacks.ku_ring.main.campusmap.type.CampusMapCategory +import com.ku_stacks.ku_ring.navigation.KuringRoute +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.Serializable + +@Serializable +internal data object CampusMapSearchDestination : KuringRoute + +@Composable +internal fun CampusMapSearchRoute( + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, + viewModel: CampusMapViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + CampusMapSearchScreen( + searchQuery = uiState.searchInput, + recentSearches = uiState.recentSearches, + searchResults = uiState.liveSearchResults, + onQueryChange = viewModel::updateSearchInput, + onQueryClear = viewModel::clearSearchInput, + onNavigateUp = onNavigateUp, + onSearchSubmit = { + viewModel.submitSearch() + onNavigateUp() + }, + onResultClick = { result -> + viewModel.selectSearchPlace(result.place) + onNavigateUp() + }, + onRecentClick = { recentSearch -> + viewModel.selectRecentSearch(recentSearch) + onNavigateUp() + }, + onRecentDelete = viewModel::deleteRecentSearch, + onRecentClear = viewModel::clearRecentSearches, + modifier = modifier, + ) +} + +@Composable +internal fun CampusMapSearchScreen( + searchQuery: String, + recentSearches: ImmutableList, + searchResults: ImmutableList, + onQueryChange: (String) -> Unit, + onQueryClear: () -> Unit, + onNavigateUp: () -> Unit, + onSearchSubmit: () -> Unit, + onResultClick: (CampusMapSearchResult) -> Unit, + onRecentClick: (CampusMapRecentSearch) -> Unit, + onRecentDelete: (CampusMapRecentSearch) -> Unit, + onRecentClear: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(KuringTheme.colors.background), + ) { + Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + + CampusMapSearchTopBar( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + query = searchQuery, + onQueryChange = onQueryChange, + onQueryClear = onQueryClear, + onNavigateUp = onNavigateUp, + onSearchSubmit = onSearchSubmit, + ) + + CampusMapSearchContent( + modifier = Modifier.fillMaxSize(), + query = searchQuery, + recentSearches = recentSearches, + searchResults = searchResults, + onResultClick = onResultClick, + onRecentClick = onRecentClick, + onRecentDelete = onRecentDelete, + onRecentClear = onRecentClear, + ) + } +} + +@Composable +private fun CampusMapSearchTopBar( + query: String, + onQueryChange: (String) -> Unit, + onQueryClear: () -> Unit, + onNavigateUp: () -> Unit, + onSearchSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(start = 4.dp, end = 20.dp, top = 8.dp, bottom = 8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(44.dp) + .clickable( + role = Role.Button, + onClick = onNavigateUp, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_chevron_left_v2), + contentDescription = stringResource(id = R.string.campus_map_search_navigate_up), + tint = KuringTheme.colors.gray600, + modifier = Modifier.size(24.dp), + ) + } + + CampusMapSearchTextField( + query = query, + onQueryChange = onQueryChange, + onQueryClear = onQueryClear, + onSearchSubmit = onSearchSubmit, + modifier = Modifier + .weight(1f) + .height(40.dp), + ) + } +} + +@Composable +private fun CampusMapSearchTextField( + query: String, + onQueryChange: (String) -> Unit, + onQueryClear: () -> Unit, + onSearchSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + var textFieldValue by remember { + mutableStateOf( + TextFieldValue( + text = query, + selection = TextRange(query.length), + ), + ) + } + + LaunchedEffect(query) { + if (textFieldValue.text != query) { + textFieldValue = TextFieldValue( + text = query, + selection = TextRange(query.length), + ) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + + BasicTextField( + value = textFieldValue, + onValueChange = { updatedValue -> + textFieldValue = updatedValue + if (updatedValue.text != query) { + onQueryChange(updatedValue.text) + } + }, + singleLine = true, + textStyle = KuringTheme.typography.body2.copy( + color = KuringTheme.colors.textBody, + fontWeight = FontWeight.Medium, + ), + cursorBrush = SolidColor(KuringTheme.colors.mainPrimary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + onSearchSubmit() + }, + ), + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(KuringTheme.colors.gray100) + .focusRequester(focusRequester) + .padding(start = 16.dp, end = 12.dp), + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize(), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(1f), + ) { + if (textFieldValue.text.isEmpty()) { + Text( + text = stringResource(id = R.string.campus_map_search_placeholder), + style = KuringTheme.typography.body2, + color = KuringTheme.colors.textCaption1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + innerTextField() + } + + SearchFieldTrailingIcon( + hasQuery = textFieldValue.text.isNotEmpty(), + onQueryClear = onQueryClear, + ) + } + }, + ) +} + +@Composable +private fun SearchFieldTrailingIcon( + hasQuery: Boolean, + onQueryClear: () -> Unit, + modifier: Modifier = Modifier, +) { + if (hasQuery) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(32.dp) + .clickable( + role = Role.Button, + onClick = onQueryClear, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_campus_map_search_clear), + contentDescription = stringResource(id = R.string.campus_map_clear_search_query), + tint = Color.Unspecified, + modifier = Modifier.size(24.dp), + ) + } + } else { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_search_v2), + contentDescription = null, + tint = KuringTheme.colors.gray300, + modifier = modifier.size(24.dp), + ) + } +} + +@Composable +private fun CampusMapSearchContent( + query: String, + recentSearches: ImmutableList, + searchResults: ImmutableList, + onResultClick: (CampusMapSearchResult) -> Unit, + onRecentClick: (CampusMapRecentSearch) -> Unit, + onRecentDelete: (CampusMapRecentSearch) -> Unit, + onRecentClear: () -> Unit, + modifier: Modifier = Modifier, +) { + val trimmedQuery = query.trim() + + if (trimmedQuery.isEmpty() && recentSearches.isEmpty()) { + CampusMapSearchEmptyState( + message = stringResource(id = R.string.campus_map_empty_recent_search), + modifier = modifier, + ) + return + } + + if (trimmedQuery.isNotEmpty() && searchResults.isEmpty()) { + CampusMapSearchEmptyState( + message = stringResource(id = R.string.campus_map_empty_search_result), + modifier = modifier, + ) + return + } + + LazyColumn( + contentPadding = PaddingValues(top = 4.dp, bottom = 24.dp), + modifier = modifier, + ) { + if (trimmedQuery.isEmpty()) { + item(key = "recent-search-header") { + RecentSearchHeader( + onRecentClear = onRecentClear, + modifier = Modifier.fillMaxWidth(), + ) + } + + items( + items = recentSearches, + key = CampusMapRecentSearch::id, + ) { recentSearch -> + CampusMapSearchPlaceItem( + label = recentSearch.label, + iconRes = recentSearch.iconRes, + query = "", + showDelete = true, + onClick = { onRecentClick(recentSearch) }, + onDelete = { onRecentDelete(recentSearch) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + items( + items = searchResults, + key = CampusMapSearchResult::id, + ) { result -> + CampusMapSearchPlaceItem( + label = result.title, + iconRes = CampusMapCategory.iconRes(result.category), + query = trimmedQuery, + showDelete = false, + onClick = { onResultClick(result) }, + onDelete = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun RecentSearchHeader( + onRecentClear: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(44.dp) + .padding(horizontal = 24.dp), + ) { + Text( + text = stringResource(id = R.string.campus_map_recent_search_title), + style = KuringTheme.typography.body2.copy( + color = KuringTheme.colors.textBody, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.weight(1f), + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .height(44.dp) + .clickable( + role = Role.Button, + onClick = onRecentClear, + ) + .padding(horizontal = 4.dp), + ) { + Text( + text = stringResource(id = R.string.delete_all_keyword_history), + style = KuringTheme.typography.caption1, + color = KuringTheme.colors.textCaption1, + ) + } + } +} + +@Composable +private fun CampusMapSearchEmptyState( + message: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Spacer(modifier = Modifier.height(90.dp)) + + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_campus_map_empty_search), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(150.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = message, + style = KuringTheme.typography.body2, + color = KuringTheme.colors.textCaption1, + ) + } +} + +@Composable +private fun CampusMapSearchPlaceItem( + label: String, + @DrawableRes iconRes: Int, + query: String, + showDelete: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(44.dp) + .clickable( + role = Role.Button, + onClick = onClick, + ) + .padding(start = 28.dp, end = 24.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(24.dp) + .background( + color = KuringTheme.colors.gray100, + shape = CircleShape, + ), + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = KuringTheme.colors.gray400, + modifier = Modifier.size(20.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = highlightedPlaceName( + name = label, + query = query, + highlightStyle = SpanStyle(color = KuringTheme.colors.mainPrimary), + ), + style = KuringTheme.typography.body2.copy( + color = KuringTheme.colors.textBody, + fontWeight = FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (showDelete) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(32.dp) + .clickable( + role = Role.Button, + onClick = onDelete, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_x_v2), + contentDescription = stringResource(id = R.string.campus_map_delete_recent_search), + tint = KuringTheme.colors.gray300, + modifier = Modifier.size(20.dp), + ) + } + } else { + Spacer(modifier = Modifier.size(32.dp)) + } + } +} + +private fun highlightedPlaceName( + name: String, + query: String, + highlightStyle: SpanStyle, +) = buildAnnotatedString { + append(name) + + val startIndex = name.indexOf(query, ignoreCase = true) + if (query.isEmpty() || startIndex == -1) { + return@buildAnnotatedString + } + + val endIndex = startIndex + query.length + addStyle(highlightStyle, startIndex, endIndex) +} + +@LightAndDarkPreview +@Composable +private fun CampusMapSearchScreenPreview( + @PreviewParameter(CampusMapPlacesPreviewParameterProvider::class) + places: ImmutableList, +) { + KuringTheme { + CampusMapSearchScreen( + searchQuery = "도서관", + recentSearches = places + .map(CampusMapRecentSearch::PlaceResult) + .toImmutableList(), + searchResults = buildCampusMapSearchResults( + places = places, + selectedCategory = null, + ).toImmutableList(), + onQueryChange = {}, + onQueryClear = {}, + onNavigateUp = {}, + onSearchSubmit = {}, + onResultClick = {}, + onRecentClick = {}, + onRecentDelete = {}, + onRecentClear = {}, + ) + } +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/preview/CampusMapPreviewParameterProvider.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/preview/CampusMapPreviewParameterProvider.kt new file mode 100644 index 000000000..f9ed0fca6 --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/compose/preview/CampusMapPreviewParameterProvider.kt @@ -0,0 +1,84 @@ +package com.ku_stacks.ku_ring.main.campusmap.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.domain.PlaceFacility +import com.ku_stacks.ku_ring.domain.PlaceOperationHours +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +class CampusMapPlacePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(CampusMapPreviewSample.place) +} + +class CampusMapPlacesPreviewParameterProvider : PreviewParameterProvider> { + override val values: Sequence> + get() = sequenceOf( + persistentListOf( + CampusMapPreviewSample.place, + CampusMapPreviewSample.place.copy( + id = "preview-building-2", + name = "학생회관", + category = "복지시설", + address = "서울 광진구 능동로 120 학생회관", + ), + CampusMapPreviewSample.place.copy( + id = "preview-building-3", + name = "새천년관", + category = "강의동", + address = "서울 광진구 능동로 120 새천년관", + ), + ) + ) +} + +private object CampusMapPreviewSample { + val place = Place( + id = "preview-building", + name = "상허기념도서관", + category = "건물", + address = "서울 광진구 능동로 120", + latitude = 37.541, + longitude = 127.078, + priority = Place.Priority.HIGH, + imageUrl = null, + operationHours = PlaceOperationHours( + current = "22:00에 종료", + semester = "09:00-22:00", + vacation = "10:00-17:00", + ), + facilities = listOf( + PlaceFacility( + name = "프린터", + category = "프린터", + location = "1층 로비", + operationHours = PlaceOperationHours( + current = "22:00에 종료", + semester = "09:00-22:00", + vacation = "10:00-17:00", + ), + ), + PlaceFacility( + name = "카페", + category = "카페", + location = "지하 1층", + operationHours = PlaceOperationHours( + current = "20:00에 종료", + semester = "08:30-20:00", + vacation = "10:00-16:00", + ), + ), + PlaceFacility( + name = "K-CUBE", + category = "k-cube", + location = "2층", + operationHours = PlaceOperationHours( + current = "18:00에 종료", + semester = "09:00-18:00", + vacation = "휴무", + ), + ), + ), + ) +} diff --git a/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/type/CampusMapCategory.kt b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/type/CampusMapCategory.kt new file mode 100644 index 000000000..7543711f5 --- /dev/null +++ b/feature/main/src/main/java/com/ku_stacks/ku_ring/main/campusmap/type/CampusMapCategory.kt @@ -0,0 +1,76 @@ +package com.ku_stacks.ku_ring.main.campusmap.type + +import androidx.annotation.DrawableRes +import com.ku_stacks.ku_ring.domain.Place +import com.ku_stacks.ku_ring.main.R + +internal enum class CampusMapCategory( + val label: String, + private val matchingNames: Set, + @get:DrawableRes val iconRes: Int, +) { + CAFE("교내 카페", setOf("카페", "교내 카페"), R.drawable.ic_campus_map_icon_cafe), + RESTAURANT("식당", setOf("식당"), R.drawable.ic_campus_map_icon_restaurant), + PRINT("프린터", setOf("프린터", "프린터기", "프린트", "복사", "복사실"), R.drawable.ic_campus_map_icon_print, + ), + SMOKE("흡연부스", setOf("흡연부스", "흡연"), R.drawable.ic_campus_map_icon_smoke), + STORE("편의점", setOf("편의점", "매점"), R.drawable.ic_campus_map_icon_store), + REST("휴게실", setOf("휴게실", "쉼터"), R.drawable.ic_campus_map_icon_rest), + K_CUBE("k-cube", setOf("k-cube", "K-CUBE", "케이큐브"), R.drawable.ic_campus_map_icon_kcube); + + fun matches(category: String): Boolean = + matchingNames.any { it.equals(category, ignoreCase = true) } + + companion object { + @DrawableRes + fun iconRes(category: String): Int = when { + category.contains("카페") -> CAFE.iconRes + category.contains("식당") -> RESTAURANT.iconRes + category.contains("프린") || category.contains("복사") -> PRINT.iconRes + category.contains("흡연") -> SMOKE.iconRes + category.contains("편의점") || category.contains("매점") -> STORE.iconRes + category.contains("휴게") || category.contains("쉼터") -> REST.iconRes + category.contains("cube", ignoreCase = true) || category.contains("큐브") -> { + K_CUBE.iconRes + } + + else -> R.drawable.ic_campus_map_icon_building + } + } +} + +internal fun filterCampusPlaces( + places: List, + selectedCategory: CampusMapCategory?, +): List { + if (selectedCategory == null) return places + + return places.filter { place -> + place.hasFacilityOf(selectedCategory) + } +} + +private fun Place.hasFacilityOf(category: CampusMapCategory): Boolean = + facilities.any { facility -> category.matches(facility.category) } + +internal fun searchCampusPlaces( + places: List, + query: String, +): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isEmpty()) return emptyList() + + return places.filter { place -> + place.containsSearchQuery(normalizedQuery) + } +} + +private fun Place.containsSearchQuery(query: String): Boolean = + name.contains(query, ignoreCase = true) || + address.contains(query, ignoreCase = true) || + category.contains(query, ignoreCase = true) || + facilities.any { facility -> + facility.name.contains(query, ignoreCase = true) || + facility.category.contains(query, ignoreCase = true) || + facility.location?.contains(query, ignoreCase = true) == true + } diff --git a/feature/main/src/main/res/drawable/ic_campus_map_copy.xml b/feature/main/src/main/res/drawable/ic_campus_map_copy.xml new file mode 100644 index 000000000..3d3aa2e91 --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_empty_search.xml b/feature/main/src/main/res/drawable/ic_campus_map_empty_search.xml new file mode 100644 index 000000000..d7b9a3522 --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_empty_search.xml @@ -0,0 +1,33 @@ + + + + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_building.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_building.xml new file mode 100644 index 000000000..86f964e55 --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_building.xml @@ -0,0 +1,20 @@ + + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_cafe.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_cafe.xml new file mode 100644 index 000000000..3573adeba --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_cafe.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_kcube.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_kcube.xml new file mode 100644 index 000000000..62854706b --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_kcube.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_print.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_print.xml new file mode 100644 index 000000000..9f1f6c995 --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_print.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_rest.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_rest.xml new file mode 100644 index 000000000..d389eb98a --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_rest.xml @@ -0,0 +1,12 @@ + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_restaurant.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_restaurant.xml new file mode 100644 index 000000000..2c08ddef7 --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_restaurant.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_smoke.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_smoke.xml new file mode 100644 index 000000000..76547708b --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_smoke.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_icon_store.xml b/feature/main/src/main/res/drawable/ic_campus_map_icon_store.xml new file mode 100644 index 000000000..ff6f3e5fc --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_icon_store.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_marker_selected.xml b/feature/main/src/main/res/drawable/ic_campus_map_marker_selected.xml new file mode 100644 index 000000000..283b3121f --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_marker_selected.xml @@ -0,0 +1,14 @@ + + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_recent_search.xml b/feature/main/src/main/res/drawable/ic_campus_map_recent_search.xml new file mode 100644 index 000000000..d090fed2e --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_recent_search.xml @@ -0,0 +1,20 @@ + + + + diff --git a/feature/main/src/main/res/drawable/ic_campus_map_search_clear.xml b/feature/main/src/main/res/drawable/ic_campus_map_search_clear.xml new file mode 100644 index 000000000..49efb87af --- /dev/null +++ b/feature/main/src/main/res/drawable/ic_campus_map_search_clear.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/feature/main/src/main/res/values/strings.xml b/feature/main/src/main/res/values/strings.xml index c07eb16e8..258d26c1a 100644 --- a/feature/main/src/main/res/values/strings.xml +++ b/feature/main/src/main/res/values/strings.xml @@ -123,6 +123,24 @@ 캠퍼스맵 서비스 준비 중이에요 열람실 좌석 보기 + 건물명 및 위치 검색 + 캠퍼스맵 검색 닫기 + 검색어 지우기 + 최근 검색어 + 최근 검색어가 아직 없습니다. + 검색 결과가 없습니다. + 최근 검색어 삭제 + 현재 위치 보기 + 현재 위치로 이동하려면 설정에서\n위치 권한을 허용해주세요. + 장소 정보 닫기 + 주소 + 주소 복사 + 편의시설 상세 정보 + 등록된 시설 정보가 없어요 + 위치 + 운영시간 + 학기 중 + 방학 중 이번 주 예정된 학사일정을 확인해보아요