북마크 캠핑장/ 작성한 글 탭에서는 각 아이템에 대해 좌방향 스와이프를 통한 삭제 기능을 넣기로 했다.
사용성을 고려했을 때, 삭제는 보통 바로 삭제되지 않고 중간에 한 번 더 '삭제하시겠습니까?' 같은 다이얼로그로 삭제 결정에 대해 되돌릴 수 있도록 만드는데, 우리 팀은 마이페이지에서 간편하게 스와이프로 삭제 하기 때문에 다이얼로그를 띄우기보다는 스낵바를 통해 '되돌리기'기능을 추가하기로 했다.
//ProfileFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
...
swipeRecyclerView(binding.rvBookmarked)
swipeRecyclerView(binding.rvWriting)
...
}
private fun swipeRecyclerView(recyclerView: RecyclerView) {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
//드래그앤드롭에 관한 함수. 사용하지 않기 때문에 SimpleCallback 매개변수 0, false return
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
when (recyclerView) {
binding.rvBookmarked -> {
val bookmarkID = bookmarkAdapter.currentList[position]
viewModel.removeBookmarkCamp(userId.toString(), bookmarkID.contentId.toString())
val undoSnackbar = Snackbar.make(binding.root,"해당 북마크를 삭제했습니다.",5000)
undoSnackbar.setAction("되돌리기"){
viewModel.undoBookmarkCamp(userId.toString())
}
undoSnackbar.show()
}
binding.rvWriting -> {
//되돌리기가 아니고 진짜 삭제하겠냐고 묻는 다이얼로그 필요
// val undoSnackbar = Snackbar.make(binding.root,"해당 작성 글을 삭제했습니다.",5000)
// undoSnackbar.setAction("되돌리기"){
// viewModel.undoPost()
// }
// undoSnackbar.show()
val postID = postAdapter.currentList[position]
val builder = AlertDialog.Builder(requireContext())
builder.setMessage("정말로 삭제하시겠습니까?")
.setPositiveButton("삭제",DialogInterface.OnClickListener { _, _ ->
viewModel.removePost(postID.postId.toString())
})
.setNegativeButton("취소",DialogInterface.OnClickListener { _, _ ->
postAdapter.notifyDataSetChanged()
})
builder.show()
}
}
}
override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
val icon: Bitmap
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val itemView = viewHolder.itemView
val height = (itemView.bottom - itemView.top).toFloat()
val width = height / 4
val paint = Paint()
if (dX < 0) {
paint.color = Color.WHITE
val background = RectF(itemView.right.toFloat() + dX, itemView.top.toFloat(), itemView.right.toFloat(), itemView.bottom.toFloat())
c.drawRect(background, paint)
icon = BitmapFactory.decodeResource(resources, R.drawable.ic_delete)
val iconTop = itemView.top.toFloat() + (height - width) / 2
val iconRight = itemView.right.toFloat() - width + dX
val iconDst = RectF(iconRight, iconTop, iconRight + width, iconTop + width)
c.drawBitmap(icon, null, iconDst, null)
}
}
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
})
itemTouchHelper.attachToRecyclerView(recyclerView)
}
swipeRecyclerView함수를 만들었다.onSwipe에서 when문을 통해 어떤 리사이클러뷰가 들어오는지 확인하고 각각에 맞는 함수가 동작되도록 구현했다.contentId 뿐이라서(contentId를 통해 상세 정보를 불러오는 로직) 삭제와 되돌리기 시에도 문제가 없었는데, 작성한 글의 경우는 직접 글의 모든 정보를 한번에 저장하여 사용하다보니 되돌리기를 하면 글이 다시 생성되는 게시 시간의 문제도 있고, 다시 생성된 게시글의 게시물Id가 달라져서 상세페이지로 이동되었을 떄 정보가 제대로 로드되지 않았다.뷰모델에서는 파이어스토어와 연결하여 remove~함수를 통해 바로 삭제 하고 undo~함수를 통해 삭제한 아이템이 다시 추가될 수 있게 만들어주었다.
//ProfileViewModel.kt
fun removeBookmarkCamp(userID: String, contentID: String) {
_bookmarkedList.value = _bookmarkedList.value?.toMutableList()?.apply {
removeBookmarkItem = find { it.contentId == contentID }
remove(removeBookmarkItem)
} ?: mutableListOf()
val db = FirebaseFirestore.getInstance()
val docRef = db.collection("users").document(userID)
val updateBookmarkList = mutableListOf<String>()
_bookmarkedList.value?.forEach {
updateBookmarkList.add(it.contentId.toString())
}
docRef.update("bookmarked", updateBookmarkList)
}
fun undoBookmarkCamp(userID: String) {
_bookmarkedList.value = _bookmarkedList.value?.toMutableList()?.apply {
removeBookmarkItem?.let { add(it) }
} ?: mutableListOf()
val db = FirebaseFirestore.getInstance()
val docRef = db.collection("users").document(userID)
val updateBookmarkList = mutableListOf<String>()
_bookmarkedList.value?.forEach {
updateBookmarkList.add(it.contentId.toString())
}
docRef.update("bookmarked", updateBookmarkList)
}
fun removePost(postID: String) {
_postList.value = _postList.value?.toMutableList()?.apply {
removePostItem = find { it.postId == postID }
remove(removePostItem)
} ?: mutableListOf()
val db = FirebaseFirestore.getInstance()
val docRef = db.collection("posts")
docRef.whereEqualTo("postId",removePostItem?.postId).get().addOnSuccessListener {
for(doc in it) {
doc.reference.delete()
}
}
}
// fun undoPost() {
// _postList.value = _postList.value?.toMutableList()?.apply {
// removePostItem?.let { add(it) }
// } ?: mutableListOf()
//
// val db = Firebase.firestore
// val docRef = db.collection("posts").document()
// val postItem = hashMapOf(
// "authorId" to removePostItem?.authorId,
// "authorName" to removePostItem?.authorName,
// "authorProfileImageUrl" to removePostItem?.authorProfileImageUrl,
// "content" to removePostItem?.content,
// "imageUrls" to removePostItem?.imageUrls,
// "postId" to removePostItem?.postId,
// "timestamp" to removePostItem?.timestamp,
// "title" to removePostItem?.title
// )
// docRef.set(postItem)
// }
기존에는 간편로그인(카카오UserApiClient)에서 제공해주는 토큰을 인식하여 로그인하는 중을 인식하고 있었으나, 화면을 그릴 때마다 매번 비동기방식으로 토큰을 조회하는 것도 비효율적이고, 구글 로그인 방식도 도입해야하고, 좀 더 각각의 프레그먼트 단위가 아닌 액티비티 단위에서 한 번에 컨트롤 할 수 없을지 고민하고 있어서 튜터님을 방문해 조언을 구하기로 했다.
=> 결론적으로는 어플에서는 웹과 다르게 따로 쿠키나 세션을 유지해주는 기능이 없기 때문에 로그인이 필요한 항목에서 로그인 중임을 확인해주는 것이 보통이라는 피드백을 주셨다.
다만. 지금처럼 계속 간편로그인의 토큰을 인식하는 것은 비효율적이라고 해주셨는데, 첫번째로는 우리 어플을 사용할 때 굳이 간편로그인에서 제공해주는 (지금은)12시간 유지 토큰이 의미가 없다고 하셨다. 12시간 후에 토큰이 만료되는 것과 상관없이 한 번 로그인을 하면 계속 기능을 사용할 수 있기 때문에 SharedPreferences에 유저정보를 저장해서 그것을 확인하는 것이 좋다고 해주셨다.
=> 팀원들과 상의를 해본 결과, 유저 아이디를 SharedPreferences에 저장해서 로그인 중임을 확인하도록 바꾸기로 하고, 그 과정에서 유저 정보 암호화를 위해 일반적인 SharedPreferences가 아닌 EncryptedSharedPreferences를 사용하기로 했다.
object EncryptedPrefs {
lateinit var sharedPreferences: SharedPreferences
private const val PREFS_FILE_NAME = "user_prefs"
fun initialize(context: Context) {
if (!::sharedPreferences.isInitialized) {
val masterKey = MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
sharedPreferences = EncryptedSharedPreferences.create(
context.applicationContext,
"user_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
fun saveMyId(token: String) {
sharedPreferences.edit().putString("myID", token).apply()
}
fun getMyId(): String? = sharedPreferences.getString("myID", null)
fun deleteMyId() {
sharedPreferences.edit().remove("myID").apply()
}
}
saveMyId를 사용하여 userId를 저장하고, 로그인 여부를 확인할 때에는 getMyId로 저장된 userId를 가져와서 유저 정보를 확인하고, deleteMyId를 통해서 로그아웃하면 로그인 정보를 유지할 수 없게 userId를 삭제한다.이제 이번주말이면 코드작성이 마감이 된다. 목요일 까지 기능 마감을 하고 금요일에는 최종 배포를 하자고 했다. 프로필 화면을 마무리하면 유저 디테일 화면을 만들어야하는데, 유저 디테일 화면은 프로필 화면을 재활용하는 것이나 다름 없기 때문에 금방 만들 수 있을 것 같다.
다만, 기존 기능들에 대해서 좀 더 오류에 대해 안정적이기를 팀장님이 바라셔서 사용성 면에서도 그렇고. 생각해보니까 이미지 사용 권한도 추가해줘야하고.. 유저 데이터 암호화부터.. 저번에 말한 메모리릭 문제나.. 그런거에 대해서 더 다듬는다고 생각하면 남은 시간이 긴 것도 아닌 것 같다...ㅎㅎ
내일은 아까 말했던 것 처럼 스와이프 기능을 최종적으로 PR 해야한다. 스와이프 기능까지 추가하면 기획 단계에서 정한 최종 MVP를 충족하게 된다.ㅎㅎ
물론.. 이미지 저장권한에 관한 디테일을 좀 다듬어야하기 때문에 내일은 이미지 저장 권한 추가도 해주어야한다.
디자인 등에 대한 피드백에서는 내 부분에서 크게 고칠 일이 없기 때문에 좀 더 기능 구현에 집중할 수 있을 것 같다.! 정말 얼마 남지 않았다. 남은 시간 까지 정신 빠짝 차리고 화이팅해야겠다. 파이팅!!