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회 호출 버그 재현 경로가 확인되지 않았다.