feat(pushnotification): 홈 알림 리스트 화면과 딥링크 라우팅을 추가한다

This commit is contained in:
2026-03-12 18:36:01 +09:00
parent 5bd4e45542
commit c0c5d6efc1
24 changed files with 886 additions and 7 deletions

View File

@@ -44,6 +44,12 @@ class HomeContentThemeAdapter(
notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
fun setSelectedTheme(theme: String) {
selectedTheme = theme
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemHomeContentThemeBinding.inflate(
LayoutInflater.from(parent.context),

View File

@@ -47,6 +47,7 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentHomeBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
@@ -154,6 +155,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
)
)
}
binding.ivPushNotification.setOnClickListener {
startActivity(
Intent(
requireContext(),
PushNotificationListActivity::class.java
)
)
}
} else {
binding.llShortIcon.visibility = View.GONE
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.home.pushnotification
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class GetPushNotificationCategoryResponse(
@SerializedName("categories") val categories: List<String>
)

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.home.pushnotification
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class GetPushNotificationListResponse(
@SerializedName("totalCount") val totalCount: Long,
@SerializedName("items") val items: List<PushNotificationListItem>
)
@Keep
data class PushNotificationListItem(
@SerializedName("id") val id: Long,
@SerializedName("senderNickname") val senderNickname: String,
@SerializedName("senderProfileImage") val senderProfileImage: String?,
@SerializedName("message") val message: String,
@SerializedName("category") val category: String,
@SerializedName("deepLink") val deepLink: String?,
@SerializedName("sentAt") val sentAt: String
)

View File

@@ -0,0 +1,160 @@
package kr.co.vividnext.sodalive.home.pushnotification
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityPushNotificationListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.home.HomeContentThemeAdapter
import org.koin.android.ext.android.inject
class PushNotificationListActivity : BaseActivity<ActivityPushNotificationListBinding>(
ActivityPushNotificationListBinding::inflate
) {
private val viewModel: PushNotificationListViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var categoryAdapter: HomeContentThemeAdapter
private lateinit var notificationAdapter: PushNotificationListAdapter
private var isInitialCategorySelected = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
viewModel.getPushNotificationCategories()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.tvBack.text = getString(R.string.screen_push_notification_title)
binding.tvBack.setOnClickListener { finish() }
setupCategoryList()
setupNotificationList()
}
private fun setupCategoryList() {
categoryAdapter = HomeContentThemeAdapter("") { selectedCategory ->
viewModel.selectCategory(selectedCategory)
}
binding.rvCategory.layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.HORIZONTAL,
false
)
binding.rvCategory.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 4f.dpToPx().toInt()
}
categoryAdapter.itemCount - 1 -> {
outRect.left = 4f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 4f.dpToPx().toInt()
outRect.right = 4f.dpToPx().toInt()
}
}
}
})
binding.rvCategory.adapter = categoryAdapter
}
private fun setupNotificationList() {
notificationAdapter = PushNotificationListAdapter(
onClickItem = { openDeepLink(it.deepLink) }
)
binding.rvNotification.layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.VERTICAL,
false
)
binding.rvNotification.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy <= 0) {
return
}
if (!recyclerView.canScrollVertically(1)) {
viewModel.getPushNotificationList()
}
}
})
binding.rvNotification.adapter = notificationAdapter
}
private fun bindData() {
viewModel.toastLiveData.observe(this) {
it?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.categoryListLiveData.observe(this) { categories ->
categoryAdapter.addItems(categories)
if (!isInitialCategorySelected && categories.isNotEmpty()) {
val initialCategory = categories.first()
categoryAdapter.setSelectedTheme(initialCategory)
viewModel.selectCategory(initialCategory)
isInitialCategorySelected = true
}
}
viewModel.notificationListLiveData.observe(this) { items ->
notificationAdapter.submitItems(items)
binding.llEmpty.visibility = if (items.isEmpty()) {
View.VISIBLE
} else {
View.GONE
}
}
}
private fun openDeepLink(deepLink: String?) {
val deepLinkUrl = deepLink?.takeIf { it.isNotBlank() } ?: return
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUrl)))
}.onFailure {
Toast.makeText(applicationContext, getString(R.string.common_error_unknown), Toast.LENGTH_LONG).show()
}
}
}

View File

@@ -0,0 +1,57 @@
package kr.co.vividnext.sodalive.home.pushnotification
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemPushNotificationListBinding
class PushNotificationListAdapter(
private val onClickItem: (PushNotificationListItem) -> Unit
) : RecyclerView.Adapter<PushNotificationListAdapter.ViewHolder>() {
private val items = mutableListOf<PushNotificationListItem>()
inner class ViewHolder(
private val binding: ItemPushNotificationListBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: PushNotificationListItem) {
binding.ivProfile.load(item.senderProfileImage) {
transformations(CircleCropTransformation())
placeholder(R.drawable.ic_place_holder)
error(R.drawable.ic_place_holder)
crossfade(true)
}
binding.tvNickname.text = item.senderNickname
binding.tvTimeAgo.text = item.relativeTimeText(binding.root.context)
binding.tvMessage.text = item.message
binding.root.setOnClickListener { onClickItem(item) }
}
}
@SuppressLint("NotifyDataSetChanged")
fun submitItems(items: List<PushNotificationListItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemPushNotificationListBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}

View File

@@ -0,0 +1,132 @@
package kr.co.vividnext.sodalive.home.pushnotification
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.user.UserRepository
class PushNotificationListViewModel(
private val userRepository: UserRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _categoryListLiveData = MutableLiveData<List<String>>()
val categoryListLiveData: LiveData<List<String>>
get() = _categoryListLiveData
private val _notificationListLiveData = MutableLiveData<List<PushNotificationListItem>>()
val notificationListLiveData: LiveData<List<PushNotificationListItem>>
get() = _notificationListLiveData
private var page = 1
private val size = 20
private var totalCount = 0L
private var selectedCategory: String? = null
private var isLastPage = false
private val loadedItems = mutableListOf<PushNotificationListItem>()
fun getPushNotificationCategories() {
val unknownError = kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
.get()
.getString(R.string.common_error_unknown)
compositeDisposable.add(
userRepository.getPushNotificationCategories(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_categoryListLiveData.value = it.data.categories
} else {
_toastLiveData.value = it.message ?: unknownError
}
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.value = unknownError
}
)
)
}
fun selectCategory(category: String?) {
val normalizedCategory = category?.takeIf { it.isNotBlank() }
if (selectedCategory == normalizedCategory && loadedItems.isNotEmpty()) {
return
}
selectedCategory = normalizedCategory
resetPaging()
getPushNotificationList()
}
fun getPushNotificationList() {
if (isLastPage || _isLoading.value == true) {
return
}
_isLoading.value = true
val unknownError = kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
.get()
.getString(R.string.common_error_unknown)
compositeDisposable.add(
userRepository.getPushNotificationList(
page = page,
size = size,
category = selectedCategory,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
totalCount = it.data.totalCount
loadedItems.addAll(it.data.items)
_notificationListLiveData.value = loadedItems.toList()
isLastPage =
it.data.items.isEmpty() ||
totalCount == 0L ||
loadedItems.size.toLong() >= totalCount
if (!isLastPage) {
page += 1
}
} else {
_toastLiveData.value = it.message ?: unknownError
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.value = unknownError
}
)
)
}
private fun resetPaging() {
page = 1
totalCount = 0L
isLastPage = false
loadedItems.clear()
_notificationListLiveData.value = emptyList()
}
}

View File

@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.home.pushnotification
import android.content.Context
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kr.co.vividnext.sodalive.R
fun PushNotificationListItem.relativeTimeText(context: Context): String {
val pastMillis = parsePushNotificationUtcToMillis(sentAt)
?: return context.getString(R.string.character_comment_time_just_now)
val diff = (System.currentTimeMillis() - pastMillis).coerceAtLeast(0L)
val minute = 60_000L
val hour = 60 * minute
val day = 24 * hour
return when {
diff < minute -> context.getString(R.string.character_comment_time_just_now)
diff < hour -> context.getString(R.string.character_comment_time_minutes, (diff / minute).toInt())
diff < day -> context.getString(R.string.character_comment_time_hours, (diff / hour).toInt())
else -> context.getString(R.string.character_comment_time_days, (diff / day).toInt())
}
}
private fun parsePushNotificationUtcToMillis(sentAt: String?): Long? {
if (sentAt.isNullOrBlank()) return null
val value = sentAt.trim()
if (value.all { it.isDigit() }) {
return try {
val epoch = value.toLong()
if (value.length <= 10) epoch * 1000 else epoch
} catch (_: NumberFormatException) {
null
}
}
val patterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm:ss"
)
for (pattern in patterns) {
try {
val dateFormat = SimpleDateFormat(pattern, Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsed: Date? = dateFormat.parse(value)
if (parsed != null) {
return parsed.time
}
} catch (_: ParseException) {
}
}
return null
}