feat: 마이페이지

- 최근 들은 콘텐츠 추가
This commit is contained in:
2025-07-25 21:33:49 +09:00
parent 39be49b481
commit 7ed5e921bd
13 changed files with 330 additions and 12 deletions

View File

@@ -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<ActivityAudioContentDetailBindin
ActivityAudioContentDetailBinding::inflate
) {
private val viewModel: AudioContentDetailViewModel by inject()
private val recentContentViewModel: RecentContentViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var creatorOtherContentAdapter: OtherContentAdapter
@@ -808,6 +809,15 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
)
}
)
recentContentViewModel.insertRecentContent(
RecentContent(
contentId = response.contentId,
coverImageUrl = response.coverImageUrl,
title = response.title,
creatorNickname = response.creator.nickname
)
)
}
binding.ivPlayOrPause.setImageResource(

View File

@@ -40,6 +40,9 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentPlayerBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.mypage.recent.RecentContentViewModel
import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
@UnstableApi
@@ -53,6 +56,7 @@ class AudioContentPlayerFragment(
private lateinit var binding: FragmentAudioContentPlayerBinding
private val viewModel: AudioContentPlayerViewModel by viewModel()
private val recentContentViewModel: RecentContentViewModel by inject()
private var mediaController: MediaController? = null
private val handler = Handler(Looper.getMainLooper())
@@ -451,7 +455,19 @@ class AudioContentPlayerFragment(
transformations(RoundedCornersTransformation(8f.dpToPx()))
}
adapter.updateCurrentPlayingId(it.extras?.getLong(Constants.EXTRA_AUDIO_CONTENT_ID))
val contentId = it.extras?.getLong(Constants.EXTRA_AUDIO_CONTENT_ID)
adapter.updateCurrentPlayingId(contentId)
// Save to recent content
contentId?.let { id ->
val recentContent = RecentContent(
contentId = id,
coverImageUrl = it.artworkUri.toString(),
title = it.title.toString(),
creatorNickname = it.artist.toString()
)
recentContentViewModel.insertRecentContent(recentContent)
}
}
}

View File

@@ -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
)

View File

@@ -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>(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>(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>(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)) {

View File

@@ -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<RecentContent, RecentContentAdapter.RecentContentViewHolder>(
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<RecentContent>() {
override fun areItemsTheSame(oldItem: RecentContent, newItem: RecentContent): Boolean {
return oldItem.contentId == newItem.contentId
}
override fun areContentsTheSame(oldItem: RecentContent, newItem: RecentContent): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -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<RecentContentDatabase>().recentContentDao() }
// Repository
factory { RecentContentRepository(get()) }
// ViewModel
viewModel { RecentContentViewModel(get()) }
}

View File

@@ -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<Int> = recentContentDao.getCount()
fun getRecentContents(limit: Int): Flow<List<RecentContent>> {
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()
}
}

View File

@@ -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<Int> = repository.recentContentsCount.asLiveData()
fun getRecentContents(limit: Int): LiveData<List<RecentContent>> {
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()
}
}

View File

@@ -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
)

View File

@@ -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<List<RecentContent>>
@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<Int>
@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()
}

View File

@@ -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
}
}
}
}

View File

@@ -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<ActivitySettingsBinding>(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<ActivitySettingsBinding>(ActivitySettingsB
viewModel.logout {
NotiflyClient.logout(context = applicationContext)
SharedPreferenceManager.clear()
alarmViewModel.truncate()
recentContentViewModel.truncate()
finishAffinity()
startActivity(Intent(applicationContext, SplashActivity::class.java))
}
@@ -181,8 +186,11 @@ class SettingsActivity : BaseActivity<ActivitySettingsBinding>(ActivitySettingsB
viewModel.logoutAllDevice() {
NotiflyClient.logout(context = applicationContext)
SharedPreferenceManager.clear()
alarmViewModel.truncate()
recentContentViewModel.truncate()
finishAffinity()
startActivity(Intent(applicationContext, SplashActivity::class.java))
}

View File

@@ -385,9 +385,7 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_recent_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="24dp" />
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>