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위치 권한을 허용해주세요.
+ 장소 정보 닫기
+ 주소
+ 주소 복사
+ 편의시설 상세 정보
+ 등록된 시설 정보가 없어요
+ 위치
+ 운영시간
+ 학기 중
+ 방학 중
이번 주 예정된 학사일정을 확인해보아요