From 99b7a6ce9919edd2ebd6bcd5fb9144ae80d52058 Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 19 May 2026 15:55:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(main-v2):=20=EB=A9=94=EC=9D=B8=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=20=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 + .../audio_content/AudioContentPlayService.kt | 4 +- .../player/AudioContentPlayerService.kt | 4 +- .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 2 + .../LiveReservationCompleteActivity.kt | 4 +- .../sodalive/main/DeepLinkActivity.kt | 5 +- .../sodalive/mypage/MyPageFragment.kt | 13 +- .../mypage/can/status/CanStatusActivity.kt | 4 +- .../mypage/point/PointStatusActivity.kt | 4 +- .../sodalive/splash/SplashActivity.kt | 4 +- .../sodalive/user/login/LoginActivity.kt | 5 +- .../sodalive/user/signup/SignUpActivity.kt | 4 +- .../sodalive/v2/main/ChatMainFragment.kt | 8 + .../sodalive/v2/main/ContentMainFragment.kt | 8 + .../sodalive/v2/main/HomeMainFragment.kt | 8 + .../sodalive/v2/main/MainV2Activity.kt | 654 ++++++++++++++++++ .../vividnext/sodalive/v2/main/MainV2Tab.kt | 8 + .../sodalive/v2/main/MainV2ViewModel.kt | 231 +++++++ .../color_main_v2_bottom_navigation_label.xml | 6 + .../main/res/drawable-mdpi/ic_nav_chat.png | Bin 0 -> 602 bytes .../drawable-mdpi/ic_nav_chat_selected.png | Bin 0 -> 446 bytes .../main/res/drawable-mdpi/ic_nav_content.png | Bin 0 -> 537 bytes .../drawable-mdpi/ic_nav_content_selected.png | Bin 0 -> 378 bytes .../main/res/drawable-mdpi/ic_nav_home.png | Bin 0 -> 564 bytes .../drawable-mdpi/ic_nav_home_selected.png | Bin 0 -> 408 bytes app/src/main/res/drawable-mdpi/ic_nav_my.png | Bin 0 -> 723 bytes app/src/main/res/drawable/ic_nav_chat_tab.xml | 5 + .../main/res/drawable/ic_nav_content_tab.xml | 5 + app/src/main/res/drawable/ic_nav_home_tab.xml | 5 + app/src/main/res/drawable/ic_nav_my_tab.xml | 5 + app/src/main/res/layout/activity_main_v2.xml | 108 +++ .../layout/fragment_event_popup_dialog.xml | 1 + .../main/res/layout/fragment_v2_main_chat.xml | 5 + .../res/layout/fragment_v2_main_content.xml | 5 + .../main/res/layout/fragment_v2_main_home.xml | 5 + .../menu/menu_main_v2_bottom_navigation.xml | 19 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + ...260519_메인페이지하단내비게이션신규개발.md | 367 ++++++++++ ...19_메인페이지하단내비게이션신규개발_prd.md | 158 +++++ 41 files changed, 1646 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/ChatMainFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/ContentMainFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Tab.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt create mode 100644 app/src/main/res/color/color_main_v2_bottom_navigation_label.xml create mode 100644 app/src/main/res/drawable-mdpi/ic_nav_chat.png create mode 100644 app/src/main/res/drawable-mdpi/ic_nav_chat_selected.png create mode 100644 app/src/main/res/drawable-mdpi/ic_nav_content.png create mode 100644 app/src/main/res/drawable-mdpi/ic_nav_content_selected.png create mode 100644 app/src/main/res/drawable-mdpi/ic_nav_home.png create mode 100644 app/src/main/res/drawable-mdpi/ic_nav_home_selected.png create mode 100644 app/src/main/res/drawable-mdpi/ic_nav_my.png create mode 100644 app/src/main/res/drawable/ic_nav_chat_tab.xml create mode 100644 app/src/main/res/drawable/ic_nav_content_tab.xml create mode 100644 app/src/main/res/drawable/ic_nav_home_tab.xml create mode 100644 app/src/main/res/drawable/ic_nav_my_tab.xml create mode 100644 app/src/main/res/layout/activity_main_v2.xml create mode 100644 app/src/main/res/layout/fragment_v2_main_chat.xml create mode 100644 app/src/main/res/layout/fragment_v2_main_content.xml create mode 100644 app/src/main/res/layout/fragment_v2_main_home.xml create mode 100644 app/src/main/res/menu/menu_main_v2_bottom_navigation.xml create mode 100644 docs/plan-task/20260519_메인페이지하단내비게이션신규개발.md create mode 100644 docs/prd/20260519_메인페이지하단내비게이션신규개발_prd.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 81388ca0..ee562124 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -111,6 +111,7 @@ + diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt index fb9693e9..7736be05 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt @@ -22,7 +22,7 @@ import com.bumptech.glide.request.transition.Transition import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.common.Constants 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 class AudioContentPlayService : @@ -471,7 +471,7 @@ class AudioContentPlayService : } 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_SINGLE_TOP) val pendingIntent = PendingIntent.getActivity( diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt index 56a8e889..58e11185 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt @@ -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.SharedPreferenceManager 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 @UnstableApi @@ -153,7 +153,7 @@ class AudioContentPlayerService : MediaSessionService() { } 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 } val pendingIntent = PendingIntent.getActivity( 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 ab246359..c91d62e8 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 @@ -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.login.LoginViewModel import kr.co.vividnext.sodalive.user.signup.SignUpViewModel +import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import org.koin.android.ext.koin.androidContext @@ -297,6 +298,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { TermsViewModel(get()) } viewModel { FindPasswordViewModel(get()) } viewModel { MainViewModel(get(), get(), get(), get(), get()) } + viewModel { MainV2ViewModel(get(), get(), get(), get(), get()) } viewModel { LiveViewModel(get(), get(), get(), get(), get()) } viewModel { MyPageViewModel(get(), get(), get()) } viewModel { CanStatusViewModel(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/complete/LiveReservationCompleteActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/complete/LiveReservationCompleteActivity.kt index bfd8bad1..7e1dc87a 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/complete/LiveReservationCompleteActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/reservation/complete/LiveReservationCompleteActivity.kt @@ -9,8 +9,8 @@ import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.databinding.ActivityLiveReservationCompleteBinding import kr.co.vividnext.sodalive.extensions.convertDateFormat 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.v2.main.MainV2Activity import java.util.Locale import java.util.TimeZone @@ -52,7 +52,7 @@ class LiveReservationCompleteActivity : BaseActivity(FragmentMyBinding::inflat binding.rlProfileContainer.visibility = View.GONE binding.llProfileLoginContainer.visibility = View.VISIBLE binding.llProfileLoginContainer.setOnClickListener { - (requireActivity() as MainActivity).showLoginActivity() + showLoginActivity() } binding.tvCanAmount.text = SodaLiveApplicationHolder.get().getString(R.string.common_zero) binding.tvCanAmount.setOnClickListener { - (requireActivity() as MainActivity).showLoginActivity() + showLoginActivity() } binding.tvPointAmount.text = SodaLiveApplicationHolder.get().getString(R.string.common_zero) binding.tvPointAmount.setOnClickListener { - (requireActivity() as MainActivity).showLoginActivity() + showLoginActivity() } binding.tvChargeCan.visibility = View.INVISIBLE @@ -499,4 +500,10 @@ class MyPageFragment : BaseFragment(FragmentMyBinding::inflat } } + private fun showLoginActivity() { + when (val activity = requireActivity()) { + is MainActivity -> activity.showLoginActivity() + is MainV2Activity -> activity.showLoginActivity() + } + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/status/CanStatusActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/status/CanStatusActivity.kt index 2501bb61..942f4e51 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/status/CanStatusActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/status/CanStatusActivity.kt @@ -11,10 +11,10 @@ import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.databinding.ActivityCanStatusBinding 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.status.charge.CanChargeStatusFragment 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 class CanStatusActivity : BaseActivity( @@ -137,7 +137,7 @@ class CanStatusActivity : BaseActivity( } 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_SINGLE_TOP) startActivity(intent) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/point/PointStatusActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/point/PointStatusActivity.kt index ecc7c430..c77421ad 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/point/PointStatusActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/point/PointStatusActivity.kt @@ -11,9 +11,9 @@ import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.databinding.ActivityPointStatusBinding 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.use.PointUseStatusFragment +import kr.co.vividnext.sodalive.v2.main.MainV2Activity import org.koin.android.ext.android.inject class PointStatusActivity : BaseActivity( @@ -120,7 +120,7 @@ class PointStatusActivity : BaseActivity( } 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_SINGLE_TOP) startActivity(intent) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/splash/SplashActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/splash/SplashActivity.kt index 9c6c9d5e..cc5e6243 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/splash/SplashActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/splash/SplashActivity.kt @@ -20,7 +20,7 @@ import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.SodaDialog import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.databinding.ActivitySplashBinding -import kr.co.vividnext.sodalive.main.MainActivity +import kr.co.vividnext.sodalive.v2.main.MainV2Activity @SuppressLint("CustomSplashScreen") class SplashActivity : BaseActivity(ActivitySplashBinding::inflate) { @@ -174,7 +174,7 @@ class SplashActivity : BaseActivity(ActivitySplashBinding private fun showMainActivity(extras: Bundle?) { handler.postDelayed({ startActivity( - Intent(applicationContext, MainActivity::class.java).apply { + Intent(applicationContext, MainV2Activity::class.java).apply { putExtra(Constants.EXTRA_DATA, extras) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/login/LoginActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/login/LoginActivity.kt index edc4cd95..3a78a027 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/user/login/LoginActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/user/login/LoginActivity.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.user.login import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle 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.LoadingDialog 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.signup.SignUpActivity +import kr.co.vividnext.sodalive.v2.main.MainV2Activity import org.koin.android.ext.android.inject import java.util.UUID import androidx.core.net.toUri @@ -434,7 +433,7 @@ class LoginActivity : BaseActivity(ActivityLoginBinding::i } 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) ?: if (intent.extras != null) { intent.extras diff --git a/app/src/main/java/kr/co/vividnext/sodalive/user/signup/SignUpActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/user/signup/SignUpActivity.kt index a739238b..6520a9f2 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/user/signup/SignUpActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/user/signup/SignUpActivity.kt @@ -17,8 +17,8 @@ import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.LoadingDialog 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.v2.main.MainV2Activity import org.koin.android.ext.android.inject @OptIn(UnstableApi::class) @@ -152,7 +152,7 @@ class SignUpActivity : BaseActivity(ActivitySignupBinding } 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) ?: if (intent.extras != null) { intent.extras diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/ChatMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/ChatMainFragment.kt new file mode 100644 index 00000000..c94893e4 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/ChatMainFragment.kt @@ -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::inflate +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/ContentMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/ContentMainFragment.kt new file mode 100644 index 00000000..ae5e570e --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/ContentMainFragment.kt @@ -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::inflate +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt new file mode 100644 index 00000000..ee69bde5 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt @@ -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::inflate +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt new file mode 100644 index 00000000..04729b5f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt @@ -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::inflate) { + + private val viewModel: MainV2ViewModel by inject() + + private lateinit var notificationSettingsDialog: NotificationSettingsDialog + private var mediaController: MediaController? = null + private var mediaControllerFuture: ListenableFuture? = 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?) { + } + }) + .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 + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Tab.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Tab.kt new file mode 100644 index 00000000..9b62e372 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Tab.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.main + +enum class MainV2Tab { + HOME, + CONTENT, + CHAT, + MY +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt new file mode 100644 index 00000000..2b8049e8 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt @@ -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 + 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({}, {}) + ) + } +} diff --git a/app/src/main/res/color/color_main_v2_bottom_navigation_label.xml b/app/src/main/res/color/color_main_v2_bottom_navigation_label.xml new file mode 100644 index 00000000..fc672b45 --- /dev/null +++ b/app/src/main/res/color/color_main_v2_bottom_navigation_label.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable-mdpi/ic_nav_chat.png b/app/src/main/res/drawable-mdpi/ic_nav_chat.png new file mode 100644 index 0000000000000000000000000000000000000000..4e2afd3430b75c2872b3e16464264d75700434f5 GIT binary patch literal 602 zcmV-g0;T%U&Fs#Mi5|ED?b?P~t!5gA z@wVITa$y)|HBHMx9q795Z@1e?r_%`%5mc+y91Qm{E%G;v`)pVckkkVn);=x~oh7K( z>!0x06`s1Q)oSFr?o!=jSynckPM0ukV3-(AyWMVHC9rLq4>%YD9I)sH;2Ir7);LFE zM2wdqT!h?2$Tr=G`F!pb3I%XzPClPs;QLFi>5;wc#Sq3FiV1PX! ziLp2#0LIL&K2Th6^)mrF!1RPbd7uU*1PpUw2CY_WB6ZO>m|?t90*DUq4hPGsK1#sG zJ&FPDNvTwdjays?XcZ%M{n(56?!i7qn5E5T^9}jJEgCuxau)5DY&et2ykm{8#e}SM zyInWxfTn~vI51um oDf}$9a~k^V^?Dp_;}68f51Q@K0rxjhD*ylh07*qoM6N<$f_2pRcK`qY literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_nav_chat_selected.png b/app/src/main/res/drawable-mdpi/ic_nav_chat_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..6a9f13fc1b892200985e9ee68accf2ce2bd552d7 GIT binary patch literal 446 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(Kz7p4nlHmNblJdl&R0g-q^xVXG8>b}$by<44 zIEGX(z75Ttc*sED?!7q;7CS6-IoI2%l~^of zFJ#?)-LOi6)y&v-Vq*f!^{ASPbg3}S!+}kiCk|)_w(`h0eD=vMJJ7UzLSu|xrq@#S zErBbPxn-rqGCXFBEvZ$Ru<`7o%TIh8uF3muoW$|r-lTHzv!DInSD!IW*V`&)wP>sI zKZ9wX+mgCn>%G3-2uRb?op{cQFE4Sv1yu(3`L9_yHFrY zE(QPCw(|G;y^;IAM$WxBgZw7{!t4ye3bVDYr}#V^4z0|Ac%k5AGWk9# zB%j4iMB8s9R(IhN_^aTNMK{lCgxU@W84$rjQ67wq^5n#wE*hh1)oL|Ilmzd@ZnulX b4cCc(7xKS)OC55`00000NkvXXu0mjfK0W1j literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_nav_content_selected.png b/app/src/main/res/drawable-mdpi/ic_nav_content_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..d911ac0be7d52b73187501379941df759f958d00 GIT binary patch literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(Kz7p4nlHmNblJdl&R0g-q^xVXG8>b}$Wxjg4 zIEGX(z74r3*kr(yJ9k0e1l|%Bvj*M-=G~3TC)jp*-fa*}U@mByHSeL`^woR6HOoJ} z`&9XD^;vJO(5&P2hpO`pW---?n(X|eP#{svSkE^r=F`>sfR#Q0?2KmZHc59P>yBk8 z@8M&PxEHLhR$Afg*HE;eN$*hUmh>4D8}vdIc1W~%pP#$QR^fP9$^GJcjxYQqxKC&I zEWcK@h*|G-;})}tX|5*}LlXplayOq3asS~ym0RlKi6Zah1`hFz8r^8=Cml;x2v5&3 z(vx&KXcc;3!sFQmzup?#ZvJy{x4-Dd+5a+B3TE4isu;A&n(TYB@%G2)3g(0wAFs;= SXRiVSfWgz%&t;ucLK6TOtd8mc literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_nav_home.png b/app/src/main/res/drawable-mdpi/ic_nav_home.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc02eab242623978b6f4e2be1b73dceb9df4c74 GIT binary patch literal 564 zcmV-40?Yl0P)3R+2H8U7AP48-8x^>{ zqyrBw?m!4SY&M%um;n*Z8@q>zCOn-^)A`0ZBe5JD4hIgCOi!?lVKU(8^!y|ed)*1* z2rLmr29gXuAE9SNXp_Fv9)>Uz6n$t01iLgkO9)Q}D2K=r0@>X|HqJf4bwP*v#6*9w z-=T>E2_mEEe|qBi7gZ`18I5fv$Yf=|wkf+J4t@aR*3a)h!IJR+0000b}$bqRaA zIEGX(z72WH+vLFWR`UO8wq1>V2RQN;NWWlvz_O{)g3+QeXMuEsu!8V|>=h?jpFaNa zbzoBiSWE6Vw3f4?~SCs zg?a}LuAP<=9B4C5!uVvt0YS+oD@hkS|0LNgX3tXvD^)GRe!GRmK8Rz=HDWn^qr%Xl zcAn5Hj+q+*wx7+2*mL0M4Awe>s>aH>!c}q~9<1D3r=kQW3GR+lzDza$BgplOhGCK4=?Zgp&mG&cPE?i-GFxo*!N4h zr?iQR8qF(NtmS_4*^{HGw>+JWZgDj^$Rhvf-N*e6ZgTe~DWM4fQr)3Z literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_nav_my.png b/app/src/main/res/drawable-mdpi/ic_nav_my.png new file mode 100644 index 0000000000000000000000000000000000000000..30eefebdfd055b1d9e2666940a7a2fd83e183239 GIT binary patch literal 723 zcmV;^0xbQBP)jB5S7GML2;5JutE?Ktcbu%1d<~_7k~Mmv8Q>uAXW2^y}{FX<62HC%eobj$^l0 ztNp^#Kb=mu=R>yr0{(YA9{)}zlk`gf;2&-` zrLv+UY@hb~{U1=K+w^)p4UyG(l|B z7!f^#v+Z`<)T8eA`w_%XW(Dx|Xf#@?Z!BlETID%+x7#)JbIt{tZ9h;la>Mx?Vh-m% zoj@)`cP6oI5gb6V?$a?+4Pms@%Oa6SQm)2$JZ^GL-C!^Xm7Sb<0=Jn_4giq_vRvp9 z0PqeXnNFu`rQ;g<4LHzk*$m83+WHbe(%+bu3f-%;sb%OhoVn3;QYxPJa{!=Q9LTIZ zB?KQ1hj&WR?RFVqfXiN8u}@*|sm%|ogiv!{Xn;OnLnd;`jBu%UQW5DHynwv|kO<}y+m-lMi2j%K7OE%?N+bXL$sxD zX2)#!;Pio~#s+n{3?u?NZi5>zaPTr&F@JsiSt z#mkgdY!=NUHHR38*fO(6-KKEZNw?5>VtqgW2^cv`Ryyo-5Jn~2g*iS*#{VMw^tjd{ z^JMs)FOIKVdx#!c_E-NGr2qy{U>Uzmhz&dMt1bO~=nYhHeQu;eHKzao002ovPDHLk FV1oTvJ3RmZ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_nav_chat_tab.xml b/app/src/main/res/drawable/ic_nav_chat_tab.xml new file mode 100644 index 00000000..06603087 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_chat_tab.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_nav_content_tab.xml b/app/src/main/res/drawable/ic_nav_content_tab.xml new file mode 100644 index 00000000..bedc41d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_content_tab.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_nav_home_tab.xml b/app/src/main/res/drawable/ic_nav_home_tab.xml new file mode 100644 index 00000000..bb3f0c17 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_home_tab.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_nav_my_tab.xml b/app/src/main/res/drawable/ic_nav_my_tab.xml new file mode 100644 index 00000000..34d40264 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_my_tab.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main_v2.xml b/app/src/main/res/layout/activity_main_v2.xml new file mode 100644 index 00000000..54be8ceb --- /dev/null +++ b/app/src/main/res/layout/activity_main_v2.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_event_popup_dialog.xml b/app/src/main/res/layout/fragment_event_popup_dialog.xml index 3c4436c8..cf5d3fde 100644 --- a/app/src/main/res/layout/fragment_event_popup_dialog.xml +++ b/app/src/main/res/layout/fragment_event_popup_dialog.xml @@ -2,6 +2,7 @@ + diff --git a/app/src/main/res/layout/fragment_v2_main_content.xml b/app/src/main/res/layout/fragment_v2_main_content.xml new file mode 100644 index 00000000..7c7115b6 --- /dev/null +++ b/app/src/main/res/layout/fragment_v2_main_content.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/fragment_v2_main_home.xml b/app/src/main/res/layout/fragment_v2_main_home.xml new file mode 100644 index 00000000..7c7115b6 --- /dev/null +++ b/app/src/main/res/layout/fragment_v2_main_home.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/menu/menu_main_v2_bottom_navigation.xml b/app/src/main/res/menu/menu_main_v2_bottom_navigation.xml new file mode 100644 index 00000000..e35501b9 --- /dev/null +++ b/app/src/main/res/menu/menu_main_v2_bottom_navigation.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d7735ab5..8fd00fe5 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -137,6 +137,7 @@ Home + Content Chat Live My diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index a08ff34b..526a37d7 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -137,6 +137,7 @@ ホーム + コンテンツ チャット ライブ マイ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f06c16b6..10fed5dc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -136,6 +136,7 @@ + 콘텐츠 채팅 라이브 마이 diff --git a/docs/plan-task/20260519_메인페이지하단내비게이션신규개발.md b/docs/plan-task/20260519_메인페이지하단내비게이션신규개발.md new file mode 100644 index 00000000..c968ddb7 --- /dev/null +++ b/docs/plan-task/20260519_메인페이지하단내비게이션신규개발.md @@ -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 +콘텐츠 +``` + +- [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`로 완료됐다. diff --git a/docs/prd/20260519_메인페이지하단내비게이션신규개발_prd.md b/docs/prd/20260519_메인페이지하단내비게이션신규개발_prd.md new file mode 100644 index 00000000..38fc0ff5 --- /dev/null +++ b/docs/prd/20260519_메인페이지하단내비게이션신규개발_prd.md @@ -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탭 구성과 빈 페이지 표시로 한정한다.