feat(pushnotification): 홈 알림 리스트 화면과 딥링크 라우팅을 추가한다
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user