feat(main-v2): 메인 하단 내비게이션을 추가한다

This commit is contained in:
2026-05-19 15:55:03 +09:00
parent 751b031627
commit 99b7a6ce99
41 changed files with 1646 additions and 22 deletions

View File

@@ -111,6 +111,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".main.MainActivity" /> <activity android:name=".main.MainActivity" />
<activity android:name=".v2.main.MainV2Activity" />
<activity android:name=".user.login.LoginActivity" /> <activity android:name=".user.login.LoginActivity" />
<activity android:name=".audio_content.all.AudioContentAllActivity" /> <activity android:name=".audio_content.all.AudioContentAllActivity" />
<activity android:name=".settings.language.LanguageSettingsActivity" /> <activity android:name=".settings.language.LanguageSettingsActivity" />

View File

@@ -22,7 +22,7 @@ import com.bumptech.glide.request.transition.Transition
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.main.MainActivity import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
class AudioContentPlayService : class AudioContentPlayService :
@@ -471,7 +471,7 @@ class AudioContentPlayService :
} }
private fun updateNotification() { private fun updateNotification() {
val intent = Intent(this, MainActivity::class.java) val intent = Intent(this, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(

View File

@@ -32,7 +32,7 @@ import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlayli
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.Utils import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.main.MainActivity import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@UnstableApi @UnstableApi
@@ -153,7 +153,7 @@ class AudioContentPlayerService : MediaSessionService() {
} }
private fun initMediaSession() { private fun initMediaSession() {
val contextIntent = Intent(applicationContext, MainActivity::class.java).apply { val contextIntent = Intent(applicationContext, MainV2Activity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
} }
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(

View File

@@ -176,6 +176,7 @@ import kr.co.vividnext.sodalive.user.UserViewModel
import kr.co.vividnext.sodalive.user.find_password.FindPasswordViewModel import kr.co.vividnext.sodalive.user.find_password.FindPasswordViewModel
import kr.co.vividnext.sodalive.user.login.LoginViewModel import kr.co.vividnext.sodalive.user.login.LoginViewModel
import kr.co.vividnext.sodalive.user.signup.SignUpViewModel import kr.co.vividnext.sodalive.user.signup.SignUpViewModel
import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
@@ -297,6 +298,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { TermsViewModel(get()) } viewModel { TermsViewModel(get()) }
viewModel { FindPasswordViewModel(get()) } viewModel { FindPasswordViewModel(get()) }
viewModel { MainViewModel(get(), get(), get(), get(), get()) } viewModel { MainViewModel(get(), get(), get(), get(), get()) }
viewModel { MainV2ViewModel(get(), get(), get(), get(), get()) }
viewModel { LiveViewModel(get(), get(), get(), get(), get()) } viewModel { LiveViewModel(get(), get(), get(), get(), get()) }
viewModel { MyPageViewModel(get(), get(), get()) } viewModel { MyPageViewModel(get(), get(), get()) }
viewModel { CanStatusViewModel(get()) } viewModel { CanStatusViewModel(get()) }

View File

@@ -9,8 +9,8 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivityLiveReservationCompleteBinding import kr.co.vividnext.sodalive.databinding.ActivityLiveReservationCompleteBinding
import kr.co.vividnext.sodalive.extensions.convertDateFormat import kr.co.vividnext.sodalive.extensions.convertDateFormat
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -52,7 +52,7 @@ class LiveReservationCompleteActivity : BaseActivity<ActivityLiveReservationComp
binding.tvRemainingCan.text = "${response.remainingCan}" binding.tvRemainingCan.text = "${response.remainingCan}"
binding.tvGoHome.setOnClickListener { binding.tvGoHome.setOnClickListener {
val intent = Intent(applicationContext, MainActivity::class.java) val intent = Intent(applicationContext, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)

View File

@@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.message.MessageActivity import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import java.util.Locale import java.util.Locale
class DeepLinkActivity : AppCompatActivity() { class DeepLinkActivity : AppCompatActivity() {
@@ -63,7 +64,7 @@ class DeepLinkActivity : AppCompatActivity() {
} }
startActivity( startActivity(
Intent(applicationContext, MainActivity::class.java).apply { Intent(applicationContext, MainV2Activity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
deepLinkExtras?.let { putExtra(Constants.EXTRA_DATA, it) } deepLinkExtras?.let { putExtra(Constants.EXTRA_DATA, it) }
} }
@@ -465,7 +466,7 @@ class DeepLinkActivity : AppCompatActivity() {
} }
startActivity( startActivity(
Intent(applicationContext, MainActivity::class.java).apply { Intent(applicationContext, MainV2Activity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(Constants.EXTRA_DATA, extras) putExtra(Constants.EXTRA_DATA, extras)
} }

View File

@@ -50,6 +50,7 @@ import kr.co.vividnext.sodalive.settings.notice.NoticeActivity
import kr.co.vividnext.sodalive.settings.notice.NoticeDetailActivity import kr.co.vividnext.sodalive.settings.notice.NoticeDetailActivity
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@UnstableApi @UnstableApi
@@ -275,19 +276,19 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
binding.rlProfileContainer.visibility = View.GONE binding.rlProfileContainer.visibility = View.GONE
binding.llProfileLoginContainer.visibility = View.VISIBLE binding.llProfileLoginContainer.visibility = View.VISIBLE
binding.llProfileLoginContainer.setOnClickListener { binding.llProfileLoginContainer.setOnClickListener {
(requireActivity() as MainActivity).showLoginActivity() showLoginActivity()
} }
binding.tvCanAmount.text = binding.tvCanAmount.text =
SodaLiveApplicationHolder.get().getString(R.string.common_zero) SodaLiveApplicationHolder.get().getString(R.string.common_zero)
binding.tvCanAmount.setOnClickListener { binding.tvCanAmount.setOnClickListener {
(requireActivity() as MainActivity).showLoginActivity() showLoginActivity()
} }
binding.tvPointAmount.text = binding.tvPointAmount.text =
SodaLiveApplicationHolder.get().getString(R.string.common_zero) SodaLiveApplicationHolder.get().getString(R.string.common_zero)
binding.tvPointAmount.setOnClickListener { binding.tvPointAmount.setOnClickListener {
(requireActivity() as MainActivity).showLoginActivity() showLoginActivity()
} }
binding.tvChargeCan.visibility = View.INVISIBLE binding.tvChargeCan.visibility = View.INVISIBLE
@@ -499,4 +500,10 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} }
} }
private fun showLoginActivity() {
when (val activity = requireActivity()) {
is MainActivity -> activity.showLoginActivity()
is MainV2Activity -> activity.showLoginActivity()
}
}
} }

View File

@@ -11,10 +11,10 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityCanStatusBinding import kr.co.vividnext.sodalive.databinding.ActivityCanStatusBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.mypage.can.status.charge.CanChargeStatusFragment import kr.co.vividnext.sodalive.mypage.can.status.charge.CanChargeStatusFragment
import kr.co.vividnext.sodalive.mypage.can.status.use.CanUseStatusFragment import kr.co.vividnext.sodalive.mypage.can.status.use.CanUseStatusFragment
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
class CanStatusActivity : BaseActivity<ActivityCanStatusBinding>( class CanStatusActivity : BaseActivity<ActivityCanStatusBinding>(
@@ -137,7 +137,7 @@ class CanStatusActivity : BaseActivity<ActivityCanStatusBinding>(
} }
private fun onClickBackButton() { private fun onClickBackButton() {
val intent = Intent(applicationContext, MainActivity::class.java) val intent = Intent(applicationContext, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)

View File

@@ -11,9 +11,9 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityPointStatusBinding import kr.co.vividnext.sodalive.databinding.ActivityPointStatusBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.point.reward.PointRewardStatusFragment import kr.co.vividnext.sodalive.mypage.point.reward.PointRewardStatusFragment
import kr.co.vividnext.sodalive.mypage.point.use.PointUseStatusFragment import kr.co.vividnext.sodalive.mypage.point.use.PointUseStatusFragment
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
class PointStatusActivity : BaseActivity<ActivityPointStatusBinding>( class PointStatusActivity : BaseActivity<ActivityPointStatusBinding>(
@@ -120,7 +120,7 @@ class PointStatusActivity : BaseActivity<ActivityPointStatusBinding>(
} }
private fun onClickBackButton() { private fun onClickBackButton() {
val intent = Intent(applicationContext, MainActivity::class.java) val intent = Intent(applicationContext, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)

View File

@@ -20,7 +20,7 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivitySplashBinding import kr.co.vividnext.sodalive.databinding.ActivitySplashBinding
import kr.co.vividnext.sodalive.main.MainActivity import kr.co.vividnext.sodalive.v2.main.MainV2Activity
@SuppressLint("CustomSplashScreen") @SuppressLint("CustomSplashScreen")
class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding::inflate) { class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding::inflate) {
@@ -174,7 +174,7 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding
private fun showMainActivity(extras: Bundle?) { private fun showMainActivity(extras: Bundle?) {
handler.postDelayed({ handler.postDelayed({
startActivity( startActivity(
Intent(applicationContext, MainActivity::class.java).apply { Intent(applicationContext, MainV2Activity::class.java).apply {
putExtra(Constants.EXTRA_DATA, extras) putExtra(Constants.EXTRA_DATA, extras)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.user.login package kr.co.vividnext.sodalive.user.login
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@@ -45,9 +44,9 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityLoginBinding import kr.co.vividnext.sodalive.databinding.ActivityLoginBinding
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.user.find_password.FindPasswordActivity import kr.co.vividnext.sodalive.user.find_password.FindPasswordActivity
import kr.co.vividnext.sodalive.user.signup.SignUpActivity import kr.co.vividnext.sodalive.user.signup.SignUpActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.util.UUID import java.util.UUID
import androidx.core.net.toUri import androidx.core.net.toUri
@@ -434,7 +433,7 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
} }
private fun navigateToMain() { private fun navigateToMain() {
val nextIntent = Intent(this@LoginActivity, MainActivity::class.java) val nextIntent = Intent(this@LoginActivity, MainV2Activity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA) val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) { ?: if (intent.extras != null) {
intent.extras intent.extras

View File

@@ -17,8 +17,8 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivitySignupBinding import kr.co.vividnext.sodalive.databinding.ActivitySignupBinding
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.settings.terms.TermsActivity import kr.co.vividnext.sodalive.settings.terms.TermsActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@@ -152,7 +152,7 @@ class SignUpActivity : BaseActivity<ActivitySignupBinding>(ActivitySignupBinding
} }
private fun navigateToMain() { private fun navigateToMain() {
val nextIntent = Intent(this@SignUpActivity, MainActivity::class.java) val nextIntent = Intent(this@SignUpActivity, MainV2Activity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA) val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) { ?: if (intent.extras != null) {
intent.extras intent.extras

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding
class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
FragmentV2MainChatBinding::inflate
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
FragmentV2MainContentBinding::inflate
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding
class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
FragmentV2MainHomeBinding::inflate
)

View File

@@ -0,0 +1,654 @@
package kr.co.vividnext.sodalive.v2.main
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import coil.load
import coil.transform.RoundedCornersTransformation
import com.google.common.util.concurrent.ListenableFuture
import com.google.firebase.messaging.FirebaseMessaging
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
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.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.MyPageFragment
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog
import kr.co.vividnext.sodalive.user.login.LoginActivity
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.util.Locale
import kotlin.math.max
@UnstableApi
class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding::inflate) {
private val viewModel: MainV2ViewModel by inject()
private lateinit var notificationSettingsDialog: NotificationSettingsDialog
private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private var playerStateJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
overrideRootWindowInsets()
checkPermissions()
trackAppLaunchIfNeeded()
pushTokenUpdate()
if (isLoggedIn()) {
updatePidAndGaid()
getEventPopup()
observePlayerState()
handler.postDelayed({ executeDeeplink(intent) }, 1000)
}
}
private fun overrideRootWindowInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
val left = max(systemBars.left, ime.left)
val top = systemBars.top
val right = max(systemBars.right, ime.right)
v.setPadding(left, top, right, 0)
insets
}
ViewCompat.requestApplyInsets(binding.root)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (isLoggedIn()) {
executeDeeplink(intent)
}
}
override fun onResume() {
super.onResume()
getMemberInfo()
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.INIT.name
}
)
}
override fun onDestroy() {
deInitMiniPlayer()
playerStateJob?.cancel()
super.onDestroy()
}
override fun setupView() {
notificationSettingsDialog = NotificationSettingsDialog(
this,
layoutInflater
) { isNotifiedLive, isNotifiedUploadContent, isNotifiedMessage ->
viewModel.updateNotificationSettings(
isNotifiedLive,
isNotifiedUploadContent,
isNotifiedMessage
)
}
setupBottomNavigation()
}
fun showLoginActivity() {
if (SharedPreferenceManager.token.isBlank()) {
val extras = intent.extras
startActivity(
Intent(applicationContext, LoginActivity::class.java).apply {
putExtra(Constants.EXTRA_DATA, extras)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
}
}
fun openChatTab() {
viewModel.clickTab(MainV2Tab.CHAT)
}
private fun setupBottomNavigation() {
binding.bottomNavigation.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.menu_main_v2_home -> viewModel.clickTab(MainV2Tab.HOME)
R.id.menu_main_v2_content -> viewModel.clickTab(MainV2Tab.CONTENT)
R.id.menu_main_v2_chat -> viewModel.clickTab(MainV2Tab.CHAT)
R.id.menu_main_v2_my -> viewModel.clickTab(MainV2Tab.MY)
}
true
}
binding.bottomNavigation.apply {
itemIconTintList = null
}
viewModel.currentTab.observe(this) { tab ->
val itemId = when (tab) {
MainV2Tab.HOME -> R.id.menu_main_v2_home
MainV2Tab.CONTENT -> R.id.menu_main_v2_content
MainV2Tab.CHAT -> R.id.menu_main_v2_chat
MainV2Tab.MY -> R.id.menu_main_v2_my
}
if (binding.bottomNavigation.selectedItemId != itemId) {
binding.bottomNavigation.selectedItemId = itemId
}
changeFragment(tab)
}
}
private fun changeFragment(currentTab: MainV2Tab) {
val tag = currentTab.toString()
val fragmentManager = supportFragmentManager
val fragmentTransaction = fragmentManager.beginTransaction()
fragmentManager.primaryNavigationFragment?.let {
fragmentTransaction.hide(it)
}
var fragment = fragmentManager.findFragmentByTag(tag)
if (fragment == null) {
fragment = when (currentTab) {
MainV2Tab.HOME -> HomeMainFragment()
MainV2Tab.CONTENT -> ContentMainFragment()
MainV2Tab.CHAT -> ChatMainFragment()
MainV2Tab.MY -> MyPageFragment()
}
fragmentTransaction.add(R.id.fl_container, fragment, tag)
} else {
fragmentTransaction.show(fragment)
}
fragmentTransaction.setPrimaryNavigationFragment(fragment)
fragmentTransaction.setReorderingAllowed(true)
fragmentTransaction.commitNow()
}
private fun observePlayerState() {
playerStateJob = lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
SharedPreferenceManager.isPlayerServiceRunningFlow.collect { isRunning ->
if (isRunning) {
handler.removeCallbacks(showMiniPlayerRunnable)
handler.postDelayed(showMiniPlayerRunnable, 1500)
} else {
deInitMiniPlayer()
}
}
}
}
}
private fun initAndVisibleMiniPlayer() {
binding.clMiniPlayer.visibility = View.VISIBLE
binding.clMiniPlayer.setOnClickListener { showPlayerFragment() }
binding.ivPlayerStop.setOnClickListener {
startService(
Intent(applicationContext, AudioContentPlayerService::class.java).apply {
action = "STOP_SERVICE"
}
)
}
connectPlayerService()
}
private fun connectPlayerService() {
if (mediaController != null || mediaControllerFuture != null) {
return
}
val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java)
val sessionToken = SessionToken(applicationContext, componentName)
val controllerFuture =
MediaController.Builder(applicationContext, sessionToken).buildAsync()
mediaControllerFuture = controllerFuture
controllerFuture.addListener(
{
try {
if (mediaController != null) {
controllerFuture.get().release()
return@addListener
}
mediaController = controllerFuture.get()
setupMediaController()
updateMediaMetadata(mediaController?.mediaMetadata)
binding.ivPlayerPlayOrPause.setImageResource(
if (mediaController?.isPlaying == true) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
}
)
binding.ivPlayerPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
} else {
it.play()
}
}
}
} catch (throwable: Throwable) {
Logger.e(throwable, "Failed to connect player service")
} finally {
mediaControllerFuture = null
}
},
ContextCompat.getMainExecutor(applicationContext)
)
}
private fun updateMediaMetadata(metadata: MediaMetadata?) {
metadata?.let {
binding.tvPlayerTitle.text = it.title
binding.tvPlayerNickname.text = it.artist
binding.ivPlayerCover.load(it.artworkUri) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(RoundedCornersTransformation(4f))
}
}
}
private fun setupMediaController() {
if (mediaController == null) {
deInitMiniPlayer()
return
}
mediaController!!.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateMediaMetadata(mediaItem?.mediaMetadata)
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
binding.ivPlayerPlayOrPause.setImageResource(
if (playWhenReady) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
}
)
}
})
}
private fun deInitMiniPlayer() {
handler.removeCallbacks(showMiniPlayerRunnable)
binding.clMiniPlayer.visibility = View.GONE
mediaControllerFuture?.cancel(true)
mediaControllerFuture = null
mediaController?.release()
mediaController = null
}
private fun showPlayerFragment() {
val playerFragment = AudioContentPlayerFragment(screenWidth, arrayListOf())
playerFragment.show(supportFragmentManager, playerFragment.tag)
}
private fun checkPermissions() {
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
TedPermission.create()
.setPermissionListener(object : PermissionListener {
override fun onPermissionGranted() {
}
override fun onPermissionDenied(deniedPermissions: MutableList<String>?) {
}
})
.setDeniedMessage(R.string.record_audio_permission_denied_message)
.setPermissions(*permissions.toTypedArray())
.check()
}
private fun trackAppLaunchIfNeeded() {
handler.postDelayed({
val alreadyTrackingAppLaunch = SharedPreferenceManager.alreadyTrackingAppLaunch
val pid = SharedPreferenceManager.marketingPid
if (!alreadyTrackingAppLaunch && pid.isNotBlank()) {
SharedPreferenceManager.alreadyTrackingAppLaunch = true
viewModel.adTrackingAppLaunch(pid = pid)
}
}, 1000)
}
private fun pushTokenUpdate() {
FirebaseMessaging.getInstance().token.addOnCompleteListener {
if (!it.isSuccessful) {
Logger.v("Fetching FCM registration token failed", it.exception)
return@addOnCompleteListener
}
val pushToken = it.result
if (pushToken != null) {
SharedPreferenceManager.pushToken = pushToken
if (isLoggedIn()) {
viewModel.pushTokenUpdate(pushToken)
}
}
}
}
private fun updatePidAndGaid() {
handler.postDelayed({
viewModel.fetchAndUpdateGaidAndPid(context = applicationContext)
}, 3000)
}
private fun getMemberInfo() {
if (isLoggedIn()) {
viewModel.getMemberInfo(context = applicationContext) {
notificationSettingsDialog.show(screenWidth)
}
}
}
private fun getEventPopup() {
viewModel.getEventPopup {
if (SharedPreferenceManager.notShowingEventPopupId != it.id) {
EventPopupDialogFragment(
screenWidth = screenWidth,
eventItem = it
) {
startActivity(
Intent(applicationContext, EventDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_EVENT, it)
}
)
}.show(supportFragmentManager, EventPopupDialogFragment::class.java.simpleName)
}
}
}
private fun executeDeeplink(intent: Intent) {
val bundle = intent.getBundleExtra(Constants.EXTRA_DATA) ?: return
val deepLinkUrl = bundle.getString("deep_link")
val routeBundle = if (!deepLinkUrl.isNullOrBlank()) {
buildBundleFromDeepLinkUrl(deepLinkUrl) ?: bundle
} else {
bundle
}
if (executeBundleRoute(routeBundle)) {
clearDeferredDeepLink()
}
}
private fun buildBundleFromDeepLinkUrl(deepLinkUrl: String): Bundle? {
val data = runCatching { deepLinkUrl.toUri() }.getOrNull() ?: return null
val extras = Bundle().apply {
putString("deep_link", deepLinkUrl)
}
fun putQuery(key: String) {
val value = data.getQueryParameter(key)
if (!value.isNullOrBlank()) {
extras.putString(key, value)
}
}
putQuery("channel_id")
putQuery("message_id")
putQuery("audition_id")
putQuery("content_id")
putQuery("deep_link_value")
putQuery("deep_link_sub5")
putQuery(Constants.EXTRA_COMMUNITY_CREATOR_ID)
putQuery(Constants.EXTRA_COMMUNITY_POST_ID)
applyPathDeepLink(data = data) { key, value ->
if (!value.isNullOrBlank() && !extras.containsKey(key)) {
extras.putString(key, value)
}
}
return extras
}
private fun applyPathDeepLink(
data: android.net.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) {
"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)
}
}
}
private fun executeBundleRoute(bundle: Bundle): Boolean {
val channelId = bundle.getString("channel_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_USER_ID).takeIf { it > 0 }
val messageId = bundle.getString("message_id")?.toLongOrNull()
?: 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 communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
when {
channelId != null && channelId > 0 -> {
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, channelId)
}
)
return true
}
contentId != null && contentId > 0 -> {
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
return true
}
messageId != null && messageId > 0 -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
return true
}
communityCreatorId != null && communityCreatorId > 0 -> {
startActivity(
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, communityCreatorId)
if (communityPostId != null && communityPostId > 0) {
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, communityPostId)
}
}
)
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()
return !deepLinkValue.isNullOrBlank() && routeByDeepLinkValue(deepLinkValue, deepLinkValueId)
}
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)
}
)
true
}
"content" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, deepLinkValueId)
}
)
true
}
"channel" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
}
)
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
}
}
private fun clearDeferredDeepLink() {
SharedPreferenceManager.marketingUtmSource = ""
SharedPreferenceManager.marketingUtmMedium = ""
SharedPreferenceManager.marketingUtmCampaign = ""
SharedPreferenceManager.marketingLinkValue = ""
SharedPreferenceManager.marketingLinkValueId = 0
}
private fun isLoggedIn(): Boolean {
return SharedPreferenceManager.token.isNotBlank() && SharedPreferenceManager.token.length > 10
}
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
enum class MainV2Tab {
HOME,
CONTENT,
CHAT,
MY
}

View File

@@ -0,0 +1,231 @@
package kr.co.vividnext.sodalive.v2.main
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AddAllPlaybackTrackingRequest
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.audio_content.PlaybackTrackingData
import kr.co.vividnext.sodalive.audio_content.PlaybackTrackingRepository
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.main.MarketingInfoUpdateRequest
import kr.co.vividnext.sodalive.main.PushTokenUpdateRequest
import kr.co.vividnext.sodalive.settings.ContentType
import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.settings.event.EventRepository
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.tracking.AdTrackingRepository
import kr.co.vividnext.sodalive.tracking.FirebaseTracking
import kr.co.vividnext.sodalive.tracking.NotiflyClient
import kr.co.vividnext.sodalive.user.UserRepository
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
class MainV2ViewModel(
private val userRepository: UserRepository,
private val eventRepository: EventRepository,
private val adTrackingRepository: AdTrackingRepository,
private val audioContentRepository: AudioContentRepository,
private val playbackTrackingRepository: PlaybackTrackingRepository
) : BaseViewModel() {
private val _currentTab = MutableLiveData(MainV2Tab.HOME)
val currentTab: LiveData<MainV2Tab>
get() = _currentTab
fun clickTab(tab: MainV2Tab) {
if (_currentTab.value != tab) {
_currentTab.postValue(tab)
}
}
fun updateNotificationSettings(
isNotifiedLive: Boolean,
isNotifiedUploadContent: Boolean,
isNotifiedMessage: Boolean
) {
compositeDisposable.add(
userRepository.updateNotificationSettings(
request = UpdateNotificationSettingRequest(
isNotifiedLive,
isNotifiedUploadContent,
isNotifiedMessage
),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
fun pushTokenUpdate(pushToken: String) {
compositeDisposable.add(
userRepository
.updatePushToken(
PushTokenUpdateRequest(pushToken = pushToken),
"Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
fun getMemberInfo(context: Context, showNotificationSettingsDialog: () -> Unit) {
compositeDisposable.add(
userRepository.getMemberInfo(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
val data = it.data
SharedPreferenceManager.can = data.can
SharedPreferenceManager.point = data.point
SharedPreferenceManager.role = data.role.name
SharedPreferenceManager.isAuth = data.isAuth
val localCountryCode = SharedPreferenceManager.countryCode.ifBlank { "KR" }
val resolvedCountryCode = data.countryCode?.ifBlank { "KR" } ?: localCountryCode
val resolvedIsAdultContentVisible =
data.isAdultContentVisible ?: SharedPreferenceManager.isAdultContentVisible
val resolvedContentType =
data.contentType
?: ContentType.entries.getOrNull(SharedPreferenceManager.contentPreference)
?: ContentType.ALL
SharedPreferenceManager.countryCode = resolvedCountryCode
SharedPreferenceManager.isAdultContentVisible = resolvedIsAdultContentVisible
SharedPreferenceManager.contentPreference = resolvedContentType.ordinal
SharedPreferenceManager.isAuditionNotification =
data.auditionNotice ?: false
if (
data.followingChannelUploadContentNotice == null &&
data.followingChannelLiveNotice == null &&
data.messageNotice == null
) {
showNotificationSettingsDialog()
}
val dateFormat = SimpleDateFormat(
"yyyy-MM-dd, HH:mm:ss",
Locale.getDefault()
)
val lastActiveDate = dateFormat.format(Date())
val params = mutableMapOf(
"nickname" to SharedPreferenceManager.nickname,
"last_active_date" to lastActiveDate,
"charge_count" to data.chargeCount,
"signup_date" to data.signupDate,
"is_auth" to data.isAuth,
"gender" to data.gender,
"can" to data.can
)
NotiflyClient.setUser(
context = context,
userId = SharedPreferenceManager.userId,
params = params
)
FirebaseTracking.login("email")
}
},
{}
)
)
}
fun addAllPlaybackTracking() {
val trackingDataList = playbackTrackingRepository.getAllPlaybackTracking()
.filter { it.endPosition != null }
.filter { it.endPosition!! - it.startPosition >= 4000 }
.map {
PlaybackTrackingData(it.contentId, it.playDateTime, it.isPreview)
}
if (trackingDataList.isNotEmpty()) {
compositeDisposable.add(
audioContentRepository.addAllPlaybackTracking(
request = AddAllPlaybackTrackingRequest(trackingDataList = trackingDataList),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
playbackTrackingRepository.removeAllPlaybackTracking()
}
},
{}
)
)
}
}
fun getEventPopup(onSuccess: (EventItem) -> Unit) {
compositeDisposable.add(
eventRepository.getEventPopup(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
onSuccess(it.data)
}
},
{}
)
)
}
fun fetchAndUpdateGaidAndPid(context: Context) {
Executors.newSingleThreadExecutor().execute {
try {
val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context)
val request = MarketingInfoUpdateRequest(
adid = adInfo.id.orEmpty(),
pid = SharedPreferenceManager.marketingPid
)
updateMarketingInfo(request)
} catch (e: Exception) {
e.printStackTrace()
updateMarketingInfo(
MarketingInfoUpdateRequest(
adid = "",
pid = SharedPreferenceManager.marketingPid
)
)
}
}
}
fun adTrackingAppLaunch(pid: String) {
compositeDisposable.add(
adTrackingRepository.appLaunch(pid)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
private fun updateMarketingInfo(request: MarketingInfoUpdateRequest) {
compositeDisposable.add(
userRepository.updateMarketingInfo(
request,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_checked="true" />
<item android:color="@color/white" android:state_selected="true" />
<item android:color="@color/gray_600" />
</selector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_nav_chat_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_chat" />
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_nav_content_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_content" />
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_nav_home_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_home" />
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_tabbar_my_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_my" />
</selector>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
<FrameLayout
android:id="@+id/fl_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/cl_mini_player"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_mini_player"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/color_222222"
android:paddingHorizontal="13.3dp"
android:paddingVertical="10.7dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/bottom_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible">
<ImageView
android:id="@+id/iv_player_cover"
android:layout_width="36.7dp"
android:layout_height="36.7dp"
android:contentDescription="@null"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/tv_player_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10.7dp"
android:ellipsize="end"
android:fontFamily="@font/medium"
android:maxLines="2"
android:textColor="@color/color_eeeeee"
android:textSize="13sp"
app:layout_constraintEnd_toStartOf="@+id/iv_player_play_or_pause"
app:layout_constraintStart_toEndOf="@+id/iv_player_cover"
app:layout_constraintTop_toTopOf="@+id/iv_player_cover"
tools:text="JFLA 커버곡 Avicii for your self" />
<TextView
android:id="@+id/tv_player_nickname"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2.3dp"
android:fontFamily="@font/medium"
android:textColor="@color/color_d2d2d2"
android:textSize="11sp"
app:layout_constraintEnd_toEndOf="@+id/tv_player_title"
app:layout_constraintStart_toStartOf="@+id/tv_player_title"
app:layout_constraintTop_toBottomOf="@+id/tv_player_title"
tools:ignore="SmallSp"
tools:text="JFLA 커버곡 Avicii for your self" />
<ImageView
android:id="@+id/iv_player_play_or_pause"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginEnd="16dp"
android:contentDescription="@null"
app:layout_constraintBottom_toBottomOf="@+id/iv_player_stop"
app:layout_constraintEnd_toStartOf="@+id/iv_player_stop"
app:layout_constraintTop_toTopOf="@+id/iv_player_stop"
tools:src="@drawable/btn_bar_play" />
<ImageView
android:id="@+id/iv_player_stop"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@null"
android:src="@drawable/ic_noti_stop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/black"
app:itemActiveIndicatorStyle="@null"
app:itemIconTint="@null"
app:itemTextAppearanceActive="@style/Typography.Caption3"
app:itemTextAppearanceActiveBoldEnabled="false"
app:itemTextAppearanceInactive="@style/Typography.Caption3"
app:itemTextColor="@color/color_main_v2_bottom_navigation_label"
app:labelVisibilityMode="labeled"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/menu_main_v2_bottom_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -2,6 +2,7 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@color/black"
android:orientation="vertical"> android:orientation="vertical">
<ImageView <ImageView

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black" />

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black" />

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black" />

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_main_v2_home"
android:icon="@drawable/ic_nav_home_tab"
android:title="@string/tab_home" />
<item
android:id="@+id/menu_main_v2_content"
android:icon="@drawable/ic_nav_content_tab"
android:title="@string/tab_content" />
<item
android:id="@+id/menu_main_v2_chat"
android:icon="@drawable/ic_nav_chat_tab"
android:title="@string/tab_chat" />
<item
android:id="@+id/menu_main_v2_my"
android:icon="@drawable/ic_nav_my_tab"
android:title="@string/tab_my" />
</menu>

View File

@@ -137,6 +137,7 @@
<!-- Main / Home --> <!-- Main / Home -->
<string name="tab_home">Home</string> <string name="tab_home">Home</string>
<string name="tab_content">Content</string>
<string name="tab_chat">Chat</string> <string name="tab_chat">Chat</string>
<string name="tab_live">Live</string> <string name="tab_live">Live</string>
<string name="tab_my">My</string> <string name="tab_my">My</string>

View File

@@ -137,6 +137,7 @@
<!-- Main / Home --> <!-- Main / Home -->
<string name="tab_home">ホーム</string> <string name="tab_home">ホーム</string>
<string name="tab_content">コンテンツ</string>
<string name="tab_chat">チャット</string> <string name="tab_chat">チャット</string>
<string name="tab_live">ライブ</string> <string name="tab_live">ライブ</string>
<string name="tab_my">マイ</string> <string name="tab_my">マイ</string>

View File

@@ -136,6 +136,7 @@
<!-- Main / Home --> <!-- Main / Home -->
<string name="tab_home"></string> <string name="tab_home"></string>
<string name="tab_content">콘텐츠</string>
<string name="tab_chat">채팅</string> <string name="tab_chat">채팅</string>
<string name="tab_live">라이브</string> <string name="tab_live">라이브</string>
<string name="tab_my">마이</string> <string name="tab_my">마이</string>

View File

@@ -0,0 +1,367 @@
# 메인 페이지 하단 내비게이션 신규 개발
## 작업 목표
- 메인 페이지를 `홈`, `콘텐츠`, `채팅`, `마이` 4개 하단 내비게이션 탭 구조로 구성한다.
- 기존 `MainActivity`에 신규 구조를 덧씌우지 않고 `kr.co.vividnext.sodalive.v2` 하위 신규 메인 Activity를 개발한다.
- 하단 내비게이션은 Material `BottomNavigationView`를 사용한다.
- `마이` 탭은 기존 `MyPageFragment`를 재사용한다.
- `홈`, `콘텐츠`, `채팅` 탭은 신규 빈 Fragment를 만들어 표시한다.
- 초기 문서 작성 완료 후, 사용자 승인에 따라 계획 문서 기준으로 앱 소스 구현까지 진행한다.
## 근거 문서
- PRD: `docs/prd/20260519_메인페이지하단내비게이션신규개발_prd.md`
- 문서 규칙: `docs/agent-guides/workflow-docs-commits.md`
- 빌드/테스트 규칙: `docs/agent-guides/build-test-style.md`
## 현재 구조 요약
- 기존 `MainActivity``activity_main.xml``FrameLayout#fl_container`에 탭별 Fragment를 표시한다.
- 기존 탭 전환은 `supportFragmentManager``add`/`hide`/`show`/`commitNow()` 패턴을 사용한다.
- 기존 하단 탭 UI는 `activity_main.xml``LinearLayout#ll_tab``item_main_tab.xml` include 구조로 구성되어 있다.
- 기존 `MainViewModel.CurrentTab``HOME`, `LIVE`, `MY`, `CHAT`을 가진다.
- 신규 구조는 기존 `MainActivity`를 직접 개편하지 않고 별도 v2 메인 Activity로 분리한다.
- 신규 요구 탭 순서는 `HOME`, `CONTENT`, `CHAT`, `MY`이다.
## 구현 계획
### Task 1: 신규 메인 Activity 골격 추가
**Files:**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt`
- Create: `app/src/main/res/layout/activity_main_v2.xml`
- Modify: `app/src/main/AndroidManifest.xml`
- Reference: `app/src/main/java/kr/co/vividnext/sodalive/splash/SplashActivity.kt`
- Reference: `app/src/main/java/kr/co/vividnext/sodalive/user/login/LoginActivity.kt`
- Reference: `app/src/main/java/kr/co/vividnext/sodalive/user/signup/SignUpActivity.kt`
- Reference: `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
- Reference: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt`
- [x] **Step 1: 기존 MainActivity 책임 확인**
Run: `rg -n "class MainActivity|checkPermissions|showLoginActivity|getEventPopup|initAndVisibleMiniPlayer|executeDeeplink|setupBottomTabLayout|changeFragment" app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt`
Expected: 기존 인증, 이벤트 팝업, 미니 플레이어, 딥링크, 탭 전환 관련 메서드가 조회된다.
- [x] **Step 2: 신규 v2 Activity와 ViewModel 작성**
`MainV2Activity``MainV2ViewModel``kr.co.vividnext.sodalive.v2.main` 패키지 하위에 작성한다. 기존 `MainActivity` 파일은 수정 대상으로 삼지 않는다.
- [x] **Step 3: 신규 Activity 레이아웃 작성**
`activity_main_v2.xml`은 Fragment 컨테이너, 미니 플레이어 영역, `BottomNavigationView`를 분리해 배치한다. 미니 플레이어는 `BottomNavigationView` 위에 위치해야 한다.
- [x] **Step 4: AndroidManifest 등록**
신규 Activity를 `AndroidManifest.xml`에 등록한다. 런처 Activity는 기존처럼 `SplashActivity`를 유지한다.
- [x] **Step 5: MainActivity 진입점 전환 대상 확인**
Run: `rg -n "MainActivity::class|kr\.co\.vividnext\.sodalive\.main\.MainActivity" app/src/main/java app/src/main/AndroidManifest.xml`
Expected: `SplashActivity`, 로그인/회원가입 완료, 딥링크, 플레이어 서비스 알림 등 기존 `MainActivity` 진입점이 조회된다. 구현 시 신규 메인으로 전환할 대상과 레거시 유지 대상을 이 목록에서 명시적으로 분류한다.
### Task 2: 탭 상태와 라벨 정의 정리
**Files:**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Tab.kt`
- Modify: `app/src/main/res/values/strings.xml`
- [x] **Step 1: 신규 탭 enum 정의**
`MainV2Tab`은 신규 메인 탭 기준으로 `HOME`, `CONTENT`, `CHAT`, `MY`를 가진다.
- [x] **Step 2: 기존 MainViewModel 탭 enum은 수정하지 않음 확인**
Run: `rg -n "enum class CurrentTab" app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt`
Expected: 기존 `MainViewModel.CurrentTab`은 레거시 메인 화면용으로 유지한다.
- [x] **Step 3: 콘텐츠 탭 문자열 추가**
`strings.xml`의 Main/Home 문자열 영역에 `tab_content`를 추가한다.
```xml
<string name="tab_content">콘텐츠</string>
```
- [x] **Step 4: 문자열 리소스 확인**
Run: `rg -n 'name="tab_(home|content|chat|my)"' app/src/main/res/values/strings.xml`
Expected: `tab_home`, `tab_content`, `tab_chat`, `tab_my`가 모두 조회된다.
### Task 3: 신규 빈 페이지 Fragment 추가
**Files:**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/ContentMainFragment.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/ChatMainFragment.kt`
- Create: `app/src/main/res/layout/fragment_v2_main_home.xml`
- Create: `app/src/main/res/layout/fragment_v2_main_content.xml`
- Create: `app/src/main/res/layout/fragment_v2_main_chat.xml`
- [x] **Step 1: 신규 패키지 위치 생성**
신규 Fragment는 저장소 규칙에 따라 `kr.co.vividnext.sodalive.v2.main` 패키지 하위에 둔다.
- [x] **Step 2: 홈 빈 Fragment 작성**
`HomeMainFragment``BaseFragment`와 ViewBinding 패턴을 사용하고, 별도 로직 없이 빈 홈 페이지 레이아웃을 표시한다.
- [x] **Step 3: 콘텐츠 빈 Fragment 작성**
`ContentMainFragment``BaseFragment`와 ViewBinding 패턴을 사용하고, 별도 로직 없이 빈 콘텐츠 페이지 레이아웃을 표시한다.
- [x] **Step 4: 채팅 빈 Fragment 작성**
`ChatMainFragment``BaseFragment`와 ViewBinding 패턴을 사용하고, 별도 로직 없이 빈 채팅 페이지 레이아웃을 표시한다.
- [x] **Step 5: 빈 페이지 레이아웃 작성**
각 XML 레이아웃은 `match_parent` 크기의 최소 루트 뷰를 사용하고, 네트워크/리스트/상태 UI는 추가하지 않는다.
- [x] **Step 6: 신규 Fragment 참조 확인**
Run: `rg -n "class (HomeMainFragment|ContentMainFragment|ChatMainFragment)" app/src/main/java/kr/co/vividnext/sodalive/v2/main`
Expected: 신규 Fragment 3개가 모두 조회된다.
### Task 4: BottomNavigationView와 아이콘 연결
**Files:**
- Modify: `app/src/main/res/layout/activity_main_v2.xml`
- Create: `app/src/main/res/menu/menu_main_v2_bottom_navigation.xml`
- Create or Modify: tab icon selector drawable files under `app/src/main/res/drawable/`
- Create or Modify: bottom navigation text color selector under `app/src/main/res/color/`
- Reference: existing PNG resources under `app/src/main/res/drawable-mdpi/` and `app/src/main/res/drawable-xxhdpi/`
- Reference: `app/src/main/res/values/typography.xml`
- [x] **Step 1: BottomNavigationView 배치 확인**
Run: `rg -n "BottomNavigationView|bottom_navigation" app/src/main/res/layout/activity_main_v2.xml`
Expected: 신규 Activity 레이아웃에 `BottomNavigationView`가 조회된다.
- [x] **Step 2: menu 리소스 작성**
`menu_main_v2_bottom_navigation.xml`의 item은 `home`, `content`, `chat`, `my` 순서로 정의한다.
- [x] **Step 3: 탭 라벨과 아이콘 연결**
각 menu item은 `android:title``android:icon`을 가진다. 접근성을 위해 title을 비워두지 않는다.
- [x] **Step 4: 탭 아이콘 selector 구성**
선택/미선택 상태 모두 아이콘 tint나 파란 선택 리소스를 사용하지 않고 다음 원본 리소스를 사용한다.
| 탭 | 미선택 | 선택 |
| --- | --- | --- |
| 홈 | `ic_nav_home` | `ic_nav_home` |
| 콘텐츠 | `ic_nav_content` | `ic_nav_content` |
| 채팅 | `ic_nav_chat` | `ic_nav_chat` |
| 마이 | `ic_nav_my` | `ic_nav_my` |
- [x] **Step 5: 아이콘 리소스 존재 확인**
Run: `rg --files app/src/main/res | rg 'ic_nav_(home|content|chat|my)(_|\.)|ic_tabbar_my_selected'`
Expected: `ic_nav_home`, `ic_nav_content`, `ic_nav_chat`, `ic_nav_my`가 조회된다.
- [x] **Step 6: BottomNavigationView 시각 스타일 조정**
`activity_main_v2.xml``BottomNavigationView`는 배경 `@color/black`, 아이콘 tint 없음, 라벨 색상 selector, `Typography.Caption3`, item padding 축소를 적용한다.
Expected:
- `app:itemIconTint="@null"`을 유지한다.
- 미선택 라벨은 `@color/gray_600`, 선택 라벨은 `@color/white`를 사용한다.
- `app:itemTextAppearanceActive``app:itemTextAppearanceInactive``@style/Typography.Caption3`을 사용한다.
- 과도한 내부 padding을 줄이기 위해 `app:itemPaddingTop``app:itemPaddingBottom``0dp`로 지정한다.
- [x] **Step 7: edge-to-edge 하단 inset과 아이콘 색상 원인 수정**
`MainV2Activity``BaseActivity`의 루트 bottom system inset padding을 화면 범위에서만 제거한다. 탭 아이콘 selector는 checked 상태에서도 원본 아이콘 리소스를 사용해 `#3BB9F1` 선택 아이콘이 표시되지 않게 한다.
Expected:
- `BaseActivity`는 수정하지 않는다.
- `MainV2Activity`에서 root bottom padding을 `0`으로 재적용한다.
- `ic_nav_home_tab`, `ic_nav_content_tab`, `ic_nav_chat_tab`, `ic_nav_my_tab`은 checked/default 모두 원본 아이콘을 사용한다.
### Task 5: 신규 MainV2Activity 탭 연결과 Fragment 전환
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt`
- [x] **Step 1: BottomNavigationView 선택 리스너 연결**
`BottomNavigationView.setOnItemSelectedListener`에서 선택된 menu item id를 `MainV2Tab`으로 매핑한다.
- [x] **Step 2: 탭별 Fragment 매핑 변경**
`HOME``HomeMainFragment`, `CONTENT``ContentMainFragment`, `CHAT``ChatMainFragment`, `MY`는 기존 `MyPageFragment`를 반환하도록 매핑한다.
- [x] **Step 3: 탭별 back stack 미사용 전환 구현**
Fragment 전환은 탭별 back stack을 만들지 않는다. `addToBackStack()`은 사용하지 않는다.
- [x] **Step 4: 채팅 배지 확장 지점 확보**
이번 구현에서 실제 배지 데이터는 붙이지 않더라도, `chat` menu item id는 향후 `BottomNavigationView.getOrCreateBadge()`로 확장할 수 있도록 안정적인 이름으로 정의한다.
### Task 6: 기존 필수 프로세스 선별 이관
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt`
- [x] **Step 1: 권한/인증 흐름 이관**
기존 `MainActivity`의 권한 요청과 로그인 진입 흐름을 신규 Activity에 필요한 범위로 이관한다.
- [x] **Step 2: 이벤트 팝업 흐름 이관**
로그인 상태에서 이벤트 팝업을 조회하고 표시하는 기존 사용자 경험을 신규 Activity에서도 유지한다.
- [x] **Step 3: 미니 플레이어 흐름 이관**
오디오 플레이어 서비스 상태를 관찰해 미니 플레이어를 표시/해제하는 흐름을 신규 Activity에서도 유지한다.
- [x] **Step 4: 딥링크 라우팅 진입점 분리**
딥링크 정리 계획을 고려해 신규 Activity 내부에 탭 라우팅 진입점을 분리한다. 상세 딥링크 정책 확장은 별도 작업에서 다룬다.
### Task 7: 검증
**Files:**
- Modify: `docs/plan-task/20260519_메인페이지하단내비게이션신규개발.md`
- [x] **Step 1: 변경 파일 LSP 진단 실행**
Run: `lsp_diagnostics` on changed Kotlin/XML files.
Expected: 이번 변경으로 인한 오류가 없다. XML LSP가 환경에 없으면 검증 기록에 남긴다.
- [x] **Step 2: 리소스/문자열/Menu 조회 검증**
Run: `rg -n 'BottomNavigationView|menu_main_v2_bottom_navigation|tab_content|ic_nav_home|ic_nav_content|ic_nav_chat|ic_nav_my|ic_tabbar_my_selected|MainV2Activity' app/src/main/res app/src/main/java/kr/co/vividnext/sodalive`
Expected: 신규 Activity, BottomNavigationView, menu, 신규 탭 문자열과 아이콘 참조가 조회된다.
- [x] **Step 3: 디버그 빌드 실행**
Run: `./gradlew :app:assembleDebug`
Expected: `BUILD SUCCESSFUL`
- [x] **Step 4: 검증 기록 누적**
이 문서 하단 `검증 기록`에 실행한 명령, 목적, 결과를 한국어로 누적 기록한다.
## 체크리스트
- [x] AC1: 하단 탭이 `홈`, `콘텐츠`, `채팅`, `마이` 순서로 표시된다.
- [x] AC2: 신규 메인 화면은 기존 `MainActivity`에 덧씌우지 않고 `kr.co.vividnext.sodalive.v2` 하위 신규 Activity로 구성된다.
- [x] AC3: 하단 내비게이션은 `BottomNavigationView`를 사용한다.
- [x] AC4: `마이` 탭은 기존 `MyPageFragment`를 표시한다.
- [x] AC5: `홈`, `콘텐츠`, `채팅` 탭은 신규 빈 Fragment를 표시한다.
- [x] AC6: 선택/미선택 아이콘은 tint 없이 원본 아이콘 리소스를 사용한다.
- [x] AC7: checked 상태에서 `#3BB9F1` 선택 아이콘 리소스로 바뀌지 않는다.
- [x] AC8: 탭별 back stack을 사용하지 않는다.
- [x] AC9: 인증, 이벤트 팝업, 미니 플레이어 흐름은 신규 Activity에서도 기존 사용자 경험을 유지한다.
- [x] AC10: `./gradlew :app:assembleDebug`가 성공한다.
## 검증 기록
- 2026-05-19
- 무엇/왜/어떻게: 구현 전 문서 작성을 위해 기존 메인 탭 구조와 문서 규칙을 조사했다.
- 실행 명령/도구:
- `rg --files docs app/src/main | rg '(^docs/|MainActivity|fragment_.*main|activity_.*main|menu|nav|tabbar|ic_nav|ic_tabbar)'`
- `read(app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt)`
- `read(app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt)`
- `read(app/src/main/res/layout/activity_main.xml)`
- `read(app/src/main/res/layout/item_main_tab.xml)`
- `read(docs/agent-guides/workflow-docs-commits.md)`
- `read(docs/prd/sample-prd.md)`
- 결과:
- 현재 메인 화면은 커스텀 하단 탭바와 수동 `FragmentTransaction` 패턴을 사용한다.
- 현재 탭은 `HOME`, `CHAT`, `LIVE`, `MY` 구성이며 신규 요구는 `HOME`, `CONTENT`, `CHAT`, `MY`이다.
- 저장소 규칙에 따라 PRD는 `docs/prd/`, 계획/TASK 문서는 `docs/plan-task/`에 작성해야 한다.
- 사용자 지시에 따라 현재 단계에서는 문서만 작성하고 구현 파일은 변경하지 않는다.
- 2026-05-19
- 무엇/왜/어떻게: PRD 검토 중 결정된 `BottomNavigationView` 사용과 신규 v2 메인 Activity 개발 방향을 문서에 반영했다.
- 실행 명령/도구:
- `read(docs/prd/20260519_메인페이지하단내비게이션신규개발_prd.md)`
- `read(docs/plan-task/20260519_메인페이지하단내비게이션신규개발.md)`
- `rg -n "BottomNavigationView|커스텀|MainActivity|신규|v2|FragmentTransaction|tabbar|탭바" docs/prd/20260519_메인페이지하단내비게이션신규개발_prd.md docs/plan-task/20260519_메인페이지하단내비게이션신규개발.md`
- 결과:
- 기존 `MainActivity`에 신규 구조를 덧씌우지 않고 신규 v2 메인 Activity를 개발하는 방향으로 계획을 변경했다.
- 하단 내비게이션은 커스텀 탭바 대신 Material `BottomNavigationView`를 사용하는 것으로 정리했다.
- 탭별 back stack은 사용하지 않고, 인증/이벤트 팝업/미니 플레이어 흐름은 신규 Activity에 필요한 범위로 이관하는 것으로 정리했다.
- 2026-05-19
- 무엇/왜/어떻게: 계획 문서 기준으로 신규 v2 메인 Activity와 BottomNavigationView 기반 하단 내비게이션을 구현하고 검증했다.
- 실행 명령/도구:
- `task(subagent_type="explore", description="v2 메인 패턴 조사")`
- `task(subagent_type="librarian", description="BottomNavigationView API 조사")`
- `rg -n "com.google.android.material|viewBinding|koin|BaseActivity|BaseFragment|EventPopupDialogFragment|NotificationSettingsDialog|isPlayerServiceRunningFlow|MediaController|BottomNavigationView|MainActivity::class|class MainActivity|class MainViewModel" app/build.gradle build.gradle settings.gradle app/src/main/java app/src/main/res app/src/main/AndroidManifest.xml`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `rg -n "class (HomeMainFragment|ContentMainFragment|ChatMainFragment)|MainV2Activity::class|MainActivity::class|showLoginActivity\(\)|menu_main_v2_|BottomNavigationView|getOrCreateBadge|addToBackStack" app/src/main/java app/src/main/res app/src/main/AndroidManifest.xml`
- `./gradlew :app:ktlintCheck`
- `./gradlew :app:assembleDebug`
- `adb devices`
- `test -f app/build/outputs/apk/debug/app-debug.apk`
- 결과:
- Kotlin LSP는 현재 환경에 설정되어 있지 않아 실행 불가(`No LSP server configured for extension: .kt`)였다.
- 신규 `MainV2Activity`, `MainV2ViewModel`, `MainV2Tab`, 홈/콘텐츠/채팅 빈 Fragment, `BottomNavigationView` menu/selector 리소스를 추가했다.
- `SplashActivity`, 로그인/회원가입 완료, `DeepLinkActivity`, 오디오 플레이어 알림 진입점을 `MainV2Activity`로 전환했다.
- 기존 `MyPageFragment``MainActivity``MainV2Activity` 양쪽 host에서 로그인 진입을 처리하도록 수정했다.
- `./gradlew :app:ktlintCheck`는 중간 fresh 실행에서 `BUILD SUCCESSFUL`로 완료됐으나, 이후 추가 확인 과정에서 최종 diff에 남지 않는 `LiveReservationCancelActivity`, `LiveRoomActivity`의 기존 ktlint 위반이 다시 보고되어 현재 명령은 실패한다.
- 최종 ktlint 보고서에서 이번 변경 파일 관련 위반은 조회되지 않았다.
- `./gradlew :app:assembleDebug`는 fresh 실행 기준 `BUILD SUCCESSFUL`로 완료됐다.
- `adb devices` 결과 연결된 기기/에뮬레이터가 없어 실제 앱 실행 수동 QA는 수행하지 못했다.
- 디버그 APK `app/build/outputs/apk/debug/app-debug.apk` 생성은 확인했다.
- 2026-05-19
- 무엇/왜/어떻게: 사용자 요청에 따라 `MainV2Activity``BottomNavigationView` 시각 스타일을 조정하고 검증했다.
- 실행 명령/도구:
- `task(category="quick", description="BottomNavigation 패턴 조사")`
- `task(subagent_type="librarian", description="Material nav API 조사")`
- `rg -n "BottomNavigationView|bottom_navigation|menu_main_v2|itemIconTint|itemTextColor|itemTextAppearance|itemPadding|padding|fitsSystemWindows|WindowInsets|gray600|caption|TextAppearance|colorBlack|black" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive/v2 app/src/main/java/kr/co/vividnext/sodalive/main app/src/main/AndroidManifest.xml`
- `lsp_diagnostics(app/src/main/res/layout/activity_main_v2.xml)`
- `rg -n "itemIconTint=\"@null\"|itemPadding(Bottom|Top)=\"0dp\"|itemTextAppearance(Active|Inactive)=\"@style/Typography.Caption3\"|itemTextColor=\"@color/color_main_v2_bottom_navigation_label\"|android:background=\"@color/black\"" app/src/main/res/layout/activity_main_v2.xml`
- `rg -n "state_checked=\"true\"|state_selected=\"true\"|gray_600|white" app/src/main/res/color/color_main_v2_bottom_navigation_label.xml`
- `./gradlew :app:assembleDebug`
- 결과:
- 과도한 하단 padding의 주요 후보는 `BottomNavigationView` 기본 item padding과 `BaseActivity`의 system bar inset padding 조합으로 확인했다.
- 전역 inset 처리는 변경하지 않고, `BottomNavigationView`의 item padding을 `0dp`로 줄였다.
- 하단 내비게이션 배경은 `@color/black`으로 변경했다.
- 아이콘은 `app:itemIconTint="@null"`을 유지해 원본 색상을 사용하도록 했다.
- 라벨 색상은 선택 `@color/white`, 미선택 `@color/gray_600` selector로 분리했다.
- 라벨 typography는 사용자 확정에 따라 active/inactive 모두 `@style/Typography.Caption3`을 적용했다.
- XML LSP는 현재 환경에 설정되어 있지 않아 실행 불가(`No LSP server configured for extension: .xml`)였다.
- `./gradlew :app:assembleDebug``BUILD SUCCESSFUL`로 완료됐다.
- 2026-05-19
- 무엇/왜/어떻게: `BottomNavigationView` 하단 빈 공간과 아이콘의 `#3BB9F1` 색상 표시 원인을 추적하고 MainV2 범위에서 수정했다.
- 실행 명령/도구:
- `task(category="quick", description="Insets 원인 조사")`
- `task(category="quick", description="Icon tint 원인 조사")`
- `task(subagent_type="librarian", description="Material inset tint 조사")`
- `rg -n "setDecorFitsSystemWindows|setOnApplyWindowInsetsListener|WindowInsets|systemBars|ime\)|navigationBarColor|BottomNavigationView|itemIconTint|color_3bb9f1|3BB9F1|ic_nav_.*tab|ic_tabbar_my_selected|itemPadding|fitsSystemWindows|paddingBottom|state_checked|state_selected" app/src/main/java app/src/main/res`
- `read(app/src/main/java/kr/co/vividnext/sodalive/base/BaseActivity.kt)`
- `read(app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt)`
- `read(app/src/main/res/drawable/ic_nav_home_tab.xml)`
- `read(app/src/main/res/drawable/ic_nav_content_tab.xml)`
- `read(app/src/main/res/drawable/ic_nav_chat_tab.xml)`
- `read(app/src/main/res/drawable/ic_nav_my_tab.xml)`
- `rg -n "overrideRootWindowInsets|setOnApplyWindowInsetsListener|setPadding\(left, top, right, 0\)|requestApplyInsets" app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- `rg -n "ic_nav_(home|content|chat|my)(_selected)?|ic_tabbar_my_selected|color_3bb9f1|#3BB9F1" app/src/main/res/drawable/ic_nav_home_tab.xml app/src/main/res/drawable/ic_nav_content_tab.xml app/src/main/res/drawable/ic_nav_chat_tab.xml app/src/main/res/drawable/ic_nav_my_tab.xml`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt)`
- `lsp_diagnostics(app/src/main/res/drawable/ic_nav_home_tab.xml)`
- `./gradlew :app:assembleDebug`
- `./gradlew :app:ktlintCheck`
- 결과:
- 하단 빈 공간의 원인은 `BaseActivity`가 edge-to-edge 상태에서 루트에 `systemBars.bottom` padding을 적용하는 구조로 확인했다.
- `BaseActivity`는 변경하지 않고 `MainV2Activity`에서 루트 inset listener를 재등록해 bottom padding만 `0`으로 재적용했다.
- 아이콘의 `#3BB9F1` 표시는 `BottomNavigationView` tint가 아니라 checked 상태 selector가 선택용 아이콘 리소스를 보여주는 구조에서 발생한 것으로 확인했다.
- `ic_nav_home_tab`, `ic_nav_content_tab`, `ic_nav_chat_tab`, `ic_nav_my_tab`은 checked/default 모두 원본 아이콘 리소스를 사용하도록 변경했다.
- Kotlin/XML LSP는 현재 환경에 설정되어 있지 않아 실행 불가였다.
- `./gradlew :app:assembleDebug``BUILD SUCCESSFUL`로 완료됐다.
- `./gradlew :app:ktlintCheck``BUILD SUCCESSFUL`로 완료됐다.

View File

@@ -0,0 +1,158 @@
# PRD: 메인 페이지 하단 내비게이션 신규 개발
## 1. Overview
메인 페이지를 4개 하단 내비게이션 탭 구조로 정리하고, 탭별 페이지를 표시한다.
---
## 2. Problem
- 현재 메인 화면은 커스텀 하단 탭바와 기존 `MainActivity`에 여러 레거시 흐름이 함께 묶여 있다.
- `BottomNavigationView`와 신규 페이지 구조를 기존 `MainActivity`에 덧씌우면 레거시 구조와 신규 구조가 섞여 복잡도가 커질 수 있다.
- 신규 메인 페이지는 `홈`, `콘텐츠`, `채팅`, `마이` 4개 탭을 기준으로 동작해야 한다.
- `마이` 탭은 기존 마이페이지 기능을 유지해야 하며, 나머지 탭은 아직 상세 기능이 없으므로 빈 페이지로 시작해야 한다.
---
## 3. Goals
- 메인 페이지 하단 내비게이션을 `홈`, `콘텐츠`, `채팅`, `마이` 4개로 구성한다.
- 신규 메인 컨테이너는 기존 `MainActivity`를 수정해 덧씌우지 않고 `kr.co.vividnext.sodalive.v2` 하위 신규 Activity로 개발한다.
- 하단 내비게이션은 커스텀 탭바가 아니라 Material `BottomNavigationView`를 사용한다.
- 탭 선택 시 해당 탭에 대응하는 Fragment 페이지를 표시한다.
- `마이` 탭은 기존 `MyPageFragment`를 그대로 표시한다.
- `홈`, `콘텐츠`, `채팅` 탭은 신규 빈 페이지 Fragment를 만들어 표시한다.
- 탭 아이콘은 tint와 선택 상태용 파란 아이콘 리소스를 적용하지 않고 원본 아이콘 리소스를 그대로 사용한다.
- 하단 내비게이션 아이콘/타이틀 정렬은 가로 가운데, 세로 가운데를 우선 적용하고 타이틀은 아이콘 하단에 위치하도록 한다.
---
## 4. Non-Goals
- `홈`, `콘텐츠`, `채팅` 탭의 실제 콘텐츠/데이터/API 기능은 구현하지 않는다.
- 기존 `MyPageFragment` 내부 UI와 비즈니스 로직은 수정하지 않는다.
- 기존 `MainActivity`에 신규 `BottomNavigationView` 구조를 덧씌우지 않는다.
- 메인 페이지 외 다른 화면의 탭 구조를 일괄 변경하지 않는다.
- 탭별 back stack은 구현하지 않는다.
- Navigation Component 도입은 이번 범위에 포함하지 않는다.
- 누락된 `ic_nav_my_selected` 원본 리소스를 새로 제작하지 않는다.
- 기존 `MainActivity` 전체를 리팩터링하지 않는다. 신규 Activity에 필요한 인증, 이벤트 팝업, 미니 플레이어 등 필수 흐름만 선별 이관한다.
---
## 5. Target Users
- 앱 메인 화면에서 하단 탭으로 주요 섹션을 이동하는 일반 사용자.
- 메인 탭 구조를 기준으로 후속 홈/콘텐츠/채팅 기능을 개발할 Android 개발자.
---
## 6. User Stories
- 사용자는 메인 화면 하단에서 `홈`, `콘텐츠`, `채팅`, `마이` 탭을 볼 수 있다.
- 사용자는 각 탭을 눌러 해당 페이지로 이동할 수 있다.
- 사용자는 `마이` 탭에서 기존 마이페이지 기능을 그대로 사용할 수 있다.
- 개발자는 아직 기능이 정해지지 않은 `홈`, `콘텐츠`, `채팅` 탭을 빈 페이지로 구분해 후속 개발의 시작점으로 사용할 수 있다.
---
## 7. Core Features
### Feature A: 4개 하단 내비게이션 탭
신규 메인 Activity의 하단 내비게이션을 `홈`, `콘텐츠`, `채팅`, `마이` 순서로 구성한다.
#### Requirements
- 탭 순서는 왼쪽부터 `홈`, `콘텐츠`, `채팅`, `마이`이다.
- 하단 내비게이션 UI는 Material `BottomNavigationView`와 menu 리소스로 구성한다.
- 탭 라벨은 기존 문자열 리소스가 있으면 재사용하고, 없는 경우 새 문자열 리소스를 추가한다.
- 현재 확인된 기존 문자열은 `tab_home`, `tab_chat`, `tab_my`이다.
- `콘텐츠` 탭 라벨은 신규 문자열 리소스 추가가 필요하다.
#### Edge Cases
- 기존 `LIVE` 탭에 연결된 딥링크/진입 흐름은 신규 메인 Activity에서 그대로 탭으로 노출하지 않는다.
- 신규 메인 탭 상태는 기존 `MainViewModel.CurrentTab`을 수정하지 않고 v2 전용 탭 모델로 분리한다.
### Feature B: 탭별 Fragment 표시
탭 선택 시 신규 메인 Activity의 Fragment 컨테이너에 대응 Fragment를 표시한다.
#### Requirements
- `마이` 탭은 `app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt`를 표시한다.
- `홈`, `콘텐츠`, `채팅` 탭은 신규 빈 Fragment를 표시한다.
- 신규 `Activity`, `Fragment`, `ViewModel` 및 연결 하위 코드는 저장소 규칙에 따라 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
- 빈 페이지는 화면 영역을 차지하되, 별도 데이터 로딩이나 네트워크 호출을 하지 않는다.
- 탭별 back stack은 만들지 않는다.
#### Edge Cases
- 신규 빈 Fragment는 `BaseFragment` 패턴과 ViewBinding 사용 여부를 기존 코드 스타일에 맞춘다.
- `MyPageFragment`는 기존 패키지에 있으므로 재사용만 하고 이동하지 않는다.
### Feature C: 신규 메인 Activity와 기존 필수 프로세스 이관
신규 메인 Activity는 기존 `MainActivity`를 덮어쓰지 않고 별도 진입점으로 구성하며, 사용자 경험 유지에 필요한 필수 프로세스만 이관한다.
#### Requirements
- 신규 Activity는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
- 기존 인증/로그인 진입, 권한 요청, 이벤트 팝업, 미니 플레이어 표시/해제 흐름은 신규 Activity에서도 현재 사용자 경험과 동일하게 동작해야 한다.
- 미니 플레이어는 `BottomNavigationView` 위에 배치해 하단 내비게이션과 겹치지 않도록 한다.
- 기존 `MainActivity`의 모든 레거시 메서드를 무조건 복사하지 않고 신규 메인 화면에 필요한 흐름만 이관한다.
- 스플래시, 로그인/회원가입 완료, 딥링크, 플레이어 알림 등 기존 `MainActivity`로 이동하던 주요 진입점은 신규 Activity로 전환할지 구현 계획에서 명시적으로 검토한다.
#### Edge Cases
- 딥링크는 향후 정리 계획이 있으므로 신규 Activity에서 탭 라우팅 진입점을 분리해 확장 가능하게 둔다.
- 채팅 배지는 향후 `BottomNavigationView`의 badge API로 확장할 수 있도록 menu item id를 안정적으로 정의한다.
### Feature D: 탭 아이콘 색상 유지
탭 선택/미선택 상태와 무관하게 아이콘 리소스 원본 색상을 그대로 표시한다.
#### Requirements
- `홈`: `ic_nav_home`
- `콘텐츠`: `ic_nav_content`
- `채팅`: `ic_nav_chat`
- `마이`: `ic_nav_my`
- `BottomNavigationView``itemIconTint``null`로 두어 아이콘 tint를 적용하지 않는다.
- checked 상태에서도 선택용 파란 아이콘 리소스를 사용하지 않는다.
#### Edge Cases
- 라벨 선택 상태는 텍스트 색상으로만 구분한다.
---
## 8. UX / UI Expectations
- 하단 내비게이션은 화면 하단에 고정된다.
- 각 탭은 동일한 가로 영역을 가진다.
- 아이콘과 타이틀은 가로 가운데 정렬한다.
- 세로 정렬은 가운데 정렬을 우선으로 하고, 타이틀은 아이콘 하단에 배치한다.
- 선택된 탭은 선택 아이콘과 선택 텍스트 스타일로 구분한다.
- 미선택 탭은 미선택 아이콘과 미선택 텍스트 스타일로 구분한다.
- 하단 내비게이션 배경은 `black`을 사용한다.
- 하단 내비게이션 아이콘은 tint로 색을 변경하지 않고 아이콘 리소스 원본 색상을 그대로 사용한다.
- checked 상태에서 `#3BB9F1` 계열 선택 아이콘으로 바뀌지 않아야 한다.
- 하단 내비게이션 라벨 색상은 미선택 `gray_600`, 선택 `white`를 사용한다.
- 하단 내비게이션 라벨 typography는 `Typography.Caption3`을 사용한다.
- 하단 내비게이션의 과도한 내부 padding은 `BottomNavigationView` item padding 속성으로 줄인다.
- edge-to-edge로 인해 하단 내비게이션 아래에 생기는 system bar inset 빈 공간은 MainV2 화면 범위에서만 제거한다.
- 향후 채팅 배지와 Material UX 정리를 고려해 `BottomNavigationView`의 표준 item 상태, ripple, badge 확장성을 우선한다.
- 빈 페이지는 기능이 비어 있음을 개발자가 확인할 수 있는 최소 UI로 유지한다.
---
## 9. Technical Constraints
- Android XML 레이아웃과 Kotlin Fragment 기반 구현을 따른다.
- 신규 메인 화면은 기존 `MainActivity`, `activity_main.xml`, `item_main_tab.xml`에 덧씌우지 않는다.
- 신규 Activity, ViewModel, Fragment 코드는 `kr.co.vividnext.sodalive.v2` 하위에 둔다.
- 하단 내비게이션은 Material `BottomNavigationView`를 사용한다.
- 탭 전환은 탭별 back stack 없이 단일 Fragment 컨테이너에서 처리한다.
- 기존 `MyPageFragment``kr.co.vividnext.sodalive.mypage` 패키지의 기존 파일을 재사용한다.
- 이번 문서 작성 단계에서는 구현 파일을 변경하지 않는다.
---
## 10. Metrics
- 하단 내비게이션 탭이 `홈`, `콘텐츠`, `채팅`, `마이` 4개로 표시된다.
- 신규 메인 화면이 `kr.co.vividnext.sodalive.v2` 하위 신규 Activity로 구성된다.
- 하단 내비게이션이 `BottomNavigationView`로 구성된다.
- 각 탭 선택 시 대응 Fragment가 표시된다.
- `마이` 탭 선택 시 기존 `MyPageFragment`가 표시된다.
- `홈`, `콘텐츠`, `채팅` 탭 선택 시 각각 신규 빈 페이지가 표시된다.
- 인증, 이벤트 팝업, 미니 플레이어 흐름이 신규 Activity에서도 기존 사용자 경험과 동일하게 동작한다.
- 요청된 선택/미선택 아이콘 리소스가 탭 상태에 맞게 연결된다.
- 디버그 빌드가 성공한다.
---
## 11. Open Questions
- 없음. 현재 구현 범위는 요청된 4탭 구성과 빈 페이지 표시로 한정한다.