Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] 알림 페이지 구현 #85

Merged
merged 14 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.polzzak_android.presentation.common.item

import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import androidx.constraintlayout.widget.ConstraintLayout
import com.polzzak_android.R
import com.polzzak_android.databinding.ItemLoadMoreLoadingBinding
import com.polzzak_android.presentation.common.util.BindableItem
import com.polzzak_android.presentation.common.util.toPx

class LoadMoreLoadingSpinnerItem(
private val marginTopDp: Int = DEFAULT_VERTICAL_MARGIN_DP,
private val marginBottomDp: Int = DEFAULT_VERTICAL_MARGIN_DP
) :
BindableItem<ItemLoadMoreLoadingBinding>() {
override val layoutRes: Int = R.layout.item_load_more_loading

override fun areItemsTheSame(other: BindableItem<*>): Boolean =
other is LoadMoreLoadingSpinnerItem

override fun areContentsTheSame(other: BindableItem<*>): Boolean =
other is LoadMoreLoadingSpinnerItem && this.marginTopDp == other.marginTopDp &&
this.marginBottomDp == other.marginBottomDp

override fun bind(binding: ItemLoadMoreLoadingBinding, position: Int) {
val context = binding.root.context
(binding.ivSpinner.layoutParams as? ConstraintLayout.LayoutParams)?.let { lp ->
binding.ivSpinner.layoutParams = lp.apply {
topMargin = marginTopDp.toPx(context)
bottomMargin = marginBottomDp.toPx(context)
}
}
val rotateAnimation = RotateAnimation(
0f, 360f, Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1000
interpolator = LinearInterpolator()
repeatCount = Animation.INFINITE
}
binding.ivSpinner.startAnimation(rotateAnimation)

}

companion object {
private const val DEFAULT_VERTICAL_MARGIN_DP = 24
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.polzzak_android.presentation.common.util

import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.databinding.ViewDataBinding

Expand All @@ -11,11 +10,7 @@ import androidx.databinding.ViewDataBinding
abstract class BindableItem<B : ViewDataBinding> {
@get:LayoutRes
abstract val layoutRes: Int

open fun onCreateViewHolder(parent: ViewGroup, position: Int) {}

abstract fun bind(binding: B, position: Int)

abstract fun areItemsTheSame(other: BindableItem<*>): Boolean
abstract fun areContentsTheSame(other: BindableItem<*>): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,19 @@ class BindableItemAdapter :
rv = null
}

fun updateItem(item: List<BindableItem<*>>) {
submitList(item)
fun updateItem(item: List<BindableItem<*>>, commitCallback: (() -> Unit)? = null) {
submitList(item, commitCallback)
}

fun addItem(items: List<BindableItem<*>>) {
fun addItem(items: List<BindableItem<*>>, commitCallback: (() -> Unit)? = null) {
val newItems = currentList + items
submitList(newItems)
submitList(newItems, commitCallback)
}

private fun submitList(items: List<BindableItem<*>>) {
asyncDiffer.submitList(items){
private fun submitList(items: List<BindableItem<*>>, commitCallback: (() -> Unit)? = null) {
asyncDiffer.submitList(items) {
rv?.invalidateItemDecorations()
commitCallback?.invoke()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package com.polzzak_android.presentation.feature.notification

import android.text.SpannableString
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.polzzak_android.presentation.common.model.ModelState
import com.polzzak_android.presentation.common.model.copyWithData
import com.polzzak_android.presentation.feature.notification.list.NotificationItemStateController
import com.polzzak_android.presentation.feature.notification.list.model.NotificationModel
import com.polzzak_android.presentation.feature.notification.list.model.NotificationRefreshStatusType
import com.polzzak_android.presentation.feature.notification.list.model.NotificationsModel
import com.polzzak_android.presentation.feature.notification.setting.model.SettingMenuModel
import com.polzzak_android.presentation.feature.notification.setting.model.SettingMenuType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject

@HiltViewModel
class NotificationViewModel @Inject constructor() : ViewModel(), NotificationItemStateController {
private val _notificationLiveData = MutableLiveData<ModelState<NotificationsModel>>()
val notificationLiveData: LiveData<ModelState<NotificationsModel>> = _notificationLiveData
private var requestNotificationJobData: NotificationJobData? = null

//TODO 추가 삭제 등 알림목록 수정 이벤트
private var updateNotificationJobMap = HashMap<Int, Job?>()

var isRefreshed = false
private set

private val notificationHorizontalScrollPositionMap = HashMap<Int, Int>()

private val _settingMenusLiveData = MutableLiveData<List<SettingMenuModel>>()
val settingMenusLiveData: LiveData<List<SettingMenuModel>> = _settingMenusLiveData
private var requestSettingMenusJob: Job? = null

private val notificationMutex = Mutex()

init {
initNotifications()
}

private fun initNotifications() {
val priority = INIT_NOTIFICATIONS_PRIORITY
if (requestNotificationJobData.getPriorityOrZero() < priority) requestNotificationJobData?.job?.cancel()
else if (requestNotificationJobData?.job?.isCompleted == false) return
requestNotificationJobData = NotificationJobData(
priority = priority,
job = createJobWithUnlockOnCompleted {
isRefreshed = true
notificationMutex.lock()
_notificationLiveData.value = ModelState.Loading(NotificationsModel())
notificationMutex.unlock()
requestNotifications()
},
)
}

fun refreshNotifications() {
val priority = REFRESH_NOTIFICATIONS_PRIORITY
if (requestNotificationJobData.getPriorityOrZero() < priority) requestNotificationJobData?.job?.cancel()
else if (requestNotificationJobData?.job?.isCompleted == false) return
requestNotificationJobData = NotificationJobData(
priority = priority,
job = createJobWithUnlockOnCompleted {
isRefreshed = true
notificationMutex.lock()
val prevData = notificationLiveData.value?.data ?: NotificationsModel()
_notificationLiveData.value =
ModelState.Loading(prevData.copy(refreshStatusType = NotificationRefreshStatusType.Loading))
notificationMutex.unlock()
requestNotifications()
},
)
}

fun requestMoreNotifications() {
if (notificationLiveData.value?.data?.hasNextPage == false) return
val priority = MORE_NOTIFICATIONS_PRIORITY
if (requestNotificationJobData.getPriorityOrZero() <= priority) requestNotificationJobData?.job?.cancel()
else if (requestNotificationJobData?.job?.isCompleted == false) return
requestNotificationJobData = NotificationJobData(
priority = priority,
job = createJobWithUnlockOnCompleted {
isRefreshed = false
notificationMutex.lock()
val prevData = notificationLiveData.value?.data ?: NotificationsModel()
_notificationLiveData.value =
ModelState.Loading(prevData.copy(refreshStatusType = NotificationRefreshStatusType.Normal))
notificationMutex.unlock()
requestNotifications()
},
)
}

//TODO test용 delay를 위해 suspend 붙여줌(제거 필요)
private suspend fun requestNotifications() {
//TODO api 연동(현재 mock data)
val nextOffset = notificationLiveData.value?.data?.nextOffset.takeIf { !isRefreshed } ?: 0
delay(2000)
val nextData = getMockNotificationData(nextOffset, NOTIFICATION_PAGE_SIZE)

//onSuccess
notificationMutex.lock()
val prevData =
notificationLiveData.value?.data.takeIf { !isRefreshed } ?: NotificationsModel()
if (isRefreshed) notificationHorizontalScrollPositionMap.clear()
_notificationLiveData.value =
ModelState.Success(
nextData.copy(
items = (prevData.items ?: emptyList()) + (nextData.items ?: emptyList()),
refreshStatusType = NotificationRefreshStatusType.Normal
)
)
notificationMutex.unlock()
}

fun deleteNotification(id: Int) {
if (updateNotificationJobMap[id]?.isCompleted == false) return
updateNotificationJobMap[id] = createJobWithUnlockOnCompleted {
//TODO api 적용
delay(1000)
deleteMockNotificationData(id = id)

//onSuccess
notificationMutex.lock()
val updatedList = notificationLiveData.value?.data?.items?.toMutableList()?.apply {
removeIf { it.id == id }
}
val updatedData =
notificationLiveData.value?.data?.copy(items = updatedList) ?: NotificationsModel()
_notificationLiveData.value =
_notificationLiveData.value?.copyWithData(newData = updatedData)
notificationMutex.unlock()
//TODO onError 이벤트 추가(Livedata, eventWrapper 등 필요할 수도 있음)
}
}

fun addNotification(model: NotificationModel) {
if (updateNotificationJobMap[model.id]?.isCompleted == false) return
updateNotificationJobMap[model.id] = createJobWithUnlockOnCompleted {
//TODO 푸쉬알림으로 인한 알림 목록 추가

}
}

private fun createJobWithUnlockOnCompleted(action: suspend () -> Unit) = viewModelScope.launch {
action.invoke()
}.apply {
invokeOnCompletion {
if (notificationMutex.isLocked) notificationMutex.unlock()
}
}

fun requestSettingMenu() {
requestSettingMenusJob?.cancel()
requestSettingMenusJob = viewModelScope.launch {
//TOOD repository 구현
_settingMenusLiveData.value = mockSettingMenus
}
}

private data class NotificationJobData(val priority: Int, val job: Job)

private fun NotificationJobData?.getPriorityOrZero() = this?.priority ?: 0

override fun setHorizontalScrollPosition(id: Int, position: Int) {
notificationHorizontalScrollPositionMap[id] = position
}

override fun getHorizontalScrollPosition(id: Int): Int =
notificationHorizontalScrollPositionMap[id] ?: 0

override fun getIsRefreshedSuccess(): Boolean =
isRefreshed && notificationLiveData.value is ModelState.Success

companion object {
const val NOTIFICATION_PAGE_SIZE = 10
private const val INIT_NOTIFICATIONS_PRIORITY = 3
private const val REFRESH_NOTIFICATIONS_PRIORITY = 2
private const val MORE_NOTIFICATIONS_PRIORITY = 1
}
}

private fun getMockNotificationData(nextOffset: Int, pageSize: Int): NotificationsModel {
return NotificationsModel(
hasNextPage = nextOffset + pageSize < mockNotification.size,
nextOffset = nextOffset + pageSize,
items = mockNotification.subList(
nextOffset,
minOf(mockNotification.size, nextOffset + pageSize)
)
)
}

private fun deleteMockNotificationData(id: Int) {
mockNotification.removeIf { it.id == id }
}

private val mockNotification = MutableList(187) {
when (it % 4) {
0 -> NotificationModel.CompleteLink(
id = it,
date = "${it}일 전",
content = SpannableString("연동 완료"),
nickName = "닉네임${it}",
profileImageUrl = "https://picsum.photos/id/${it + 1}/200/300"
)

1 -> NotificationModel.LevelDown(
id = it,
date = "${it}일 전",
content = SpannableString("레벨 감소"),
)

2 -> NotificationModel.LevelUp(
id = it,
date = "${it}일 전",
content = SpannableString("레벨 업")
)

else -> NotificationModel.RequestLink(
id = it,
date = "${it}일 전",
content = SpannableString("연동 요청"),
nickName = "닉네임${it}",
profileImageUrl = "https://picsum.photos/id/${it + 1}/200/300"
)
}
}

private val mockSettingMenus = mutableListOf(
SettingMenuModel(type = SettingMenuType.All, isChecked = false),
SettingMenuModel(type = SettingMenuType.Menu.Link, isChecked = false),
SettingMenuModel(type = SettingMenuType.Menu.Level, isChecked = false),
SettingMenuModel(type = SettingMenuType.Menu.RequestStamp, isChecked = false),
SettingMenuModel(type = SettingMenuType.Menu.RequestGift, isChecked = false),
SettingMenuModel(type = SettingMenuType.Menu.CompleteStampBoard, isChecked = false),
SettingMenuModel(type = SettingMenuType.Menu.ReceiveGift, isChecked = false),
SettingMenuModel(type = SettingMenuType.Menu.BreakPromise, isChecked = false)
)

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.polzzak_android.presentation.feature.notification.list

import android.graphics.Rect
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import com.polzzak_android.presentation.common.util.BindableItemAdapter
import com.polzzak_android.presentation.feature.notification.list.item.NotificationItem
import com.polzzak_android.presentation.feature.notification.list.item.NotificationSkeletonLoadingItem

class NotificationItemDecoration(
@Px private val paddingPx: Int,
@Px private val betweenMarginPx: Int,
) : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val adapter = (parent.adapter as? BindableItemAdapter) ?: return
val position = parent.getChildAdapterPosition(view)
val currentItem = adapter.currentList.getOrNull(position)
if (!isContentItem(currentItem)) return
val prevItem = adapter.currentList.getOrNull(position - 1)
val nextItem = adapter.currentList.getOrNull(position + 1)
outRect.top =
if (isContentItem(prevItem)) 0 else paddingPx
outRect.bottom =
if (isContentItem(nextItem)) betweenMarginPx else paddingPx
}

private fun isContentItem(item: Any?): Boolean =
item is NotificationItem || item is NotificationSkeletonLoadingItem
}
Loading