Skip to content

Commit 9bad71b

Browse files
authored
Merge pull request #174 from boostcampwm-2024/feature/#173-real-time-pick
[feature] 픽 실시간 업데이트
2 parents b038441 + f273f00 commit 9bad71b

File tree

8 files changed

+203
-85
lines changed

8 files changed

+203
-85
lines changed

app/src/main/java/com/squirtles/musicroad/map/MapScreen.kt

+39-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.squirtles.musicroad.map
22

3+
import android.widget.Toast
34
import androidx.activity.compose.BackHandler
45
import androidx.compose.foundation.background
56
import androidx.compose.foundation.clickable
@@ -93,6 +94,14 @@ fun MapScreen(
9394
}
9495
}
9596
}
97+
98+
launch {
99+
mapViewModel.fetchPicksErrorToast
100+
.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
101+
.collect {
102+
Toast.makeText(context, context.getString(R.string.error_message_fetch_picks_in_bounds), Toast.LENGTH_SHORT).show()
103+
}
104+
}
96105
}
97106

98107
LaunchedEffect(playerState) {
@@ -134,29 +143,35 @@ fun MapScreen(
134143
verticalArrangement = Arrangement.Bottom,
135144
horizontalAlignment = Alignment.CenterHorizontally
136145
) {
137-
clickedMarkerState.prevClickedMarker?.let {
138-
if (clickedMarkerState.curPickId != null) { // 단말 마커 클릭 시
139-
showBottomSheet = false
140-
mapViewModel.picks[clickedMarkerState.curPickId]?.let { pick ->
141-
InfoWindow(
142-
pick = pick,
143-
uid = mapViewModel.getUid(),
144-
navigateToPick = { pickId ->
145-
onPickSummaryClick(pickId)
146-
},
147-
calculateDistance = { lat, lng ->
148-
mapViewModel.calculateDistance(lat, lng).let { distance ->
149-
when {
150-
distance >= 1000.0 -> "%.1fkm".format(distance / 1000.0)
151-
distance >= 0 -> "%.0fm".format(distance)
152-
else -> ""
146+
if (mapViewModel.lastCameraPosition != null &&
147+
clickedMarkerState.prevClickedMarker?.position == mapViewModel.lastCameraPosition?.target
148+
) {
149+
mapViewModel.resetClickedMarkerState(context)
150+
} else {
151+
clickedMarkerState.prevClickedMarker?.let {
152+
if (clickedMarkerState.curPickId != null) { // 단말 마커 클릭 시
153+
showBottomSheet = false
154+
mapViewModel.picks[clickedMarkerState.curPickId]?.let { pick ->
155+
InfoWindow(
156+
pick = pick,
157+
uid = mapViewModel.getUid(),
158+
navigateToPick = { pickId ->
159+
onPickSummaryClick(pickId)
160+
},
161+
calculateDistance = { lat, lng ->
162+
mapViewModel.calculateDistance(lat, lng).let { distance ->
163+
when {
164+
distance >= 1000.0 -> "%.1fkm".format(distance / 1000.0)
165+
distance >= 0 -> "%.0fm".format(distance)
166+
else -> ""
167+
}
153168
}
154169
}
155-
}
156-
)
170+
)
171+
}
172+
} else { // 클러스터 마커 클릭 시
173+
showBottomSheet = true
157174
}
158-
} else { // 클러스터 마커 클릭 시
159-
showBottomSheet = true
160175
}
161176
}
162177

@@ -169,7 +184,8 @@ fun MapScreen(
169184
mapViewModel.getUid()?.let { uid ->
170185
onFavoriteClick(uid)
171186
} ?: run {
172-
signInDialogDescription = getString(context, R.string.sign_in_dialog_title_favorite_picks)
187+
signInDialogDescription =
188+
getString(context, R.string.sign_in_dialog_title_favorite_picks)
173189
showSignInDialog = true
174190
onSignInSuccess = onFavoriteClick
175191
}
@@ -179,7 +195,8 @@ fun MapScreen(
179195
onCenterClick()
180196
mapViewModel.saveCurLocationForced()
181197
} ?: run {
182-
signInDialogDescription = getString(context, R.string.sign_in_dialog_title_add_pick)
198+
signInDialogDescription =
199+
getString(context, R.string.sign_in_dialog_title_add_pick)
183200
showSignInDialog = true
184201
onSignInSuccess = {
185202
onCenterClick()

app/src/main/java/com/squirtles/musicroad/map/MapViewModel.kt

+28-21
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package com.squirtles.musicroad.map
22

33
import android.content.Context
44
import android.location.Location
5-
import android.util.Log
65
import androidx.lifecycle.ViewModel
76
import androidx.lifecycle.viewModelScope
87
import com.naver.maps.geometry.LatLng
@@ -16,9 +15,12 @@ import com.squirtles.domain.usecase.pick.FetchPickUseCase
1615
import com.squirtles.domain.usecase.user.GetCurrentUidUseCase
1716
import com.squirtles.musicroad.map.marker.MarkerKey
1817
import dagger.hilt.android.lifecycle.HiltViewModel
18+
import kotlinx.coroutines.flow.MutableSharedFlow
1919
import kotlinx.coroutines.flow.MutableStateFlow
2020
import kotlinx.coroutines.flow.StateFlow
21+
import kotlinx.coroutines.flow.asSharedFlow
2122
import kotlinx.coroutines.flow.asStateFlow
23+
import kotlinx.coroutines.flow.catch
2224
import kotlinx.coroutines.launch
2325
import javax.inject.Inject
2426

@@ -51,6 +53,9 @@ class MapViewModel @Inject constructor(
5153
private val _clickedMarkerState = MutableStateFlow(MarkerState())
5254
val clickedMarkerState = _clickedMarkerState.asStateFlow()
5355

56+
private val _fetchPicksErrorToast = MutableSharedFlow<Unit>()
57+
val fetchPicksErrorToast = _fetchPicksErrorToast.asSharedFlow()
58+
5459
// FIXME : 네이버맵의 LocationChangeListener에서 실시간으로 변하는 위치 정보 -> 더 나은 방법이 있으면 고쳐주세요
5560
private var _currentLocation: Location? = null
5661
val curLocation get() = _currentLocation
@@ -121,10 +126,12 @@ class MapViewModel @Inject constructor(
121126
) {
122127
viewModelScope.launch {
123128
val prevClickedMarker = _clickedMarkerState.value.prevClickedMarker
124-
if (prevClickedMarker == marker) return@launch
129+
// 클릭한 마커와 클릭되어 있는 마커가 다를 때만 크기 변경
130+
if (prevClickedMarker != marker) {
131+
prevClickedMarker?.toggleSizeByClick(context, false)
132+
marker.toggleSizeByClick(context, true)
133+
}
125134

126-
prevClickedMarker?.toggleSizeByClick(context, false)
127-
marker.toggleSizeByClick(context, true)
128135
val pickList = clusterTag?.split(",")?.mapNotNull { id -> picks[id] }
129136
_clickedMarkerState.emit(MarkerState(marker, pickList, pickId))
130137
}
@@ -143,26 +150,25 @@ class MapViewModel @Inject constructor(
143150
_centerLatLng.value?.run {
144151
val radiusInM = leftTop.distanceTo(this)
145152
fetchPickUseCase(this.latitude, this.longitude, radiusInM)
146-
.onSuccess { pickList ->
153+
.catch {
154+
_fetchPicksErrorToast.emit(Unit)
155+
}
156+
.collect { pickList ->
147157
val newKeyTagMap: MutableMap<MarkerKey, String> = mutableMapOf()
148158
pickList.forEach { pick ->
149159
newKeyTagMap[MarkerKey(pick)] = pick.id
150160
_picks[pick.id] = pick
151161
}
152-
_clickedMarkerState.value.clusterPickList?.let { clusterPickList -> // 클러스터 마커가 선택되어 있는 경우
153-
val updatedPickList = mutableListOf<Pick>()
154-
clusterPickList.forEach { pick ->
155-
_picks[pick.id]?.let { updatedPick ->
156-
updatedPickList.add(updatedPick)
157-
}
162+
163+
// 업데이트된 리스트에 기존 픽이 없으면 _picks와 clusterer에서 삭제
164+
val deletedKeyList = _picks.keys
165+
.filterNot { it in newKeyTagMap.values }
166+
.mapNotNull { pickId ->
167+
_picks.remove(pickId)?.let { MarkerKey(it) }
158168
}
159-
_clickedMarkerState.emit(_clickedMarkerState.value.copy(clusterPickList = updatedPickList.toList())) // 최신 픽 정보로 clusterPickList 업데이트
160-
}
169+
161170
clusterer?.addAll(newKeyTagMap)
162-
}
163-
.onFailure {
164-
// TODO: NoSuchPickInRadiusException일 때
165-
Log.e("MapViewModel", "${it.message}")
171+
clusterer?.removeAll(deletedKeyList)
166172
}
167173
}
168174
}
@@ -171,10 +177,11 @@ class MapViewModel @Inject constructor(
171177
fun requestPickNotificationArea(location: Location, notiRadius: Double) {
172178
viewModelScope.launch {
173179
fetchPickUseCase(location.latitude, location.longitude, notiRadius)
174-
.onSuccess {
175-
_nearPicks.emit(it)
176-
}.onFailure {
177-
_nearPicks.emit(emptyList())
180+
.catch {
181+
_fetchPicksErrorToast.emit(Unit)
182+
}
183+
.collect { pickList ->
184+
_nearPicks.emit(pickList)
178185
}
179186
}
180187
}

app/src/main/java/com/squirtles/musicroad/map/marker/Clusterer.kt

+21
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ internal fun <T : ClusteringKey> buildClusterer(
9696
}
9797
true
9898
}
99+
100+
if (mapViewModel.clickedMarkerState.value.prevClickedMarker?.position == marker.position) {
101+
mapViewModel.clickedMarkerState.value.clusterPickList?.let {
102+
mapViewModel.setClickedMarkerState(
103+
context = context,
104+
marker = marker,
105+
clusterTag = info.tag.toString()
106+
)
107+
}
108+
}
109+
99110
// 클러스터 마커를 클릭한 채로 configuration change 시 크기 유지
100111
if (info.tag.toString()
101112
== mapViewModel.clickedMarkerState.value.clusterPickList?.joinToString(",") { it.id }
@@ -131,6 +142,16 @@ internal fun <T : ClusteringKey> buildClusterer(
131142
}
132143
true
133144
}
145+
146+
// 2개짜리 클러스터 마커가 클릭된 상태에서 항목 삭제 시 바텀 시트 -> 인포윈도우
147+
if (mapViewModel.clickedMarkerState.value.prevClickedMarker?.position == marker.position) {
148+
mapViewModel.setClickedMarkerState(
149+
context = context,
150+
marker = marker,
151+
pickId = pick.id
152+
)
153+
}
154+
134155
// 단말 마커를 클릭한 채로 configuration change 시 크기 유지
135156
if (pick.id == mapViewModel.clickedMarkerState.value.curPickId) {
136157
mapViewModel.setClickedMarker(context, marker)

app/src/main/res/values/strings.xml

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<string name="map_info_window_favorite_count_icon_description">픽을 담은 개수</string>
2525
<string name="pick_created_by_self_1">내가</string>
2626
<string name="pick_created_by_self_2">등록한 픽</string>
27+
<string name="error_message_fetch_picks_in_bounds">데이터를 불러오는 데 일시적인 오류가 발생했습니다.</string>
2728

2829
<!-- Pick -->
2930
<string name="pick_app_bar_title">픽 등록</string>

data/src/main/java/com/squirtles/data/datasource/remote/firebase/FirebaseDataSourceImpl.kt

+70-34
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import com.firebase.geofire.GeoFireUtils
55
import com.firebase.geofire.GeoLocation
66
import com.google.android.gms.tasks.Task
77
import com.google.android.gms.tasks.Tasks
8+
import com.google.firebase.firestore.DocumentChange
89
import com.google.firebase.firestore.DocumentReference
910
import com.google.firebase.firestore.DocumentSnapshot
1011
import com.google.firebase.firestore.FieldValue
1112
import com.google.firebase.firestore.FirebaseFirestore
13+
import com.google.firebase.firestore.ListenerRegistration
1214
import com.google.firebase.firestore.Query
1315
import com.google.firebase.firestore.QuerySnapshot
1416
import com.google.firebase.firestore.toObject
@@ -19,10 +21,15 @@ import com.squirtles.data.mapper.toFirebasePick
1921
import com.squirtles.data.mapper.toPick
2022
import com.squirtles.data.mapper.toUser
2123
import com.squirtles.domain.firebase.FirebaseRemoteDataSource
24+
import com.squirtles.domain.firebase.PickType
25+
import com.squirtles.domain.firebase.PickWithType
2226
import com.squirtles.domain.model.Pick
2327
import com.squirtles.domain.model.User
2428
import kotlinx.coroutines.CoroutineScope
2529
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.channels.awaitClose
31+
import kotlinx.coroutines.flow.Flow
32+
import kotlinx.coroutines.flow.callbackFlow
2633
import kotlinx.coroutines.launch
2734
import kotlinx.coroutines.suspendCancellableCoroutine
2835
import kotlinx.coroutines.tasks.await
@@ -38,15 +45,28 @@ class FirebaseDataSourceImpl @Inject constructor(
3845

3946
private val cloudFunctionHelper = CloudFunctionHelper()
4047

41-
override suspend fun createGoogleIdUser(uid: String, email: String, userName: String?, userProfileImage: String?): User? {
48+
override suspend fun createGoogleIdUser(
49+
uid: String,
50+
email: String,
51+
userName: String?,
52+
userProfileImage: String?
53+
): User? {
4254
return suspendCancellableCoroutine { continuation ->
4355
val documentReference = db.collection("users").document(uid)
44-
documentReference.set(FirebaseUser(email = email, name = userName, profileImage = userProfileImage))
56+
documentReference.set(
57+
FirebaseUser(
58+
email = email,
59+
name = userName,
60+
profileImage = userProfileImage
61+
)
62+
)
4563
.addOnSuccessListener {
4664
documentReference.get()
4765
.addOnSuccessListener { documentSnapshot ->
4866
val savedUser = documentSnapshot.toObject<FirebaseUser>()
49-
continuation.resume(savedUser?.toUser()?.copy(uid = documentReference.id))
67+
continuation.resume(
68+
savedUser?.toUser()?.copy(uid = documentReference.id)
69+
)
5070
}
5171
.addOnFailureListener { exception ->
5272
continuation.resumeWithException(exception)
@@ -131,44 +151,60 @@ class FirebaseDataSourceImpl @Inject constructor(
131151
lat: Double,
132152
lng: Double,
133153
radiusInM: Double
134-
): List<Pick> {
135-
val center = GeoLocation(lat, lng)
136-
val bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM)
137-
138-
val queries: MutableList<Query> = ArrayList()
139-
val tasks: MutableList<Task<QuerySnapshot>> = ArrayList()
140-
val matchingPicks: MutableList<Pick> = ArrayList()
141-
142-
bounds.forEach { bound ->
143-
val query = db.collection("picks")
144-
.orderBy("geoHash")
145-
.startAt(bound.startHash)
146-
.endAt(bound.endHash)
147-
queries.add(query)
148-
}
149-
154+
): Flow<List<PickWithType>> = callbackFlow {
155+
val listeners = mutableListOf<ListenerRegistration>()
150156
try {
151-
queries.forEach { query ->
152-
tasks.add(query.get())
153-
}
154-
Tasks.whenAllComplete(tasks).await()
155-
} catch (exception: Exception) {
156-
Log.e("FirebaseDataSourceImpl", "Failed to fetch picks", exception)
157-
throw exception
158-
}
157+
val center = GeoLocation(lat, lng)
158+
val bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM)
159+
160+
bounds.forEach { bound ->
161+
val query = db.collection(COLLECTION_PICKS)
162+
.orderBy("geoHash")
163+
.startAt(bound.startHash)
164+
.endAt(bound.endHash)
165+
166+
val listener = query.addSnapshotListener { snapshots, e ->
167+
if (e != null) {
168+
Log.w("SnapshotListener", "listen:error", e)
169+
return@addSnapshotListener
170+
}
159171

160-
tasks.forEach { task ->
161-
val snap = task.result
162-
snap.documents.forEach { doc ->
163-
if (isAccurate(doc, center, radiusInM)) {
164-
doc.toObject<FirebasePick>()?.run {
165-
matchingPicks.add(this.toPick().copy(id = doc.id))
172+
val pickData = mutableListOf<PickWithType>()
173+
for (dc in snapshots!!.documentChanges) {
174+
if (isAccurate(dc.document, center, radiusInM)) {
175+
dc.document.toObject<FirebasePick>().run {
176+
when (dc.type) {
177+
DocumentChange.Type.ADDED, DocumentChange.Type.MODIFIED -> {
178+
pickData.add(
179+
PickWithType(
180+
type = PickType.UPDATED,
181+
pick = this.toPick().copy(id = dc.document.id)
182+
)
183+
)
184+
}
185+
186+
DocumentChange.Type.REMOVED -> {
187+
pickData.add(
188+
PickWithType(
189+
type = PickType.REMOVED,
190+
pick = this.toPick().copy(id = dc.document.id)
191+
)
192+
)
193+
}
194+
}
195+
}
196+
}
166197
}
198+
trySend(pickData)
167199
}
200+
listeners.add(listener)
168201
}
202+
} catch (e: Exception) {
203+
close(e)
169204
}
170205

171-
return matchingPicks
206+
// Flow 종료 시 모든 리스너 제거
207+
awaitClose { listeners.forEach { it.remove() } }
172208
}
173209

174210
/**

0 commit comments

Comments
 (0)