From 7ed5e921bd72afc8621dc00fa39bb00404442203 Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 25 Jul 2025 21:33:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20-=20=EC=B5=9C=EA=B7=BC=20=EB=93=A4=EC=9D=80=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/AudioContentDetailActivity.kt | 14 +++- .../player/AudioContentPlayerFragment.kt | 18 ++++- .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 3 + .../sodalive/mypage/MyPageFragment.kt | 81 +++++++++++++++++-- .../mypage/recent/RecentContentAdapter.kt | 64 +++++++++++++++ .../mypage/recent/RecentContentModule.kt | 20 +++++ .../mypage/recent/RecentContentRepository.kt | 27 +++++++ .../mypage/recent/RecentContentViewModel.kt | 28 +++++++ .../mypage/recent/db/RecentContent.kt | 14 ++++ .../mypage/recent/db/RecentContentDao.kt | 28 +++++++ .../mypage/recent/db/RecentContentDatabase.kt | 31 +++++++ .../sodalive/settings/SettingsActivity.kt | 10 ++- app/src/main/res/layout/fragment_my.xml | 4 +- 13 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentModule.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentRepository.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContent.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDao.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDatabase.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt index 4bc59a6c..89e4a92d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt @@ -1,8 +1,6 @@ package kr.co.vividnext.sodalive.audio_content.detail import android.annotation.SuppressLint -import android.app.Activity -import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -56,6 +54,8 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentTempActivity +import kr.co.vividnext.sodalive.mypage.recent.RecentContentViewModel +import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent import kr.co.vividnext.sodalive.report.ReportType import org.koin.android.ext.android.inject import kotlin.math.ceil @@ -65,6 +65,7 @@ class AudioContentDetailActivity : BaseActivity + val recentContent = RecentContent( + contentId = id, + coverImageUrl = it.artworkUri.toString(), + title = it.title.toString(), + creatorNickname = it.artist.toString() + ) + recentContentViewModel.insertRecentContent(recentContent) + } } } 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 8cc92ad4..8d2a30e3 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 @@ -137,6 +137,7 @@ import kr.co.vividnext.sodalive.mypage.profile.nickname.NicknameUpdateViewModel import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagApi import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagRepository import kr.co.vividnext.sodalive.mypage.profile.tag.MemberTagViewModel +import kr.co.vividnext.sodalive.mypage.recent.recentContentModule import kr.co.vividnext.sodalive.mypage.service_center.FaqApi import kr.co.vividnext.sodalive.mypage.service_center.FaqRepository import kr.co.vividnext.sodalive.mypage.service_center.ServiceCenterViewModel @@ -387,10 +388,12 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { factory { HomeRepository(get()) } } + private val moduleList = listOf( networkModule, viewModelModule, repositoryModule, + recentContentModule, otherModule ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt index 2bb862dd..f24e8a8c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt @@ -2,23 +2,28 @@ package kr.co.vividnext.sodalive.mypage import android.annotation.SuppressLint import android.content.Intent +import android.graphics.Rect import android.net.Uri import android.os.Bundle import android.view.View import android.webkit.URLUtil -import android.widget.LinearLayout import android.widget.Toast import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import coil.load import coil.transform.CircleCropTransformation import com.google.gson.Gson import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.audio_content.box.AudioContentBoxActivity +import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.base.BaseFragment +import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.FunctionButtonHelper import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.databinding.FragmentMyBinding +import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.main.MainActivity import kr.co.vividnext.sodalive.mypage.alarm.AlarmListActivity @@ -31,6 +36,8 @@ import kr.co.vividnext.sodalive.mypage.can.coupon.CanCouponActivity import kr.co.vividnext.sodalive.mypage.can.status.CanStatusActivity import kr.co.vividnext.sodalive.mypage.point.PointStatusActivity import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateActivity +import kr.co.vividnext.sodalive.mypage.recent.RecentContentAdapter +import kr.co.vividnext.sodalive.mypage.recent.RecentContentViewModel import kr.co.vividnext.sodalive.mypage.service_center.ServiceCenterActivity import kr.co.vividnext.sodalive.settings.SettingsActivity import kr.co.vividnext.sodalive.settings.event.EventActivity @@ -42,6 +49,7 @@ import org.koin.android.ext.android.inject class MyPageFragment : BaseFragment(FragmentMyBinding::inflate) { private val viewModel: MyPageViewModel by inject() + private val recentContentViewModel: RecentContentViewModel by inject() private lateinit var loadingDialog: LoadingDialog @@ -51,6 +59,73 @@ class MyPageFragment : BaseFragment(FragmentMyBinding::inflat setupView() bindData() + setupRecentContentSection() + } + + private fun setupRecentContentSection() { + val adapter = RecentContentAdapter { + startActivity( + Intent( + requireContext(), + AudioContentDetailActivity::class.java + ).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it) + } + ) + } + + val rvRecentContent = binding.rvRecentContent + + rvRecentContent.layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.HORIZONTAL, + false + ) + + rvRecentContent.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + when (parent.getChildAdapterPosition(view)) { + 0 -> { + outRect.left = 0 + outRect.right = 8f.dpToPx().toInt() + } + + adapter.itemCount - 1 -> { + outRect.left = 8f.dpToPx().toInt() + outRect.right = 0 + } + + else -> { + outRect.left = 8f.dpToPx().toInt() + outRect.right = 8f.dpToPx().toInt() + } + } + } + }) + + rvRecentContent.adapter = adapter + + // Observe recent contents + recentContentViewModel.getRecentContents(10).observe(viewLifecycleOwner) { contents -> + if (contents.isNotEmpty()) { + binding.llRecentContent.visibility = View.VISIBLE + adapter.submitList(contents) + } else { + binding.llRecentContent.visibility = View.GONE + } + } + + // Observe count + recentContentViewModel.recentContentsCount.observe(viewLifecycleOwner) { count -> + binding.tvRecentCount.text = count.toString() + } } override fun onStart() { @@ -62,10 +137,6 @@ class MyPageFragment : BaseFragment(FragmentMyBinding::inflat } private fun setupView() { -// val ivHowToUseLp = binding.ivIntroduceVoiceon.layoutParams as LinearLayout.LayoutParams -// ivHowToUseLp.width = screenWidth -// ivHowToUseLp.height = (120 * screenWidth) / 352 -// binding.ivIntroduceVoiceon.layoutParams = ivHowToUseLp binding.ivIntroduceVoiceon.setOnClickListener { val url = "https://blog.naver.com/sodalive_official" if (URLUtil.isValidUrl(url)) { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentAdapter.kt new file mode 100644 index 00000000..8a4c8fa2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentAdapter.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.mypage.recent + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.load +import coil.transform.RoundedCornersTransformation +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemHomeContentBinding +import kr.co.vividnext.sodalive.extensions.dpToPx +import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent + +class RecentContentAdapter( + private val onClickItem: (Long) -> Unit +) : ListAdapter( + RecentContentDiffCallback() +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecentContentViewHolder { + val binding = ItemHomeContentBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return RecentContentViewHolder(binding) + } + + override fun onBindViewHolder(holder: RecentContentViewHolder, position: Int) { + val currentItem = getItem(position) + holder.bind(currentItem) + } + + inner class RecentContentViewHolder( + private val binding: ItemHomeContentBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: RecentContent) { + binding.ivPoint.visibility = View.GONE + + binding.ivContentCoverImage.load(item.coverImageUrl) { + crossfade(true) + placeholder(R.drawable.ic_place_holder) + transformations(RoundedCornersTransformation(16f.dpToPx())) + } + + binding.tvContentTitle.text = item.title + binding.tvNickname.text = item.creatorNickname + + binding.root.setOnClickListener { onClickItem(item.contentId) } + } + } + + class RecentContentDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RecentContent, newItem: RecentContent): Boolean { + return oldItem.contentId == newItem.contentId + } + + override fun areContentsTheSame(oldItem: RecentContent, newItem: RecentContent): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentModule.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentModule.kt new file mode 100644 index 00000000..38847891 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentModule.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.mypage.recent + +import kr.co.vividnext.sodalive.mypage.recent.db.RecentContentDatabase +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val recentContentModule = module { + // Database + single { RecentContentDatabase.getDatabase(androidContext()) } + + // DAO + single { get().recentContentDao() } + + // Repository + factory { RecentContentRepository(get()) } + + // ViewModel + viewModel { RecentContentViewModel(get()) } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentRepository.kt new file mode 100644 index 00000000..2a06c69c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentRepository.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.mypage.recent + +import kotlinx.coroutines.flow.Flow +import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent +import kr.co.vividnext.sodalive.mypage.recent.db.RecentContentDao + +class RecentContentRepository(private val recentContentDao: RecentContentDao) { + val recentContentsCount: Flow = recentContentDao.getCount() + + fun getRecentContents(limit: Int): Flow> { + return recentContentDao.getRecentContents(limit) + } + + suspend fun insertRecentContent(recentContent: RecentContent) { + recentContentDao.insertRecentContent(recentContent) + // Keep only the most recent 10 items + recentContentDao.keepMostRecent(10) + } + + suspend fun deleteByContentId(contentId: Long) { + recentContentDao.deleteByContentId(contentId) + } + + suspend fun truncate() { + recentContentDao.deleteAllRecentContents() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentViewModel.kt new file mode 100644 index 00000000..08fdcbba --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/RecentContentViewModel.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.mypage.recent + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent + +class RecentContentViewModel(private val repository: RecentContentRepository) : ViewModel() { + val recentContentsCount: LiveData = repository.recentContentsCount.asLiveData() + + fun getRecentContents(limit: Int): LiveData> { + return repository.getRecentContents(limit).asLiveData() + } + + fun insertRecentContent(recentContent: RecentContent) = viewModelScope.launch { + repository.insertRecentContent(recentContent) + } + + fun deleteByContentId(contentId: Long) = viewModelScope.launch { + repository.deleteByContentId(contentId) + } + + fun truncate() = viewModelScope.launch { + repository.truncate() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContent.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContent.kt new file mode 100644 index 00000000..c6624fbe --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContent.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.mypage.recent.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "recent_contents") +data class RecentContent( + @PrimaryKey + val contentId: Long, + val coverImageUrl: String, + val title: String, + val creatorNickname: String, + val listenedAt: Long = System.currentTimeMillis() // For sorting by most recent +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDao.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDao.kt new file mode 100644 index 00000000..d01126a2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDao.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.mypage.recent.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface RecentContentDao { + @Query("SELECT * FROM recent_contents ORDER BY listenedAt DESC LIMIT :limit") + fun getRecentContents(limit: Int): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRecentContent(recentContent: RecentContent) + + @Query("DELETE FROM recent_contents WHERE contentId = :contentId") + suspend fun deleteByContentId(contentId: Long) + + @Query("SELECT COUNT(*) FROM recent_contents") + fun getCount(): Flow + + @Query("DELETE FROM recent_contents WHERE contentId NOT IN (SELECT contentId FROM recent_contents ORDER BY listenedAt DESC LIMIT :limit)") + suspend fun keepMostRecent(limit: Int) + + @Query("DELETE FROM recent_contents") + suspend fun deleteAllRecentContents() +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDatabase.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDatabase.kt new file mode 100644 index 00000000..28e99d7b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/recent/db/RecentContentDatabase.kt @@ -0,0 +1,31 @@ +package kr.co.vividnext.sodalive.mypage.recent.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import kr.co.vividnext.sodalive.common.Converter + +@Database(entities = [RecentContent::class], version = 1) +@TypeConverters(Converter::class) +abstract class RecentContentDatabase : RoomDatabase() { + abstract fun recentContentDao(): RecentContentDao + + companion object { + @Volatile + private var INSTANCE: RecentContentDatabase? = null + + fun getDatabase(context: Context): RecentContentDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + RecentContentDatabase::class.java, + "recent_content_database" // Different name from alarm database + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt index 50600d1b..7fe190dc 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.databinding.ActivitySettingsBinding import kr.co.vividnext.sodalive.mypage.alarm.AlarmViewModel +import kr.co.vividnext.sodalive.mypage.recent.RecentContentViewModel import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsActivity import kr.co.vividnext.sodalive.settings.signout.SignOutActivity import kr.co.vividnext.sodalive.settings.terms.TermsActivity @@ -56,6 +57,7 @@ class SettingsActivity : BaseActivity(ActivitySettingsB private val viewModel: SettingsViewModel by inject() private val alarmViewModel: AlarmViewModel by viewModels() + private val recentContentViewModel: RecentContentViewModel by inject() private lateinit var loadingDialog: LoadingDialog @@ -158,8 +160,11 @@ class SettingsActivity : BaseActivity(ActivitySettingsB viewModel.logout { NotiflyClient.logout(context = applicationContext) + SharedPreferenceManager.clear() alarmViewModel.truncate() + recentContentViewModel.truncate() + finishAffinity() startActivity(Intent(applicationContext, SplashActivity::class.java)) } @@ -181,9 +186,12 @@ class SettingsActivity : BaseActivity(ActivitySettingsB viewModel.logoutAllDevice() { NotiflyClient.logout(context = applicationContext) + SharedPreferenceManager.clear() alarmViewModel.truncate() - finishAffinity() + recentContentViewModel.truncate() + + finishAffinity() startActivity(Intent(applicationContext, SplashActivity::class.java)) } } diff --git a/app/src/main/res/layout/fragment_my.xml b/app/src/main/res/layout/fragment_my.xml index b8f6ecca..71b0144b 100644 --- a/app/src/main/res/layout/fragment_my.xml +++ b/app/src/main/res/layout/fragment_my.xml @@ -385,9 +385,7 @@ + android:layout_height="wrap_content" />