Skip to content

Commit 40a2e37

Browse files
committed
feat: 주소검색 추가
1 parent d3d6187 commit 40a2e37

File tree

29 files changed

+704
-34
lines changed

29 files changed

+704
-34
lines changed

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(xargs grep:*)"
5+
]
6+
}
7+
}

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
<uses-permission android:name="android.permission.RECORD_AUDIO" />
88
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
99
<uses-permission android:name="android.permission.VIBRATE" />
10+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
11+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
1012

1113
<uses-feature android:name="android.hardware.camera.any" />
1214

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.no5ing.bbibbi.data.datasource.network
2+
3+
import com.no5ing.bbibbi.data.datasource.network.response.KakaoSearchResponse
4+
import retrofit2.http.GET
5+
import retrofit2.http.Header
6+
import retrofit2.http.Query
7+
8+
interface KakaoLocalApi {
9+
10+
@GET("v2/local/search/keyword.json")
11+
suspend fun searchKeyword(
12+
@Header("Authorization") authorization: String,
13+
@Query("query") query: String,
14+
@Query("x") x: String? = null,
15+
@Query("y") y: String? = null,
16+
@Query("radius") radius: Int? = null,
17+
@Query("page") page: Int? = null,
18+
@Query("size") size: Int? = null,
19+
): KakaoSearchResponse
20+
}

app/src/main/java/com/no5ing/bbibbi/data/datasource/network/request/post/CreatePostRequest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@ data class CreatePostRequest(
99
val imageUrl: String,
1010
val content: String,
1111
val uploadTime: String,
12+
val latitude: Double? = null,
13+
val longitude: Double? = null,
14+
val address: String? = null,
1215
) : Parcelable, BaseModel()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.no5ing.bbibbi.data.datasource.network.response
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
4+
import com.fasterxml.jackson.annotation.JsonProperty
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
data class KakaoSearchResponse(
8+
val meta: KakaoSearchMeta,
9+
val documents: List<KakaoSearchDocument>,
10+
)
11+
12+
@JsonIgnoreProperties(ignoreUnknown = true)
13+
data class KakaoSearchMeta(
14+
@JsonProperty("total_count") val totalCount: Int,
15+
@JsonProperty("pageable_count") val pageableCount: Int,
16+
@JsonProperty("is_end") val isEnd: Boolean,
17+
)
18+
19+
@JsonIgnoreProperties(ignoreUnknown = true)
20+
data class KakaoSearchDocument(
21+
val id: String = "",
22+
@JsonProperty("place_name") val placeName: String = "",
23+
@JsonProperty("address_name") val addressName: String = "",
24+
@JsonProperty("road_address_name") val roadAddressName: String = "",
25+
val x: String = "",
26+
val y: String = "",
27+
val phone: String = "",
28+
val distance: String = "",
29+
@JsonProperty("category_name") val categoryName: String = "",
30+
)

app/src/main/java/com/no5ing/bbibbi/data/model/post/DailyCalendarElement.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ data class DailyCalendarElement(
1919
val emojiCount: Int,
2020
val allFamilyMembersUploaded: Boolean,
2121
val createdAt: ZonedDateTime,
22+
val address: String?,
2223
) : Parcelable, BaseModel() {
2324
fun toPost() = Post(
2425
postId = postId,
@@ -30,5 +31,6 @@ data class DailyCalendarElement(
3031
imageUrl = postImgUrl,
3132
content = postContent,
3233
createdAt = createdAt,
34+
address = address,
3335
)
3436
}

app/src/main/java/com/no5ing/bbibbi/data/model/post/Post.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ data class Post(
1515
val emojiCount: Int,
1616
val imageUrl: String,
1717
val content: String,
18+
val address: String?,
1819
val createdAt: ZonedDateTime,
1920
) : Parcelable, BaseModel()
2021

app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
77
import com.fasterxml.jackson.module.kotlin.kotlinModule
88
import com.google.gson.Gson
99
import com.no5ing.bbibbi.BuildConfig
10+
import com.no5ing.bbibbi.data.datasource.network.KakaoLocalApi
1011
import com.no5ing.bbibbi.data.datasource.network.RestAPI
1112
import com.no5ing.bbibbi.data.model.auth.AuthResult
1213
import com.skydoves.sandwich.SandwichInitializer
@@ -215,6 +216,22 @@ object NetworkModule {
215216
.build()
216217
}
217218

219+
@Provides
220+
@Singleton
221+
fun provideKakaoLocalApi(): KakaoLocalApi {
222+
return Retrofit.Builder()
223+
.baseUrl("https://dapi.kakao.com/")
224+
.addConverterFactory(
225+
JacksonConverterFactory.create(
226+
jacksonObjectMapper()
227+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
228+
.registerModule(kotlinModule())
229+
)
230+
)
231+
.build()
232+
.create(KakaoLocalApi::class.java)
233+
}
234+
218235
@Provides
219236
@Singleton
220237
fun provideRestFamilyApi(retrofit: Retrofit): RestAPI.FamilyApi {

app/src/main/java/com/no5ing/bbibbi/presentation/feature/state/main/home/HomePageState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ fun rememberHomePageState(
3030
createdAt = ZonedDateTime.now(),
3131
missionId = null,
3232
type = PostType.SURVIVAL,
33+
address = null,
3334
)
3435
)
3536
},
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package com.no5ing.bbibbi.presentation.feature.view.main.location_picker
2+
3+
import androidx.compose.foundation.clickable
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.Spacer
9+
import androidx.compose.foundation.layout.fillMaxSize
10+
import androidx.compose.foundation.layout.fillMaxWidth
11+
import androidx.compose.foundation.layout.height
12+
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.layout.size
14+
import androidx.compose.foundation.layout.width
15+
import androidx.compose.foundation.lazy.LazyColumn
16+
import androidx.compose.foundation.lazy.items
17+
import androidx.compose.foundation.shape.RoundedCornerShape
18+
import androidx.compose.foundation.text.KeyboardActions
19+
import androidx.compose.foundation.text.KeyboardOptions
20+
import androidx.compose.material3.CircularProgressIndicator
21+
import androidx.compose.material3.Divider
22+
import androidx.compose.material3.Icon
23+
import androidx.compose.material3.MaterialTheme
24+
import androidx.compose.material3.Text
25+
import androidx.compose.material3.TextField
26+
import androidx.compose.material3.TextFieldDefaults
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.LaunchedEffect
29+
import androidx.compose.runtime.collectAsState
30+
import androidx.compose.runtime.getValue
31+
import androidx.compose.runtime.mutableStateOf
32+
import androidx.compose.runtime.remember
33+
import androidx.compose.runtime.setValue
34+
import androidx.compose.ui.Alignment
35+
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.graphics.Color
37+
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
38+
import androidx.compose.ui.res.painterResource
39+
import androidx.compose.ui.res.stringResource
40+
import androidx.compose.ui.text.input.ImeAction
41+
import androidx.compose.ui.text.style.TextAlign
42+
import androidx.compose.ui.text.style.TextOverflow
43+
import androidx.compose.ui.unit.dp
44+
import androidx.hilt.navigation.compose.hiltViewModel
45+
import com.no5ing.bbibbi.R
46+
import com.no5ing.bbibbi.presentation.component.BBiBBiSurface
47+
import com.no5ing.bbibbi.presentation.component.DisposableTopBar
48+
import com.no5ing.bbibbi.presentation.feature.view_model.location.LocationSearchViewModel
49+
import com.no5ing.bbibbi.presentation.theme.bbibbiScheme
50+
import com.no5ing.bbibbi.presentation.theme.bbibbiTypo
51+
52+
@Composable
53+
fun LocationPickerPage(
54+
onDispose: () -> Unit = {},
55+
onConfirmLocation: (latitude: Double, longitude: Double, address: String) -> Unit = { _, _, _ -> },
56+
currentLatitude: Double? = null,
57+
currentLongitude: Double? = null,
58+
locationSearchViewModel: LocationSearchViewModel = hiltViewModel(),
59+
) {
60+
var query by remember { mutableStateOf("") }
61+
val searchResults by locationSearchViewModel.searchResults.collectAsState()
62+
val isLoading by locationSearchViewModel.isLoading.collectAsState()
63+
val keyboardController = LocalSoftwareKeyboardController.current
64+
65+
LaunchedEffect(Unit) {
66+
if (currentLatitude != null && currentLongitude != null) {
67+
locationSearchViewModel.search("", currentLatitude, currentLongitude)
68+
}
69+
}
70+
71+
BBiBBiSurface(
72+
modifier = Modifier.fillMaxSize(),
73+
) {
74+
Column(
75+
modifier = Modifier.fillMaxSize(),
76+
) {
77+
DisposableTopBar(
78+
onDispose = onDispose,
79+
title = stringResource(id = R.string.location_picker_title),
80+
)
81+
82+
// Search bar
83+
TextField(
84+
value = query,
85+
onValueChange = { query = it },
86+
modifier = Modifier
87+
.fillMaxWidth()
88+
.padding(horizontal = 16.dp, vertical = 8.dp),
89+
placeholder = {
90+
Text(
91+
text = stringResource(id = R.string.location_search_hint),
92+
color = MaterialTheme.bbibbiScheme.icon,
93+
)
94+
},
95+
leadingIcon = {
96+
Icon(
97+
painter = painterResource(id = R.drawable.search_icon),
98+
contentDescription = null,
99+
tint = MaterialTheme.bbibbiScheme.icon,
100+
modifier = Modifier.size(20.dp),
101+
)
102+
},
103+
singleLine = true,
104+
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105+
keyboardActions = KeyboardActions(
106+
onSearch = {
107+
keyboardController?.hide()
108+
locationSearchViewModel.search(query, currentLatitude, currentLongitude)
109+
}
110+
),
111+
shape = RoundedCornerShape(12.dp),
112+
colors = TextFieldDefaults.colors(
113+
focusedContainerColor = MaterialTheme.bbibbiScheme.backgroundHover,
114+
unfocusedContainerColor = MaterialTheme.bbibbiScheme.backgroundHover,
115+
focusedIndicatorColor = Color.Transparent,
116+
unfocusedIndicatorColor = Color.Transparent,
117+
focusedTextColor = MaterialTheme.bbibbiScheme.textPrimary,
118+
unfocusedTextColor = MaterialTheme.bbibbiScheme.textPrimary,
119+
cursorColor = MaterialTheme.bbibbiScheme.mainYellow,
120+
),
121+
)
122+
123+
// Results
124+
if (isLoading) {
125+
Box(
126+
modifier = Modifier
127+
.fillMaxWidth()
128+
.padding(32.dp),
129+
contentAlignment = Alignment.Center,
130+
) {
131+
CircularProgressIndicator(
132+
color = MaterialTheme.bbibbiScheme.mainYellow,
133+
modifier = Modifier.size(24.dp),
134+
strokeWidth = 2.dp,
135+
)
136+
}
137+
} else if (searchResults.isEmpty()) {
138+
Box(
139+
modifier = Modifier
140+
.fillMaxSize(),
141+
contentAlignment = Alignment.Center,
142+
) {
143+
Text(
144+
text = "아직 위치가 없어요.\n검색으로 위치를 추가할 수 있어요.",
145+
color = MaterialTheme.bbibbiScheme.gray500,
146+
style = MaterialTheme.bbibbiTypo.bodyOneRegular,
147+
textAlign = TextAlign.Center,
148+
)
149+
}
150+
} else {
151+
LazyColumn(
152+
modifier = Modifier.fillMaxSize(),
153+
) {
154+
items(searchResults) { document ->
155+
Column(
156+
modifier = Modifier
157+
.fillMaxWidth()
158+
.clickable {
159+
val lat = document.y.toDoubleOrNull() ?: return@clickable
160+
val lng = document.x.toDoubleOrNull() ?: return@clickable
161+
val address = document.placeName
162+
onConfirmLocation(lat, lng, address)
163+
}
164+
.padding(horizontal = 20.dp, vertical = 14.dp),
165+
) {
166+
Text(
167+
text = document.placeName,
168+
color = MaterialTheme.bbibbiScheme.textPrimary,
169+
style = MaterialTheme.bbibbiTypo.bodyOneBold,
170+
maxLines = 1,
171+
overflow = TextOverflow.Ellipsis,
172+
)
173+
Spacer(modifier = Modifier.height(4.dp))
174+
val distanceText = when {
175+
document.distance.isNotEmpty() -> formatDistance(document.distance)
176+
currentLatitude != null && currentLongitude != null -> {
177+
val docLat = document.y.toDoubleOrNull()
178+
val docLng = document.x.toDoubleOrNull()
179+
if (docLat != null && docLng != null) {
180+
formatDistance(calculateDistance(currentLatitude, currentLongitude, docLat, docLng).toString())
181+
} else null
182+
}
183+
else -> null
184+
}
185+
Text(
186+
text = distanceText ?: document.roadAddressName.ifEmpty { document.addressName },
187+
color = MaterialTheme.bbibbiScheme.textSecondary,
188+
style = MaterialTheme.bbibbiTypo.bodyTwoRegular,
189+
maxLines = 1,
190+
overflow = TextOverflow.Ellipsis,
191+
)
192+
}
193+
}
194+
}
195+
}
196+
}
197+
}
198+
}
199+
200+
private fun calculateDistance(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Int {
201+
val r = 6371000.0 // Earth radius in meters
202+
val dLat = Math.toRadians(lat2 - lat1)
203+
val dLng = Math.toRadians(lng2 - lng1)
204+
val a = kotlin.math.sin(dLat / 2) * kotlin.math.sin(dLat / 2) +
205+
kotlin.math.cos(Math.toRadians(lat1)) * kotlin.math.cos(Math.toRadians(lat2)) *
206+
kotlin.math.sin(dLng / 2) * kotlin.math.sin(dLng / 2)
207+
val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
208+
return (r * c).toInt()
209+
}
210+
211+
private fun formatDistance(distanceStr: String): String {
212+
val meters = distanceStr.toIntOrNull() ?: return distanceStr
213+
return if (meters >= 1000) {
214+
String.format("%.1fkm", meters / 1000.0)
215+
} else {
216+
"${meters}m"
217+
}
218+
}

0 commit comments

Comments
 (0)