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

@@ -167,6 +167,7 @@
<activity android:name=".mypage.profile.nickname.NicknameUpdateActivity" />
<activity android:name=".mypage.profile.password.ModifyPasswordActivity" />
<activity android:name=".audio_content.all.AudioContentNewAllActivity" />
<activity android:name=".home.pushnotification.PushNotificationListActivity" />
<activity android:name=".audio_content.all.AudioContentRankingAllActivity" />
<activity android:name=".audio_content.all.by_theme.AudioContentAllByThemeActivity" />
<activity android:name=".live.roulette.config.RouletteConfigActivity" />

View File

@@ -87,6 +87,7 @@ import kr.co.vividnext.sodalive.following.FollowingCreatorViewModel
import kr.co.vividnext.sodalive.home.HomeApi
import kr.co.vividnext.sodalive.home.HomeRepository
import kr.co.vividnext.sodalive.home.HomeViewModel
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListViewModel
import kr.co.vividnext.sodalive.live.LiveApi
import kr.co.vividnext.sodalive.live.LiveRepository
import kr.co.vividnext.sodalive.live.LiveViewModel
@@ -376,6 +377,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { SearchViewModel(get()) }
viewModel { PointStatusViewModel(get()) }
viewModel { HomeViewModel(get(), get()) }
viewModel { PushNotificationListViewModel(get()) }
viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) }
viewModel { CharacterGalleryViewModel(get()) }
@@ -435,7 +437,6 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { SeriesMainRepository(get()) }
}
private val moduleList = listOf(
networkModule,
viewModelModule,

View File

@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
@@ -66,6 +67,14 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val deepLinkUrl = messageData["deepLink"] ?: messageData["deep_link"]
if (!deepLinkUrl.isNullOrBlank()) {
runCatching {
intent.action = Intent.ACTION_VIEW
intent.data = Uri.parse(deepLinkUrl)
}
}
val deepLinkExtras = android.os.Bundle().apply {
messageData["room_id"]?.let { putString("room_id", it) }
messageData["message_id"]?.let { putString("message_id", it) }

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
}

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import java.util.Locale
class DeepLinkActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -69,6 +70,12 @@ class DeepLinkActivity : AppCompatActivity() {
val data = intent.data
fun putIfAbsent(key: String, value: String?) {
if (!value.isNullOrBlank() && !extras.containsKey(key)) {
extras.putString(key, value)
}
}
if (data != null) {
fun putQuery(key: String) {
val value = data.getQueryParameter(key)
@@ -135,6 +142,10 @@ class DeepLinkActivity : AppCompatActivity() {
extras.putString("content_id", it.toString())
}
if (data != null) {
applyPathDeepLink(data = data, putIfAbsent = ::putIfAbsent)
}
val deepLinkValue = extras.getString("deep_link_value")
val deepLinkValueId = extras.getString("deep_link_sub5")
@@ -156,6 +167,14 @@ class DeepLinkActivity : AppCompatActivity() {
extras.putString("audition_id", deepLinkValueId)
}
"community" -> if (!extras.containsKey(Constants.EXTRA_COMMUNITY_CREATOR_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
"message" -> if (!extras.containsKey("message_id")) {
extras.putString("message_id", deepLinkValueId)
}
else -> Unit
}
}
@@ -166,4 +185,62 @@ class DeepLinkActivity : AppCompatActivity() {
extras
}
}
private fun applyPathDeepLink(
data: Uri,
putIfAbsent: (key: String, value: String?) -> Unit
) {
val host = data.host?.lowercase(Locale.ROOT).orEmpty()
val pathSegments = data.pathSegments.filter { it.isNotBlank() }
val pathType: String
val pathId: String?
if (host.isNotBlank() && host != "payverse") {
pathType = host
pathId = pathSegments.firstOrNull()
} else if (pathSegments.isNotEmpty()) {
pathType = pathSegments[0].lowercase(Locale.ROOT)
pathId = pathSegments.getOrNull(1)
} else {
return
}
when (pathType) {
"live" -> {
putIfAbsent("room_id", pathId)
putIfAbsent("deep_link_value", "live")
putIfAbsent("deep_link_sub5", pathId)
}
"content" -> {
putIfAbsent("content_id", pathId)
putIfAbsent("deep_link_value", "content")
putIfAbsent("deep_link_sub5", pathId)
}
"series" -> {
putIfAbsent("deep_link_value", "series")
putIfAbsent("deep_link_sub5", pathId)
}
"community" -> {
putIfAbsent("deep_link_value", "community")
putIfAbsent(Constants.EXTRA_COMMUNITY_CREATOR_ID, pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"message" -> {
putIfAbsent("deep_link_value", "message")
putIfAbsent("message_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"audition" -> {
putIfAbsent("deep_link_value", "audition")
putIfAbsent("audition_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
}
}
}

View File

@@ -37,6 +37,7 @@ import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.ChatFragment
import kr.co.vividnext.sodalive.common.Constants
@@ -45,6 +46,7 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityMainBinding
import kr.co.vividnext.sodalive.databinding.ItemMainTabBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.home.HomeFragment
import kr.co.vividnext.sodalive.live.LiveFragment
@@ -318,6 +320,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
?: bundle.getLong(Constants.EXTRA_MESSAGE_ID).takeIf { it > 0 }
val contentId = bundle.getString("content_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDIO_CONTENT_ID).takeIf { it > 0 }
val auditionId = bundle.getString("audition_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDITION_ID).takeIf { it > 0 }
val communityCreatorId = bundle.getString(Constants.EXTRA_COMMUNITY_CREATOR_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_CREATOR_ID).takeIf { it > 0 }
val isLiveReservation = bundle.getBoolean(Constants.EXTRA_LIVE_RESERVATION_RESPONSE)
when {
@@ -355,12 +361,24 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
startActivity(Intent(applicationContext, MessageActivity::class.java))
return true
}
communityCreatorId != null && communityCreatorId > 0 -> {
val nextIntent = Intent(applicationContext, CreatorCommunityAllActivity::class.java)
nextIntent.putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, communityCreatorId)
startActivity(nextIntent)
return true
}
auditionId != null && auditionId > 0 -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
return true
}
}
val deepLinkValue = bundle.getString("deep_link_value")
val deepLinkValueId = bundle.getString("deep_link_sub5")?.toLongOrNull()
if (!deepLinkValue.isNullOrBlank() && deepLinkValueId != null && deepLinkValueId > 0) {
if (!deepLinkValue.isNullOrBlank()) {
return routeByDeepLinkValue(deepLinkValue = deepLinkValue, deepLinkValueId = deepLinkValueId)
}
@@ -371,16 +389,23 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
val deepLinkValue = SharedPreferenceManager.marketingLinkValue
val deepLinkValueId = SharedPreferenceManager.marketingLinkValueId
if (deepLinkValue.isNotBlank() && deepLinkValueId > 0) {
routeByDeepLinkValue(deepLinkValue = deepLinkValue, deepLinkValueId = deepLinkValueId)
if (deepLinkValue.isNotBlank()) {
routeByDeepLinkValue(
deepLinkValue = deepLinkValue,
deepLinkValueId = deepLinkValueId.takeIf { it > 0 }
)
}
clearDeferredDeepLink()
}
private fun routeByDeepLinkValue(deepLinkValue: String, deepLinkValueId: Long): Boolean {
private fun routeByDeepLinkValue(deepLinkValue: String, deepLinkValueId: Long?): Boolean {
return when (deepLinkValue.lowercase(Locale.ROOT)) {
"series" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, deepLinkValueId)
@@ -390,6 +415,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
}
"content" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(
applicationContext,
@@ -402,6 +431,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
}
"channel" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
@@ -411,6 +444,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
}
"live" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
handler.postDelayed({
@@ -419,6 +456,29 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
true
}
"community" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
)
true
}
"message" -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
true
}
"audition" -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
true
}
else -> false
}
}

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.user
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationCategoryResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationListResponse
import kr.co.vividnext.sodalive.explorer.profile.MemberBlockRequest
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.main.MarketingInfoUpdateRequest
@@ -47,6 +49,19 @@ interface UserApi {
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetMemberInfoResponse>>
@GET("/push/notification/categories")
fun getPushNotificationCategories(
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetPushNotificationCategoryResponse>>
@GET("/push/notification/list")
fun getPushNotificationList(
@Query("page") page: Int,
@Query("size") size: Int,
@Query("category") category: String?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetPushNotificationListResponse>>
@POST("/member/notification")
fun updateNotificationSettings(
@Body request: UpdateNotificationSettingRequest,
@@ -180,5 +195,4 @@ interface UserApi {
fun loginLine(
@Body request: SocialLoginRequest
): Single<ApiResponse<LoginResponse>>
}

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.user
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationCategoryResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationListResponse
import kr.co.vividnext.sodalive.explorer.profile.MemberBlockRequest
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.main.MarketingInfoUpdateRequest
@@ -36,6 +38,24 @@ class UserRepository(private val userApi: UserApi) {
fun getMemberInfo(token: String) = userApi.getMemberInfo(authHeader = token)
fun getPushNotificationCategories(token: String): Single<ApiResponse<GetPushNotificationCategoryResponse>> {
return userApi.getPushNotificationCategories(authHeader = token)
}
fun getPushNotificationList(
page: Int,
size: Int,
category: String?,
token: String
): Single<ApiResponse<GetPushNotificationListResponse>> {
return userApi.getPushNotificationList(
page = page,
size = size,
category = category,
authHeader = token
)
}
fun getMyPage(token: String): Single<ApiResponse<MyPageResponse>> {
return userApi.getMyPage(authHeader = token)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="51.7dp"
android:background="@color/color_131313"
android:paddingHorizontal="13.3dp">
<TextView
android:id="@+id/tv_back"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:drawablePadding="6.7dp"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:minHeight="48dp"
android:textColor="@color/color_eeeeee"
android:textSize="18.3sp"
app:drawableStartCompat="@drawable/ic_back"
tools:ignore="RelativeOverlap"
tools:text="@string/screen_push_notification_title" />
<ImageView
android:id="@+id/iv_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:contentDescription="@null"
android:src="@drawable/ic_bell_settings" />
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_category"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:clipToPadding="false"
android:paddingHorizontal="13.3dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_notification"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:clipToPadding="false"
android:paddingBottom="13.3dp" />
<LinearLayout
android:id="@+id/ll_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_no_item" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="@font/medium"
android:text="@string/screen_push_notification_empty"
android:textColor="@color/color_bbbbbb"
android:textSize="13sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@@ -39,7 +39,7 @@
android:id="@+id/iv_charge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginStart="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_can" />
@@ -47,8 +47,17 @@
android:id="@+id/iv_storage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_storage" />
<ImageView
android:id="@+id/iv_push_notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_bell" />
</LinearLayout>
</RelativeLayout>

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="13.3dp"
android:paddingVertical="13.3dp">
<ImageView
android:id="@+id/iv_profile"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@null"
tools:src="@drawable/ic_place_holder" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="13.3dp"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_nickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/medium"
android:maxLines="1"
android:textColor="@color/color_bbbbbb"
android:textSize="13.3sp"
tools:text="란월" />
<TextView
android:id="@+id/tv_time_ago"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:ellipsize="end"
android:fontFamily="@font/medium"
android:maxLines="1"
android:textColor="@color/color_909090"
android:textSize="10sp"
tools:text="・10분 전" />
</LinearLayout>
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:fontFamily="@font/medium"
android:lineSpacingExtra="2dp"
android:maxLines="2"
android:textColor="@color/color_eeeeee"
android:textSize="13.3sp"
tools:text="라이브를 시작했습니다 ‘모여라 라이브 제목 1234567890" />
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="13.3dp"
android:background="@color/color_555555" />
</LinearLayout>

View File

@@ -172,6 +172,8 @@
<string name="dialog_unfollow_title">Unfollow</string>
<string name="dialog_unfollow_message">Are you sure you want to unfollow %1$s?</string>
<string name="screen_home_theme_all">All</string>
<string name="screen_push_notification_title">Notifications</string>
<string name="screen_push_notification_empty">No notifications yet.</string>
<string name="screen_home_sort_revenue">Revenue</string>
<string name="screen_home_sort_sales_count">Units</string>
<string name="screen_home_sort_comment_count">Comments</string>

View File

@@ -172,6 +172,8 @@
<string name="dialog_unfollow_title">フォロー解除</string>
<string name="dialog_unfollow_message">%1$sさんのフォロー를解除しますか</string>
<string name="screen_home_theme_all"></string>
<string name="screen_push_notification_title">通知</string>
<string name="screen_push_notification_empty">通知はありません。</string>
<string name="screen_home_sort_revenue">売上</string>
<string name="screen_home_sort_sales_count">販売数</string>
<string name="screen_home_sort_comment_count">コメント</string>

View File

@@ -171,6 +171,8 @@
<string name="dialog_unfollow_title">팔로우 해제</string>
<string name="dialog_unfollow_message">%1$s님을 팔로우 해제 하시겠습니까?</string>
<string name="screen_home_theme_all">전체</string>
<string name="screen_push_notification_title">알림</string>
<string name="screen_push_notification_empty">받은 알림이 없습니다.</string>
<string name="screen_home_sort_revenue">매출</string>
<string name="screen_home_sort_sales_count">판매량</string>
<string name="screen_home_sort_comment_count">댓글</string>

View File

@@ -0,0 +1,44 @@
# 2026-03-12 알림 리스트 구현
## 체크리스트
- [x] 기존 패턴 분석 (Title bar, 카테고리 UI, Home 진입, 딥링크 라우팅)
- [x] 알림 카테고리/리스트 API 모델 및 네트워크 계층 구현
- [x] 알림 리스트 화면 UI 구현 (Title bar, Category List, Item List)
- [x] 무한 스크롤 및 마지막 페이지/빈 데이터 처리 구현
- [x] 알림 아이템 터치 시 deepLink 실행 및 Path 딥링크 보완
- [x] HomeFragment의 `ic_push_notification` 진입 연결
- [x] 검증 수행 및 결과 기록
## 검증 기록
- 2026-03-12
- 무엇/왜/어떻게: 작업 시작 전 기존 코드 패턴과 요구사항 반영 지점을 확인해 구현 범위를 고정했다.
- 실행 명령: `background_output(task_id="bg_0688c56d")`, `background_output(task_id="bg_d0442733")`, `background_output(task_id="bg_db96f80d")`
- 결과: Title bar/카테고리 패턴, 기존 딥링크 분기, Home 진입점 누락 상태를 확인했다.
- 2026-03-12
- 무엇/왜/어떻게: 알림 리스트 구현 변경분의 빌드/테스트/코드스타일 상태를 최종 확인해 배포 전 기본 안정성을 검증했다.
- 실행 명령: `./gradlew :app:assembleDebug :app:testDebugUnitTest :app:ktlintCheck`
- 결과: `BUILD SUCCESSFUL`.
- 2026-03-12
- 무엇/왜/어떻게: 수정 파일 정적 진단 수행 가능 여부를 확인했다.
- 실행 명령: `lsp_diagnostics(filePath="app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListViewModel.kt")`, `lsp_diagnostics(filePath="app/src/main/res/layout/activity_push_notification_list.xml")`
- 결과: 현재 실행 환경에서 `.kt`, `.xml` LSP 서버가 미구성되어 진단을 수행할 수 없음을 확인했다.
- 2026-03-12
- 무엇/왜/어떻게: 작업 트리 상태를 확인해 리네임 과정의 인덱스 흔적 유무를 점검했다.
- 실행 명령: `git status --short`
- 결과: `home/push_notification` 경로에 `AD` 인덱스 흔적이 남아 있어 커밋 전 스테이징 정리가 필요함을 확인했다.
- 2026-03-12
- 무엇/왜/어떻게: 카테고리 리스트의 앱 내 `전체` 주입을 제거하고 서버 조회 카테고리만 사용하도록 로직을 축소했다.
- 실행 명령: `grep(pattern="allCategoryLabel|screen_home_theme_all|categoryAdapter.addItems\\(listOf", path="app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListActivity.kt")`
- 결과: `PushNotificationListActivity`에서 `allCategoryLabel` 기반 주입/비교 로직이 제거되고, 서버 응답 `categories`를 그대로 바인딩하도록 반영되었다.
- 2026-03-12
- 무엇/왜/어떻게: 수정 파일 정적 진단과 빌드/테스트/코드스타일 검증을 재실행해 변경 안정성을 확인했다.
- 실행 명령: `lsp_diagnostics(filePath="app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListActivity.kt")`, `lsp_diagnostics(filePath="docs/20260312_알림리스트구현.md")`, `./gradlew :app:assembleDebug :app:testDebugUnitTest :app:ktlintCheck`
- 결과: `.kt` LSP 서버 미구성으로 Kotlin 진단은 불가, Markdown 진단은 이슈 없음, Gradle 검증은 `BUILD SUCCESSFUL`.
- 2026-03-12
- 무엇/왜/어떻게: 서버 카테고리만 사용하면서 기본 선택을 첫 번째 카테고리로 고정하도록 초기 선택 흐름을 조정했다.
- 실행 명령: `grep(pattern="selectCategory\\(null\\)|isInitialCategorySelected|setSelectedTheme", path="app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListActivity.kt")`, `./gradlew :app:assembleDebug :app:testDebugUnitTest :app:ktlintCheck`
- 결과: 앱 내 `전체` 기본 선택 호출을 제거하고 서버 카테고리 첫 항목 선택 로직을 반영했으며, Gradle 검증은 `BUILD SUCCESSFUL`.
- 2026-03-12
- 무엇/왜/어떻게: 최초 진입 시 카테고리 조회/선택 과정에서 알림 리스트 API가 2회 호출될 가능성이 있는지 호출 경로를 정적 분석으로 점검했다.
- 실행 명령: `background_output(task_id="bg_061507b7")`, `background_output(task_id="bg_23070c8e")`, `grep(pattern="getPushNotificationList\\(|selectCategory\\(|categoryListLiveData.observe\\(|onScrolled\\(", path="app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification")`, `ast_grep_search(pattern="fun getPushNotificationList() { $$$ }", lang="kotlin", paths=["app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification"])`
- 결과: 최초 진입 경로에서는 `categoryListLiveData` 수신 후 `isInitialCategorySelected` 가드로 첫 카테고리 선택이 1회만 실행되고, `getPushNotificationList``_isLoading`/`isLastPage` 가드로 재진입이 차단되어 2회 호출 버그 재현 경로가 확인되지 않았다.