diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index aa20e889..13505952 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -167,6 +167,7 @@
+
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
index a1b45b3a..1fa19fa7 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
@@ -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,
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt b/app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt
index 0887a851..27762a86 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt
@@ -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) }
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeContentThemeAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeContentThemeAdapter.kt
index 5939c1f1..76669a4d 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeContentThemeAdapter.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeContentThemeAdapter.kt
@@ -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),
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt
index 6f537851..b9b2c03f 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt
@@ -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::infl
)
)
}
+
+ binding.ivPushNotification.setOnClickListener {
+ startActivity(
+ Intent(
+ requireContext(),
+ PushNotificationListActivity::class.java
+ )
+ )
+ }
} else {
binding.llShortIcon.visibility = View.GONE
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/GetPushNotificationCategoryResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/GetPushNotificationCategoryResponse.kt
new file mode 100644
index 00000000..01174d56
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/GetPushNotificationCategoryResponse.kt
@@ -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
+)
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/GetPushNotificationListResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/GetPushNotificationListResponse.kt
new file mode 100644
index 00000000..aa27a944
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/GetPushNotificationListResponse.kt
@@ -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
+)
+
+@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
+)
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListActivity.kt
new file mode 100644
index 00000000..d2adbd2a
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListActivity.kt
@@ -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::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()
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListAdapter.kt
new file mode 100644
index 00000000..cfa78982
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListAdapter.kt
@@ -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() {
+
+ private val items = mutableListOf()
+
+ 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) {
+ 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
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListViewModel.kt
new file mode 100644
index 00000000..00c251d9
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationListViewModel.kt
@@ -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()
+ val toastLiveData: LiveData
+ get() = _toastLiveData
+
+ private val _isLoading = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
+ private val _categoryListLiveData = MutableLiveData>()
+ val categoryListLiveData: LiveData>
+ get() = _categoryListLiveData
+
+ private val _notificationListLiveData = MutableLiveData>()
+ val notificationListLiveData: LiveData>
+ 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()
+
+ 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()
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationTimeFormatter.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationTimeFormatter.kt
new file mode 100644
index 00000000..1510eab7
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/home/pushnotification/PushNotificationTimeFormatter.kt
@@ -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
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt
index a6552cb8..168f0c2b 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt
@@ -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)
+ }
+ }
+ }
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt
index b624f01b..c011df08 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt
@@ -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::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::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::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::infl
}
"content" -> {
+ if (deepLinkValueId == null || deepLinkValueId <= 0) {
+ return false
+ }
+
startActivity(
Intent(
applicationContext,
@@ -402,6 +431,10 @@ class MainActivity : BaseActivity(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::infl
}
"live" -> {
+ if (deepLinkValueId == null || deepLinkValueId <= 0) {
+ return false
+ }
+
viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
handler.postDelayed({
@@ -419,6 +456,29 @@ class MainActivity : BaseActivity(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
}
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt
index 21c35786..a0af2383 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt
@@ -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>
+ @GET("/push/notification/categories")
+ fun getPushNotificationCategories(
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/push/notification/list")
+ fun getPushNotificationList(
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ @Query("category") category: String?,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
@POST("/member/notification")
fun updateNotificationSettings(
@Body request: UpdateNotificationSettingRequest,
@@ -180,5 +195,4 @@ interface UserApi {
fun loginLine(
@Body request: SocialLoginRequest
): Single>
-
}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt
index 36764799..17d9aa7e 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt
@@ -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> {
+ return userApi.getPushNotificationCategories(authHeader = token)
+ }
+
+ fun getPushNotificationList(
+ page: Int,
+ size: Int,
+ category: String?,
+ token: String
+ ): Single> {
+ return userApi.getPushNotificationList(
+ page = page,
+ size = size,
+ category = category,
+ authHeader = token
+ )
+ }
+
fun getMyPage(token: String): Single> {
return userApi.getMyPage(authHeader = token)
}
diff --git a/app/src/main/res/drawable-xxhdpi/ic_bell.png b/app/src/main/res/drawable-xxhdpi/ic_bell.png
new file mode 100644
index 00000000..d3675137
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bell.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_bell_settings.png b/app/src/main/res/drawable-xxhdpi/ic_bell_settings.png
new file mode 100644
index 00000000..b152507c
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bell_settings.png differ
diff --git a/app/src/main/res/layout/activity_push_notification_list.xml b/app/src/main/res/layout/activity_push_notification_list.xml
new file mode 100644
index 00000000..e906f87d
--- /dev/null
+++ b/app/src/main/res/layout/activity_push_notification_list.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
index 7a5f81f0..b6b4bb3f 100644
--- a/app/src/main/res/layout/fragment_home.xml
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -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" />
+
+
diff --git a/app/src/main/res/layout/item_push_notification_list.xml b/app/src/main/res/layout/item_push_notification_list.xml
new file mode 100644
index 00000000..6558bd83
--- /dev/null
+++ b/app/src/main/res/layout/item_push_notification_list.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index dd19be14..8310fd16 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -172,6 +172,8 @@
Unfollow
Are you sure you want to unfollow %1$s?
All
+ Notifications
+ No notifications yet.
Revenue
Units
Comments
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index cda567c9..3ef05213 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -172,6 +172,8 @@
フォロー解除
%1$sさんのフォロー를解除しますか?
全
+ 通知
+ 通知はありません。
売上
販売数
コメント
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 68922c01..63a36a5f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -171,6 +171,8 @@
팔로우 해제
%1$s님을 팔로우 해제 하시겠습니까?
전체
+ 알림
+ 받은 알림이 없습니다.
매출
판매량
댓글
diff --git a/docs/20260312_알림리스트구현.md b/docs/20260312_알림리스트구현.md
new file mode 100644
index 00000000..85e5b2d9
--- /dev/null
+++ b/docs/20260312_알림리스트구현.md
@@ -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회 호출 버그 재현 경로가 확인되지 않았다.