콘텐츠 기능 추가

This commit is contained in:
klaus 2023-08-05 01:25:09 +09:00
parent 7dbbd8d490
commit 1329ae5e5d
136 changed files with 10725 additions and 21 deletions

View File

@ -61,8 +61,8 @@ android {
buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"' buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"'
buildConfigField 'String', 'BOOTPAY_APP_ID', '"6242a7772701800023f68b2e"' buildConfigField 'String', 'BOOTPAY_APP_ID', '"6242a7772701800023f68b2e"'
buildConfigField 'String', 'AGORA_APP_ID', '"d28c80855d314a599cd7c15280920699"' buildConfigField 'String', 'AGORA_APP_ID', '"b96574e191a9430fa54c605528aa3ef7"'
buildConfigField 'String', 'AGORA_APP_CERTIFICATE', '"29ef33b7c37e4b80b74af9a6e9b2af5e"' buildConfigField 'String', 'AGORA_APP_CERTIFICATE', '"ae18ade3afcf4086bd4397726eb0654c"'
} }
} }
compileOptions { compileOptions {
@ -142,4 +142,8 @@ dependencies {
// sound visualizer // sound visualizer
implementation "com.gauravk.audiovisualizer:audiovisualizer:0.9.2" implementation "com.gauravk.audiovisualizer:audiovisualizer:0.9.2"
// Glide
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
} }

View File

@ -0,0 +1,67 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:2209417227252155460",
"lastPropertyId": "8:7803281435927194929",
"name": "PlaybackTracking",
"properties": [
{
"id": "1:3889922602505997244",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:874896374244616380",
"name": "contentId",
"type": 6
},
{
"id": "3:305496269372931228",
"name": "totalDuration",
"type": 5
},
{
"id": "4:1202262957765031780",
"name": "startPosition",
"type": 5
},
{
"id": "5:1595250877919247629",
"name": "isFree",
"type": 1
},
{
"id": "6:4066577743967565922",
"name": "isPreview",
"type": 1
},
{
"id": "7:7482414752180672089",
"name": "endPosition",
"type": 5
},
{
"id": "8:7803281435927194929",
"name": "playDateTime",
"type": 9
}
],
"relations": []
}
],
"lastEntityId": "1:2209417227252155460",
"lastIndexId": "0:0",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [],
"retiredRelationUids": [],
"version": 1
}

View File

@ -2,9 +2,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />
<application <application
android:name=".app.SodaLiveApp" android:name=".app.SodaLiveApp"
@ -15,6 +37,7 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SodaLive" android:theme="@style/Theme.SodaLive"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
@ -57,6 +80,11 @@
<activity android:name=".settings.notification.NotificationSettingsActivity" /> <activity android:name=".settings.notification.NotificationSettingsActivity" />
<activity android:name=".live.reservation_status.LiveReservationStatusActivity" /> <activity android:name=".live.reservation_status.LiveReservationStatusActivity" />
<activity android:name=".live.reservation_status.LiveReservationCancelActivity" /> <activity android:name=".live.reservation_status.LiveReservationCancelActivity" />
<activity android:name=".audio_content.AudioContentActivity" />
<activity android:name=".audio_content.detail.AudioContentDetailActivity" />
<activity android:name=".audio_content.modify.AudioContentModifyActivity" />
<activity android:name=".audio_content.order.AudioContentOrderListActivity" />
<activity android:name=".audio_content.upload.AudioContentUploadActivity" />
<activity <activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity" android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
@ -65,6 +93,12 @@
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.AppCompat.DayNight" /> android:theme="@style/Theme.AppCompat.DayNight" />
<service
android:name=".common.SodaLiveService"
android:stopWithTask="false" />
<service android:name=".audio_content.AudioContentPlayService" />
<!-- [START firebase_service] --> <!-- [START firebase_service] -->
<service <service
android:name=".fcm.SodaFirebaseMessagingService" android:name=".fcm.SodaFirebaseMessagingService"

View File

@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.audio_content
import com.google.gson.annotations.SerializedName
import java.util.TimeZone
data class AddAllPlaybackTrackingRequest(
@SerializedName("timezone") val timezone: String = TimeZone.getDefault().id,
@SerializedName("trackingDataList") val trackingDataList: List<PlaybackTrackingData>
)
data class PlaybackTrackingData(
@SerializedName("contentId") val contentId: Long,
@SerializedName("playDateTime") val playDateTime: String,
@SerializedName("isPreview") val isPreview: Boolean,
)

View File

@ -0,0 +1,212 @@
package kr.co.vividnext.sodalive.audio_content
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
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.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class AudioContentActivity : BaseActivity<ActivityAudioContentBinding>(
ActivityAudioContentBinding::inflate
) {
private val viewModel: AudioContentViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var audioContentAdapter: AudioContentAdapter
private var userId: Long = 0
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
userId = intent.getLongExtra(Constants.EXTRA_USER_ID, 0)
activityResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
viewModel.page = 1
viewModel.getAudioContentList(userId = userId) { finish() }
}
}
super.onCreate(savedInstanceState)
if (userId <= 0) {
Toast.makeText(applicationContext, "잘못된 요청입니다.", Toast.LENGTH_LONG).show()
finish()
}
bindData()
viewModel.getAudioContentList(userId = userId) { finish() }
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = "콘텐츠 전체보기"
binding.toolbar.tvBack.setOnClickListener { finish() }
audioContentAdapter = AudioContentAdapter {
val intent = Intent(applicationContext, AudioContentDetailActivity::class.java)
.apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
}
activityResultLauncher.launch(intent)
}
binding.rvAudioContent.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.VERTICAL,
false
)
binding.rvAudioContent.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)) {
audioContentAdapter.itemCount - 1 -> {
outRect.bottom = 0
}
else -> {
outRect.bottom = 13.3f.dpToPx().toInt()
}
}
}
})
binding.rvAudioContent.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!!
.findLastCompletelyVisibleItemPosition()
val itemTotalCount = recyclerView.adapter!!.itemCount - 1
// 스크롤이 끝에 도달했는지 확인
if (!recyclerView.canScrollVertically(1) &&
lastVisibleItemPosition == itemTotalCount
) {
viewModel.getAudioContentList(userId = userId) { }
}
}
})
binding.rvAudioContent.adapter = audioContentAdapter
binding.tvSortNewest.setOnClickListener {
viewModel.changeSort(AudioContentViewModel.Sort.NEWEST)
}
binding.tvSortPriceLow.setOnClickListener {
viewModel.changeSort(AudioContentViewModel.Sort.PRICE_LOW)
}
binding.tvSortPriceHigh.setOnClickListener {
viewModel.changeSort(AudioContentViewModel.Sort.PRICE_HIGH)
}
if (userId == SharedPreferenceManager.userId) {
binding.tvNewContent.visibility = View.VISIBLE
binding.tvNewContent.setOnClickListener {
startActivity(
Intent(
applicationContext,
AudioContentUploadActivity::class.java
)
)
}
} else {
binding.tvNewContent.visibility = View.GONE
}
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.audioContentListLiveData.observe(this) {
if (viewModel.page - 1 == 1) {
audioContentAdapter.items.clear()
binding.rvAudioContent.scrollToPosition(0)
}
binding.tvTotalCount.text = "${it.totalCount}"
audioContentAdapter.items.addAll(it.items)
audioContentAdapter.notifyDataSetChanged()
}
viewModel.sort.observe(this) {
deselectSort()
selectSort(
when (it) {
AudioContentViewModel.Sort.PRICE_HIGH -> {
binding.tvSortPriceHigh
}
AudioContentViewModel.Sort.PRICE_LOW -> {
binding.tvSortPriceLow
}
else -> {
binding.tvSortNewest
}
}
)
viewModel.getAudioContentList(userId = userId) { finish() }
}
}
private fun deselectSort() {
val color = ContextCompat.getColor(
applicationContext,
R.color.color_88e2e2e2
)
binding.tvSortNewest.setTextColor(color)
binding.tvSortPriceLow.setTextColor(color)
binding.tvSortPriceHigh.setTextColor(color)
}
private fun selectSort(view: TextView) {
view.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_e2e2e2
)
)
}
}

View File

@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.audio_content
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentBinding
import kr.co.vividnext.sodalive.explorer.profile.GetAudioContentListItem
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
class AudioContentAdapter(
private val onClickItem: (Long) -> Unit
) : RecyclerView.Adapter<AudioContentAdapter.ViewHolder>() {
val items = mutableListOf<GetAudioContentListItem>()
inner class ViewHolder(
private val binding: ItemAudioContentBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetAudioContentListItem) {
binding.ivCover.load(item.coverImageUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(5.3f.dpToPx()))
}
binding.tvTitle.text = item.title
binding.tvTheme.text = item.themeStr
binding.tvDuration.text = item.duration
binding.tvLikeCount.text = item.likeCount.moneyFormat()
binding.tvCommentCount.text = item.commentCount.moneyFormat()
if (item.price < 1) {
binding.tvPrice.text = "무료"
binding.tvPrice.setCompoundDrawables(null, null, null, null)
} else {
binding.tvPrice.text = item.price.moneyFormat()
binding.tvPrice.setCompoundDrawablesWithIntrinsicBounds(
R.drawable.ic_coin_w,
0,
0,
0
)
}
binding.iv19.visibility = if (item.isAdult) {
View.VISIBLE
} else {
View.GONE
}
binding.root.setOnClickListener { onClickItem(item.contentId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemAudioContentBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.count()
}

View File

@ -0,0 +1,140 @@
package kr.co.vividnext.sodalive.audio_content
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.audio_content.comment.GetAudioContentCommentListResponse
import kr.co.vividnext.sodalive.audio_content.comment.RegisterAudioContentCommentRequest
import kr.co.vividnext.sodalive.audio_content.detail.GetAudioContentDetailResponse
import kr.co.vividnext.sodalive.audio_content.detail.PutAudioContentLikeRequest
import kr.co.vividnext.sodalive.audio_content.detail.PutAudioContentLikeResponse
import kr.co.vividnext.sodalive.audio_content.donation.AudioContentDonationRequest
import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentMainItem
import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentMainResponse
import kr.co.vividnext.sodalive.audio_content.order.GetAudioContentOrderListResponse
import kr.co.vividnext.sodalive.audio_content.order.OrderRequest
import kr.co.vividnext.sodalive.audio_content.upload.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.explorer.profile.GetAudioContentListResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
interface AudioContentApi {
@GET("/audio-content")
fun getAudioContentList(
@Query("creator-id") id: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("sort-type") sort: AudioContentViewModel.Sort,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetAudioContentListResponse>>
@GET("/audio-content/theme")
fun getAudioContentThemeList(
@Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetAudioContentThemeResponse>>>
@POST("/audio-content")
@Multipart
fun uploadAudioContent(
@Part coverImage: MultipartBody.Part,
@Part contentFile: MultipartBody.Part,
@Part("request") request: RequestBody,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/audio-content/{id}")
fun getAudioContentDetail(
@Path("id") id: Long,
@Query("timezone") timezone: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetAudioContentDetailResponse>>
@POST("/order/audio-content")
fun orderAudioContent(
@Body request: OrderRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/order/audio-content")
fun getAudioContentOrderList(
@Query("page") page: Int,
@Query("size") size: Int,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetAudioContentOrderListResponse>>
@POST("/audio-content/playback-tracking")
fun addAllPlaybackTracking(
@Body request: AddAllPlaybackTrackingRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@POST("/audio-content/comment")
fun registerComment(
@Body request: RegisterAudioContentCommentRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/audio-content/{id}/comment")
fun getAudioContentCommentList(
@Path("id") id: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("timezone") timezone: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetAudioContentCommentListResponse>>
@GET("/audio-content/comment/{id}")
fun getAudioContentCommentCommentList(
@Path("id") id: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("timezone") timezone: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetAudioContentCommentListResponse>>
@PUT("/audio-content/like")
fun likeContent(
@Body request: PutAudioContentLikeRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<PutAudioContentLikeResponse>>
@PUT("/audio-content")
@Multipart
fun modifyAudioContent(
@Part coverImage: MultipartBody.Part?,
@Part("request") request: RequestBody,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@DELETE("/audio-content/{id}")
fun deleteAudioContent(
@Path("id") id: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/audio-content/main")
fun getMain(
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetAudioContentMainResponse>>
@GET("/audio-content/main/new")
fun getNewContentOfTheme(
@Query("theme") theme: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetAudioContentMainItem>>>
@POST("/audio-content/donation")
fun donation(
@Body request: AudioContentDonationRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
}

View File

@ -0,0 +1,556 @@
package kr.co.vividnext.sodalive.audio_content
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
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 org.koin.android.ext.android.inject
class AudioContentPlayService :
Service(),
MediaPlayer.OnPreparedListener,
MediaPlayer.OnCompletionListener {
private var playbackTrackingId: Long = 0
private val playbackTrackingRepository: PlaybackTrackingRepository by inject()
private lateinit var mediaPlayer: MediaPlayer
private var url: String? = null
private var title: String? = null
private var isFree: Boolean? = null
private var isPreview: Boolean? = null
private var nickname: String? = null
private var contentId: Long? = null
private var creatorId: Long? = null
private var coverImageUrl: String? = null
private var isPlaying = false
private val handler = Handler(Looper.getMainLooper())
private var changeMediaPlayerPositionRunnable = object : Runnable {
override fun run() {
val intent = Intent(Constants.ACTION_AUDIO_CONTENT_RECEIVER).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_PROGRESS, mediaPlayer.currentPosition)
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
sendBroadcast(intent)
handler.postDelayed(this, 1000)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
MusicAction.INIT.name -> {
val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
if (this.contentId != null && this.contentId == contentId) {
sendBroadcast(
Intent(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_CHANGE_UI,
true
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_PLAYING,
isPlaying
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_DURATION,
mediaPlayer.duration
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_PROGRESS,
mediaPlayer.currentPosition
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_ID,
contentId
)
}
)
}
if (
this.contentId != null &&
title != null &&
nickname != null &&
coverImageUrl != null
) {
sendBroadcast(
Intent(Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_SHOWING,
true
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_PLAYING,
isPlaying
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_TITLE,
title
)
putExtra(
Constants.EXTRA_NICKNAME,
nickname
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL,
coverImageUrl
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_ID,
this@AudioContentPlayService.contentId
)
}
)
} else {
sendBroadcast(
Intent(Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_SHOWING,
false
)
}
)
}
}
MusicAction.PLAY.name -> {
if (!isPlaying) {
mediaPlayer.start()
toggleIsPlaying()
updateNotification()
}
}
MusicAction.PAUSE.name -> {
if (isPlaying) {
mediaPlayer.pause()
toggleIsPlaying()
updateNotification()
}
}
MusicAction.STOP.name -> {
if (this::mediaPlayer.isInitialized) {
mediaPlayer.stop()
setEndPositionPlaybackTracking(mediaPlayer.currentPosition)
toggleIsPlaying(false)
onStopService()
}
}
MusicAction.CONDITIONAL_STOP.name -> {
val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
if (
this.contentId != null &&
this.contentId == contentId &&
this::mediaPlayer.isInitialized
) {
mediaPlayer.stop()
setEndPositionPlaybackTracking(mediaPlayer.currentPosition)
toggleIsPlaying(false)
onStopService()
}
}
MusicAction.PROGRESS.name -> {
val progress = intent.getIntExtra(Constants.EXTRA_AUDIO_CONTENT_PROGRESS, 0)
if (progress > 0) {
if (contentId != null) saveNewPlaybackTracking(
totalDuration = mediaPlayer.duration,
progress = progress
)
mediaPlayer.seekTo(progress)
}
}
else -> {
val contentId = intent?.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
if (contentId != null && this.contentId == contentId) {
if (isPlaying) {
sendBroadcast(
Intent(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_NEXT_ACTION,
MusicAction.PAUSE
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_ID,
contentId
)
}
)
} else {
sendBroadcast(
Intent(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_NEXT_ACTION,
MusicAction.PLAY
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_ID,
contentId
)
}
)
}
} else {
url = intent?.getStringExtra(Constants.EXTRA_AUDIO_CONTENT_URL)
title = intent?.getStringExtra(Constants.EXTRA_AUDIO_CONTENT_TITLE)
isFree = intent?.getBooleanExtra(
Constants.EXTRA_AUDIO_CONTENT_FREE,
true
)
isPreview = intent?.getBooleanExtra(
Constants.EXTRA_AUDIO_CONTENT_PREVIEW,
true
)
nickname = intent?.getStringExtra(Constants.EXTRA_NICKNAME)
coverImageUrl = intent?.getStringExtra(
Constants.EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL
)
creatorId = intent?.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_CREATOR_ID, 0)
this.contentId = contentId
if (url != null) {
sendBroadcast(
Intent(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_LOADING,
true
)
}
)
if (isPlaying) {
mediaPlayer.stop()
setEndPositionPlaybackTracking(mediaPlayer.currentPosition)
mediaPlayer.release()
toggleIsPlaying()
}
initMediaPlayer()
mediaPlayer.setDataSource(url)
mediaPlayer.prepareAsync()
}
}
}
}
return START_NOT_STICKY
}
override fun onDestroy() {
if (this::mediaPlayer.isInitialized) {
mediaPlayer.release()
}
onStopService()
super.onDestroy()
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
override fun onCompletion(mp: MediaPlayer?) {
setEndPositionPlaybackTracking(mediaPlayer.currentPosition)
if (SharedPreferenceManager.isContentPlayLoop) {
saveNewPlaybackTracking(totalDuration = mediaPlayer.duration, progress = 0)
mediaPlayer.start()
} else {
toggleIsPlaying(false)
mediaPlayer.release()
onStopService()
}
}
private fun toggleIsPlaying(isPlaying: Boolean? = null) {
this.isPlaying = isPlaying ?: !this.isPlaying
if (this.isPlaying) {
handler.postDelayed(changeMediaPlayerPositionRunnable, 1000)
} else {
handler.removeCallbacks(changeMediaPlayerPositionRunnable)
}
sendBroadcast(
Intent(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_CHANGE_UI,
true
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_PLAYING,
this@AudioContentPlayService.isPlaying
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_ID,
contentId
)
}
)
if (isPlaying != null && !isPlaying) {
resetAudioData()
}
sendBroadcast(
Intent(Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_PLAYING,
this@AudioContentPlayService.isPlaying
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_SHOWING,
contentId != null
)
}
)
}
private fun resetAudioData() {
url = null
title = null
nickname = null
contentId = null
}
private fun initMediaPlayer() {
mediaPlayer = MediaPlayer()
mediaPlayer.setOnPreparedListener(this)
mediaPlayer.setOnCompletionListener(this)
mediaPlayer.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
}
override fun onPrepared(mp: MediaPlayer?) {
saveNewPlaybackTracking(totalDuration = mediaPlayer.duration, progress = 0)
sendBroadcast(
Intent(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_NEXT_ACTION,
MusicAction.PLAY
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_DURATION,
mediaPlayer.duration
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_ID,
contentId
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_ALERT_PREVIEW,
true
)
}
)
sendBroadcast(
Intent(Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER)
.apply {
putExtra(
Constants.EXTRA_AUDIO_CONTENT_PLAYING,
false
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_SHOWING,
true
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_TITLE,
title
)
putExtra(
Constants.EXTRA_NICKNAME,
nickname
)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL,
coverImageUrl
)
}
)
}
private fun updateNotification() {
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
)
val channelId = "audio_content_play_channel"
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"콘텐츠 알림 채널",
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
}
val playPauseIcon =
if (isPlaying) R.drawable.ic_noti_pause else R.drawable.ic_noti_play
val playPauseAction =
if (isPlaying) MusicAction.PAUSE.name else MusicAction.PLAY.name
Glide
.with(this)
.asBitmap()
.load(coverImageUrl)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
val notificationBuilder = NotificationCompat
.Builder(this@AudioContentPlayService, channelId)
.setSmallIcon(R.drawable.ic_noti)
.setLargeIcon(resource)
.setContentTitle(title ?: "오디오 콘텐츠")
.setContentText(nickname ?: "")
.setContentIntent(pendingIntent)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(
NotificationCompat
.Action
.Builder(
playPauseIcon,
"Play_or_Pause",
getServiceIntent(playPauseAction)
).build()
)
.addAction(
NotificationCompat
.Action
.Builder(
R.drawable.ic_noti_stop,
"Stop",
getServiceIntent(MusicAction.STOP.name)
).build()
)
notificationBuilder.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1)
)
startForeground(1, notificationBuilder.build())
}
override fun onLoadCleared(placeholder: Drawable?) {
}
})
}
private fun getServiceIntent(action: String): PendingIntent {
val intent = Intent(this, AudioContentPlayService::class.java)
intent.action = action
return PendingIntent.getService(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
)
}
private fun saveNewPlaybackTracking(totalDuration: Int, progress: Int) {
if (creatorId != SharedPreferenceManager.userId) {
playbackTrackingId = playbackTrackingRepository.savePlaybackTracking(
PlaybackTracking(
contentId = contentId!!,
totalDuration = totalDuration,
startPosition = progress,
isFree = isFree!!,
isPreview = isPreview!!
)
)
}
}
private fun setEndPositionPlaybackTracking(progress: Int) {
if (creatorId != SharedPreferenceManager.userId && playbackTrackingId > 0) {
val playbackTracking = playbackTrackingRepository
.getPlaybackTracking(playbackTrackingId)
if (playbackTracking != null) {
playbackTracking.endPosition = progress
playbackTrackingRepository.savePlaybackTracking(playbackTracking)
}
playbackTrackingId = 0
}
}
private fun onStopService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
stopSelf()
}
enum class MusicAction {
PLAY, PAUSE, STOP, PROGRESS, INIT, CONDITIONAL_STOP
}
}

View File

@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.audio_content
import kr.co.vividnext.sodalive.audio_content.detail.PutAudioContentLikeRequest
import kr.co.vividnext.sodalive.audio_content.donation.AudioContentDonationRequest
import kr.co.vividnext.sodalive.audio_content.order.OrderRequest
import kr.co.vividnext.sodalive.audio_content.order.OrderType
import kr.co.vividnext.sodalive.user.CreatorFollowRequestRequest
import kr.co.vividnext.sodalive.user.UserApi
import okhttp3.MultipartBody
import okhttp3.RequestBody
import java.util.TimeZone
class AudioContentRepository(
private val api: AudioContentApi,
private val userApi: UserApi
) {
fun getAudioContentList(
id: Long,
page: Int,
size: Int,
sort: AudioContentViewModel.Sort,
token: String
) = api.getAudioContentList(
id = id,
page = page - 1,
size = size,
sort = sort,
authHeader = token
)
fun getAudioContentThemeList(token: String) = api.getAudioContentThemeList(token)
fun uploadAudioContent(
coverImage: MultipartBody.Part,
contentFile: MultipartBody.Part,
request: RequestBody,
token: String
) = api.uploadAudioContent(
coverImage = coverImage,
contentFile = contentFile,
request = request,
authHeader = token
)
fun modifyAudioContent(
coverImage: MultipartBody.Part? = null,
request: RequestBody,
token: String
) = api.modifyAudioContent(
coverImage = coverImage,
request = request,
authHeader = token
)
fun deleteAudioContent(
id: Long,
token: String
) = api.deleteAudioContent(
id = id,
authHeader = token
)
fun getAudioContentDetail(audioContentId: Long, token: String) = api.getAudioContentDetail(
id = audioContentId,
timezone = TimeZone.getDefault().id,
authHeader = token
)
fun registerNotification(
creatorId: Long,
token: String
) = userApi.creatorFollow(
request = CreatorFollowRequestRequest(creatorId = creatorId),
authHeader = token
)
fun unRegisterNotification(
creatorId: Long,
token: String
) = userApi.creatorUnFollow(
request = CreatorFollowRequestRequest(creatorId = creatorId),
authHeader = token
)
fun orderContent(
contentId: Long,
orderType: OrderType,
token: String
) = api.orderAudioContent(
request = OrderRequest(
contentId = contentId,
orderType = orderType,
container = "aos"
),
authHeader = token
)
fun getAudioContentOrderList(
page: Int,
size: Int,
token: String
) = api.getAudioContentOrderList(
page = page - 1,
size = size,
authHeader = token
)
fun addAllPlaybackTracking(
request: AddAllPlaybackTrackingRequest,
token: String
) = api.addAllPlaybackTracking(request, authHeader = token)
fun likeContent(
request: PutAudioContentLikeRequest,
token: String
) = api.likeContent(request, authHeader = token)
fun getMain(token: String) = api.getMain(authHeader = token)
fun getNewContentOfTheme(theme: String, token: String) = api.getNewContentOfTheme(
theme = theme,
authHeader = token
)
fun donation(
contentId: Long,
can: Int,
comment: String,
token: String
) = api.donation(
request = AudioContentDonationRequest(
contentId = contentId,
donationCan = can,
comment = comment
),
authHeader = token
)
}

View File

@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.audio_content
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.annotations.SerializedName
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.explorer.profile.GetAudioContentListResponse
class AudioContentViewModel(private val repository: AudioContentRepository) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private var _audioContentListLiveData = MutableLiveData<GetAudioContentListResponse>()
val audioContentListLiveData: LiveData<GetAudioContentListResponse>
get() = _audioContentListLiveData
private val _sort = MutableLiveData(Sort.NEWEST)
val sort: LiveData<Sort>
get() = _sort
enum class Sort {
@SerializedName("NEWEST")
NEWEST,
@SerializedName("PRICE_HIGH")
PRICE_HIGH,
@SerializedName("PRICE_LOW")
PRICE_LOW
}
private var isLast = false
var page = 1
private val size = 10
fun getAudioContentList(userId: Long, onFailure: (() -> Unit)? = null) {
if (!_isLoading.value!! && !isLast) {
_isLoading.value = true
compositeDisposable.add(
repository.getAudioContentList(
id = userId,
page = page,
size = size,
token = "Bearer ${SharedPreferenceManager.token}",
sort = _sort.value!!
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
if (it.data.items.isNotEmpty()) {
page += 1
_audioContentListLiveData.postValue(it.data!!)
} else {
isLast = true
}
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
if (onFailure != null) {
onFailure()
}
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
if (onFailure != null) {
onFailure()
}
}
)
)
}
}
fun changeSort(sort: Sort) {
page = 1
isLast = false
_sort.postValue(sort)
}
}

View File

@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.audio_content
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Entity
data class PlaybackTracking(
@Id
var id: Long = 0,
var contentId: Long,
var totalDuration: Int,
var startPosition: Int,
var isFree: Boolean,
var isPreview: Boolean,
var endPosition: Int? = null,
var playDateTime: String = SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault()
).format(Date())
)

View File

@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.audio_content
import kr.co.vividnext.sodalive.common.ObjectBox
class PlaybackTrackingRepository(private val objectBox: ObjectBox) {
fun savePlaybackTracking(data: PlaybackTracking): Long {
return objectBox.playbackTrackingBox.put(data)
}
fun getPlaybackTracking(id: Long): PlaybackTracking? {
val query = objectBox.playbackTrackingBox
.query(PlaybackTracking_.id.equal(id))
.build()
val playbackTracking = query.findFirst()
query.close()
return playbackTracking
}
fun getAllPlaybackTracking(): List<PlaybackTracking> {
return objectBox
.playbackTrackingBox
.all
}
fun removeAllPlaybackTracking() {
objectBox.playbackTrackingBox.removeAll()
}
}

View File

@ -0,0 +1,103 @@
package kr.co.vividnext.sodalive.audio_content.comment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentCommentBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
class AudioContentCommentAdapter(
private val onItemClick: (GetAudioContentCommentListItem) -> Unit
) : RecyclerView.Adapter<AudioContentCommentAdapter.ViewHolder>() {
var items = mutableSetOf<GetAudioContentCommentListItem>()
inner class ViewHolder(
private val binding: ItemAudioContentCommentBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetAudioContentCommentListItem) {
binding.ivCommentProfile.load(item.profileUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
val tvCommentLayoutParams = binding.tvComment.layoutParams as LinearLayout.LayoutParams
val coin = item.donationCoin
if (coin > 0) {
tvCommentLayoutParams.topMargin = 0
binding.llDonationCoin.visibility = View.VISIBLE
binding.tvDonationCoin.text = coin.moneyFormat()
binding.llDonationCoin.setBackgroundResource(
when {
coin >= 100000 -> {
R.drawable.bg_round_corner_10_7_973a3a
}
coin >= 50000 -> {
R.drawable.bg_round_corner_10_7_d85e37
}
coin >= 10000 -> {
R.drawable.bg_round_corner_10_7_d38c38
}
coin >= 5000 -> {
R.drawable.bg_round_corner_10_7_59548f
}
coin >= 1000 -> {
R.drawable.bg_round_corner_10_7_4d6aa4
}
coin >= 500 -> {
R.drawable.bg_round_corner_10_7_2d7390
}
else -> {
R.drawable.bg_round_corner_10_7_548f7d
}
}
)
} else {
tvCommentLayoutParams.topMargin = 13.3f.dpToPx().toInt()
binding.llDonationCoin.visibility = View.GONE
}
binding.tvComment.layoutParams = tvCommentLayoutParams
binding.tvComment.text = item.comment
binding.tvCommentDate.text = item.date
binding.tvCommentNickname.text = item.nickname
binding.tvWriteReply.text = if (item.replyCount > 0) {
"답글 ${item.replyCount}"
} else {
"답글 쓰기"
}
binding.tvWriteReply.setOnClickListener { onItemClick(item) }
binding.root.setOnClickListener { onItemClick(item) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemAudioContentCommentBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items.toList()[position])
}
override fun getItemCount() = items.size
}

View File

@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.audio_content.comment
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.DialogAudioContentCommentBinding
class AudioContentCommentFragment(private val audioContentId: Long) : BottomSheetDialogFragment() {
private lateinit var binding: DialogAudioContentCommentBinding
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener {
val d = it as BottomSheetDialog
val bottomSheet = d.findViewById<FrameLayout>(
com.google.android.material.R.id.design_bottom_sheet
)
if (bottomSheet != null) {
BottomSheetBehavior.from(bottomSheet).state = BottomSheetBehavior.STATE_EXPANDED
}
}
return dialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogAudioContentCommentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val commentListFragmentTag = "COMMENT_LIST_FRAGMENT"
val commentListFragment = AudioContentCommentListFragment.newInstance(
audioContentId = audioContentId
)
val fragmentTransaction = childFragmentManager.beginTransaction()
fragmentTransaction.add(R.id.fl_container, commentListFragment, commentListFragmentTag)
fragmentTransaction.addToBackStack(commentListFragmentTag)
fragmentTransaction.commit()
}
fun hideCommentDialog() {
dialog?.dismiss()
}
fun onClickComment(comment: GetAudioContentCommentListItem) {
val commentReplyFragmentTag = "COMMENT_REPLY_FRAGMENT"
val commentReplyFragment = AudioContentCommentReplyFragment.newInstance(
audioContentId = audioContentId,
comment = comment
)
val fragmentTransaction = childFragmentManager.beginTransaction()
fragmentTransaction.add(R.id.fl_container, commentReplyFragment, commentReplyFragmentTag)
fragmentTransaction.addToBackStack(commentReplyFragmentTag)
fragmentTransaction.commit()
}
}

View File

@ -0,0 +1,182 @@
package kr.co.vividnext.sodalive.audio_content.comment
import android.annotation.SuppressLint
import android.app.Service
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentCommentListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class AudioContentCommentListFragment : BaseFragment<FragmentAudioContentCommentListBinding>(
FragmentAudioContentCommentListBinding::inflate
) {
private val viewModel: AudioContentCommentListViewModel by inject()
private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: AudioContentCommentAdapter
private var audioContentId: Long = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
audioContentId = arguments?.getLong(Constants.EXTRA_AUDIO_CONTENT_ID) ?: 0
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
imm = requireContext().getSystemService(
Service.INPUT_METHOD_SERVICE
) as InputMethodManager
setupView()
bindData()
viewModel.getCommentList(audioContentId = audioContentId) { hideDialog() }
}
private fun hideDialog() {
(parentFragment as AudioContentCommentFragment).hideCommentDialog()
}
private fun setupView() {
binding.ivClose.setOnClickListener { hideDialog() }
binding.ivCommentProfile.load(SharedPreferenceManager.profileImage) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
binding.ivCommentSend.setOnClickListener {
hideKeyboard()
val comment = binding.etComment.text.toString()
binding.etComment.setText("")
viewModel.registerComment(audioContentId, comment)
}
adapter = AudioContentCommentAdapter {
(parentFragment as AudioContentCommentFragment).onClickComment(it)
}
val recyclerView = binding.rvComment
recyclerView.setHasFixedSize(true)
recyclerView.layoutManager = LinearLayoutManager(
activity,
LinearLayoutManager.VERTICAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 13.3f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 13.3f.dpToPx().toInt()
}
else -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
}
}
})
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!!
.findLastCompletelyVisibleItemPosition()
val itemTotalCount = recyclerView.adapter!!.itemCount - 1
// 스크롤이 끝에 도달했는지 확인
if (!recyclerView.canScrollVertically(1) &&
lastVisibleItemPosition == itemTotalCount
) {
viewModel.getCommentList(audioContentId = audioContentId)
}
}
})
recyclerView.adapter = adapter
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(viewLifecycleOwner) {
it?.let { Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show() }
}
viewModel.totalCommentCount.observe(viewLifecycleOwner) {
binding.tvCommentCount.text = "$it"
}
viewModel.commentList.observe(viewLifecycleOwner) {
if (viewModel.page - 1 == 1) {
adapter.items.clear()
binding.rvComment.scrollToPosition(0)
}
adapter.items.addAll(it)
adapter.notifyDataSetChanged()
}
}
private fun hideKeyboard() {
imm.hideSoftInputFromWindow(view?.windowToken, 0)
}
companion object {
fun newInstance(audioContentId: Long): AudioContentCommentListFragment {
val args = Bundle()
args.putLong(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)
val fragment = AudioContentCommentListFragment()
fragment.arguments = args
return fragment
}
}
}

View File

@ -0,0 +1,125 @@
package kr.co.vividnext.sodalive.audio_content.comment
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class AudioContentCommentListViewModel(
private val repository: AudioContentCommentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private var _commentList = MutableLiveData<List<GetAudioContentCommentListItem>>()
val commentList: LiveData<List<GetAudioContentCommentListItem>>
get() = _commentList
private var _totalCommentCount = MutableLiveData(0)
val totalCommentCount: LiveData<Int>
get() = _totalCommentCount
var page = 1
private var isLast = false
private val size = 10
fun getCommentList(audioContentId: Long, onFailure: (() -> Unit)? = null) {
if (!_isLoading.value!! && !isLast) {
_isLoading.value = true
compositeDisposable.add(
repository.getAudioContentCommentList(
audioContentId = audioContentId,
page = page,
size = size,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_totalCommentCount.postValue(it.data.totalCount)
if (it.data.items.isNotEmpty()) {
page += 1
_commentList.postValue(it.data.items)
} else {
isLast = true
}
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
if (onFailure != null) {
onFailure()
}
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
if (onFailure != null) {
onFailure()
}
}
)
)
}
}
fun registerComment(contentId: Long, comment: String) {
if (!_isLoading.value!!) {
_isLoading.value = true
}
compositeDisposable.add(
repository.registerComment(
contentId = contentId,
comment = comment,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success) {
page = 1
isLast = false
getCommentList(contentId)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@ -0,0 +1,93 @@
package kr.co.vividnext.sodalive.audio_content.comment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentCommentBinding
import kr.co.vividnext.sodalive.databinding.ItemAudioContentCommentReplyBinding
class AudioContentCommentReplyAdapter :
RecyclerView.Adapter<AudioContentCommentReplyViewHolder>() {
var items = mutableSetOf<GetAudioContentCommentListItem>()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): AudioContentCommentReplyViewHolder {
return if (viewType == 0) {
AudioContentCommentReplyHeaderViewHolder(
ItemAudioContentCommentBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
} else {
AudioContentCommentReplyItemViewHolder(
ItemAudioContentCommentReplyBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
}
override fun onBindViewHolder(holder: AudioContentCommentReplyViewHolder, position: Int) {
holder.bind(items.toList()[position])
}
override fun getItemCount() = items.size
override fun getItemViewType(position: Int): Int {
return position
}
}
abstract class AudioContentCommentReplyViewHolder(
binding: ViewBinding
) : RecyclerView.ViewHolder(binding.root) {
abstract fun bind(item: GetAudioContentCommentListItem)
}
class AudioContentCommentReplyHeaderViewHolder(
private val binding: ItemAudioContentCommentBinding
) : AudioContentCommentReplyViewHolder(binding) {
override fun bind(item: GetAudioContentCommentListItem) {
binding.ivCommentProfile.load(item.profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
binding.tvComment.text = item.comment
binding.tvCommentDate.text = item.date
binding.tvCommentNickname.text = item.nickname
binding.tvWriteReply.visibility = View.GONE
}
}
class AudioContentCommentReplyItemViewHolder(
private val binding: ItemAudioContentCommentReplyBinding
) : AudioContentCommentReplyViewHolder(binding) {
override fun bind(item: GetAudioContentCommentListItem) {
binding.ivCommentProfile.load(item.profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
binding.tvComment.text = item.comment
binding.tvCommentDate.text = item.date
binding.tvCommentNickname.text = item.nickname
}
}

View File

@ -0,0 +1,204 @@
package kr.co.vividnext.sodalive.audio_content.comment
import android.annotation.SuppressLint
import android.app.Service
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.core.os.BundleCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentCommentReplyBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class AudioContentCommentReplyFragment : BaseFragment<FragmentAudioContentCommentReplyBinding>(
FragmentAudioContentCommentReplyBinding::inflate
) {
private val viewModel: AudioContentCommentReplyViewModel by inject()
private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: AudioContentCommentReplyAdapter
private var originalComment: GetAudioContentCommentListItem? = null
private var audioContentId: Long = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
audioContentId = arguments?.getLong(Constants.EXTRA_AUDIO_CONTENT_ID) ?: 0
originalComment = BundleCompat.getParcelable(
requireArguments(),
Constants.EXTRA_AUDIO_CONTENT_COMMENT,
GetAudioContentCommentListItem::class.java
)
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (originalComment == null) {
parentFragmentManager.popBackStack()
}
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
imm = requireContext().getSystemService(
Service.INPUT_METHOD_SERVICE
) as InputMethodManager
setupView()
bindData()
viewModel.getCommentReplyList(commentId = originalComment!!.id) {
parentFragmentManager.popBackStack()
}
}
private fun hideDialog() {
(parentFragment as AudioContentCommentFragment).hideCommentDialog()
}
private fun setupView() {
binding.root.setOnClickListener { }
binding.tvBack.setOnClickListener {
parentFragmentManager.popBackStack()
}
binding.ivClose.setOnClickListener { hideDialog() }
binding.ivCommentProfile.load(SharedPreferenceManager.profileImage) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
binding.ivCommentSend.setOnClickListener {
hideKeyboard()
val comment = binding.etComment.text.toString()
binding.etComment.setText("")
viewModel.registerComment(audioContentId, originalComment!!.id, comment)
}
adapter = AudioContentCommentReplyAdapter().apply {
items.add(originalComment!!)
}
val recyclerView = binding.rvCommentReply
recyclerView.setHasFixedSize(true)
recyclerView.layoutManager = LinearLayoutManager(
activity,
LinearLayoutManager.VERTICAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 13.3f.dpToPx().toInt()
outRect.bottom = 12f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.top = 12f.dpToPx().toInt()
outRect.bottom = 13.3f.dpToPx().toInt()
}
else -> {
outRect.top = 12f.dpToPx().toInt()
outRect.bottom = 12f.dpToPx().toInt()
}
}
}
})
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!!
.findLastCompletelyVisibleItemPosition()
val itemTotalCount = recyclerView.adapter!!.itemCount - 1
// 스크롤이 끝에 도달했는지 확인
if (!recyclerView.canScrollVertically(1) &&
lastVisibleItemPosition == itemTotalCount
) {
viewModel.getCommentReplyList(originalComment!!.id)
}
}
})
recyclerView.adapter = adapter
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(viewLifecycleOwner) {
it?.let { Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show() }
}
viewModel.commentList.observe(viewLifecycleOwner) {
if (viewModel.page - 1 == 1) {
adapter.items.clear()
binding.rvCommentReply.scrollToPosition(0)
adapter.items.add(originalComment!!)
}
adapter.items.addAll(it)
adapter.notifyDataSetChanged()
}
}
private fun hideKeyboard() {
imm.hideSoftInputFromWindow(view?.windowToken, 0)
}
companion object {
fun newInstance(
audioContentId: Long,
comment: GetAudioContentCommentListItem
): AudioContentCommentReplyFragment {
val args = Bundle()
args.putLong(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)
args.putParcelable(Constants.EXTRA_AUDIO_CONTENT_COMMENT, comment)
val fragment = AudioContentCommentReplyFragment()
fragment.arguments = args
return fragment
}
}
}

View File

@ -0,0 +1,120 @@
package kr.co.vividnext.sodalive.audio_content.comment
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class AudioContentCommentReplyViewModel(
private val repository: AudioContentCommentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private var _commentList = MutableLiveData<List<GetAudioContentCommentListItem>>()
val commentList: LiveData<List<GetAudioContentCommentListItem>>
get() = _commentList
var page = 1
private var isLast = false
private val size = 10
fun getCommentReplyList(commentId: Long, onFailure: (() -> Unit)? = null) {
if (!_isLoading.value!! && !isLast) {
_isLoading.value = true
compositeDisposable.add(
repository.getAudioContentCommentReplyList(
commentId = commentId,
page = page,
size = size,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
if (it.data.items.isNotEmpty()) {
page += 1
_commentList.postValue(it.data.items)
} else {
isLast = true
}
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
if (onFailure != null) {
onFailure()
}
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
if (onFailure != null) {
onFailure()
}
}
)
)
}
}
fun registerComment(contentId: Long, commentId: Long, comment: String) {
if (!_isLoading.value!!) {
_isLoading.value = true
}
compositeDisposable.add(
repository.registerComment(
contentId = contentId,
comment = comment,
parentId = commentId,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success) {
page = 1
isLast = false
getCommentReplyList(commentId)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.audio_content.comment
import kr.co.vividnext.sodalive.audio_content.AudioContentApi
import java.util.TimeZone
class AudioContentCommentRepository(private val api: AudioContentApi) {
fun registerComment(
contentId: Long,
comment: String,
parentId: Long? = null,
token: String
) = api.registerComment(
request = RegisterAudioContentCommentRequest(
comment = comment,
contentId = contentId,
parentId = parentId
),
authHeader = token
)
fun getAudioContentCommentList(
audioContentId: Long,
page: Int,
size: Int,
token: String
) = api.getAudioContentCommentList(
id = audioContentId,
page = page - 1,
size = size,
timezone = TimeZone.getDefault().id,
authHeader = token
)
fun getAudioContentCommentReplyList(
commentId: Long,
page: Int,
size: Int,
token: String
) = api.getAudioContentCommentCommentList(
id = commentId,
page = page - 1,
size = size,
timezone = TimeZone.getDefault().id,
authHeader = token
)
}

View File

@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.audio_content.comment
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
data class GetAudioContentCommentListResponse(
@SerializedName("totalCount") val totalCount: Int,
@SerializedName("items") val items: List<GetAudioContentCommentListItem>
)
@Parcelize
data class GetAudioContentCommentListItem(
@SerializedName("id") val id: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileUrl") val profileUrl: String,
@SerializedName("comment") val comment: String,
@SerializedName("donationCoin") val donationCoin: Int,
@SerializedName("date") val date: String,
@SerializedName("replyCount") val replyCount: Int
) : Parcelable

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.audio_content.comment
import com.google.gson.annotations.SerializedName
data class RegisterAudioContentCommentRequest(
@SerializedName("comment") val comment: String,
@SerializedName("contentId") val contentId: Long,
@SerializedName("parentId") val parentId: Long?
)

View File

@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.audio_content.detail
import android.annotation.SuppressLint
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import android.view.WindowManager
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import kr.co.vividnext.sodalive.databinding.DialogAudioContentDeleteBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
@SuppressLint("SetTextI18n")
class AudioContentDeleteDialog(
activity: Activity,
layoutInflater: LayoutInflater,
title: String,
confirmButtonClick: () -> Unit
) {
private val alertDialog: AlertDialog
val dialogView = DialogAudioContentDeleteBinding.inflate(layoutInflater)
init {
val dialogBuilder = AlertDialog.Builder(activity)
dialogBuilder.setView(dialogView.root)
alertDialog = dialogBuilder.create()
alertDialog.setCancelable(false)
alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialogView.tvTitle.text = "[$title]을 삭제하시겠습니까?"
dialogView.tvCancel.setOnClickListener {
alertDialog.dismiss()
}
dialogView.tvConfirm.setOnClickListener {
if (dialogView.tvNotice.isSelected) {
alertDialog.dismiss()
confirmButtonClick()
} else {
Toast.makeText(
activity,
"동의하셔야 삭제할 수 있습니다.",
Toast.LENGTH_LONG
).show()
}
}
dialogView.tvNotice.setOnClickListener {
it.isSelected = !it.isSelected
}
}
fun show(width: Int) {
alertDialog.show()
val lp = WindowManager.LayoutParams()
lp.copyFrom(alertDialog.window?.attributes)
lp.width = width - (26.7f.dpToPx()).toInt()
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
alertDialog.window?.attributes = lp
}
}

View File

@ -0,0 +1,788 @@
package kr.co.vividnext.sodalive.audio_content.detail
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.RelativeLayout
import android.widget.SeekBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.gson.Gson
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.comment.AudioContentCommentFragment
import kr.co.vividnext.sodalive.audio_content.modify.AudioContentModifyActivity
import kr.co.vividnext.sodalive.audio_content.order.AudioContentOrderConfirmDialog
import kr.co.vividnext.sodalive.audio_content.order.AudioContentOrderFragment
import kr.co.vividnext.sodalive.audio_content.order.OrderType
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.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog
import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.report.ReportType
import org.koin.android.ext.android.inject
class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBinding>(
ActivityAudioContentDetailBinding::inflate
) {
private val viewModel: AudioContentDetailViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var creatorOtherContentAdapter: OtherContentAdapter
private lateinit var sameThemeOtherContentAdapter: OtherContentAdapter
private var audioContentId: Long = 0
private var isAlertPreview = false
private val audioContentReceiver = AudioContentReceiver()
private var refresh = false
set(value) {
field = value
setResult(RESULT_OK)
}
private var title = ""
@SuppressLint("SetTextI18n")
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
binding.scrollView.scrollTo(0, 0)
binding.sbProgress.progress = 0
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
binding.tvTotalDuration.text = " / 00:00:00"
binding.tvCurrentDuration.text = "00:00:00"
binding.rlPreviewAlert.visibility = View.GONE
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
override fun onCreate(savedInstanceState: Bundle?) {
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
super.onCreate(savedInstanceState)
if (audioContentId <= 0) {
Toast.makeText(applicationContext, "잘못된 요청입니다.", Toast.LENGTH_LONG).show()
finish()
}
bindData()
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
override fun onResume() {
super.onResume()
val intentFilter = IntentFilter(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
registerReceiver(audioContentReceiver, intentFilter)
if (refresh) {
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
}
override fun onPause() {
super.onPause()
unregisterReceiver(audioContentReceiver)
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.tvBack.text = "콘텐츠 상세"
binding.tvBack.setOnClickListener { finish() }
binding.ivClosePreviewAlert.setOnClickListener { viewModel.toggleShowPreviewAlert() }
binding.ivMenu.setOnClickListener {
showOptionMenu(
this,
binding.ivMenu,
)
}
creatorOtherContentAdapter = OtherContentAdapter {
val intent = Intent(applicationContext, AudioContentDetailActivity::class.java)
.apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(intent)
}
binding.rvCreatorOtherContent.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.HORIZONTAL,
false
)
binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.getAudioContentDetail(
audioContentId = audioContentId
) { finish() }
binding.swipeRefreshLayout.isRefreshing = false
}
binding.rvCreatorOtherContent.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 = 6.7f.dpToPx().toInt()
}
creatorOtherContentAdapter.itemCount - 1 -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
}
}
}
})
binding.rvCreatorOtherContent.adapter = creatorOtherContentAdapter
sameThemeOtherContentAdapter = OtherContentAdapter {
val intent = Intent(applicationContext, AudioContentDetailActivity::class.java)
.apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(intent)
}
binding.rvThemeOtherContent.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.HORIZONTAL,
false
)
binding.rvThemeOtherContent.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 = 6.7f.dpToPx().toInt()
}
creatorOtherContentAdapter.itemCount - 1 -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
}
}
}
})
binding.rvThemeOtherContent.adapter = sameThemeOtherContentAdapter
binding.sbProgress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
}
override fun onStartTrackingTouch(p0: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
if (seekBar != null) {
val intent = Intent(
this@AudioContentDetailActivity,
AudioContentPlayService::class.java
)
intent.action = AudioContentPlayService.MusicAction.PROGRESS.name
intent.putExtra(Constants.EXTRA_AUDIO_CONTENT_PROGRESS, seekBar.progress)
startService(intent)
}
}
})
val layoutParams = binding.ivCover.layoutParams as RelativeLayout.LayoutParams
layoutParams.width = (screenWidth - 13.3f.dpToPx()).toInt()
layoutParams.height = (screenWidth - 13.3f.dpToPx()).toInt()
binding.ivCover.layoutParams = layoutParams
binding.ivPlayLoop.setOnClickListener { viewModel.togglePlayLoop() }
binding.llDonation.setOnClickListener {
val dialog = LiveRoomDonationDialog(
this,
LayoutInflater.from(this)
) { can, message ->
if (can <= 0) {
showToast("1코인 이상 후원하실 수 있습니다.")
} else if (message.isBlank()) {
showToast("함께 보낼 메시지를 입력하세요.")
} else {
donation(can, message)
}
}
dialog.show(screenWidth)
}
}
private fun donation(coin: Int, message: String) {
viewModel.donation(audioContentId, coin, message) {
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
}
private fun showOptionMenu(context: Context, v: View) {
val popup = PopupMenu(context, v)
val inflater = popup.menuInflater
if (
viewModel.audioContentLiveData.value!!.creator.creatorId ==
SharedPreferenceManager.userId
) {
inflater.inflate(R.menu.audio_content_detail_creator_menu, popup.menu)
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_modify -> {
refresh = true
startActivity(
Intent(applicationContext, AudioContentModifyActivity::class.java)
.apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)
}
)
}
R.id.menu_delete -> {
showDeleteDialog()
}
}
true
}
} else {
inflater.inflate(R.menu.audio_content_detail_user_menu, popup.menu)
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_report -> {
showReportDialog()
}
}
true
}
}
popup.show()
}
private fun showDeleteDialog() {
AudioContentDeleteDialog(
this,
layoutInflater,
this.title,
confirmButtonClick = {
viewModel.deleteAudioContent(audioContentId) {
setResult(RESULT_OK)
finish()
}
}
).show(screenWidth)
}
private fun showReportDialog() {
AudioContentReportDialog(this, layoutInflater) {
viewModel.report(
type = ReportType.AUDIO_CONTENT,
contentId = audioContentId,
reason = it
)
}.show(screenWidth)
}
@SuppressLint("NotifyDataSetChanged", "SetTextI18n")
private fun bindData() {
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.isExpandDetail.observe(this) {
binding.tvDetail.maxLines = if (it) {
Int.MAX_VALUE
} else {
2
}
}
viewModel.isShowPreviewAlert.observe(this) {
binding.rlPreviewAlert.visibility = if (it) {
View.VISIBLE
} else {
View.GONE
}
}
viewModel.audioContentLiveData.observe(this) {
refresh = false
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.INIT.name
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it.contentId)
}
)
title = it.title
setupCreatorArea(it.creator)
setupMosaicArea(it.isMosaic)
setupPlayArea(it)
setupInfoArea(it)
setupPurchaseButton(it)
setupCommentArea(it)
setupCreatorOtherContentListArea(it.creatorOtherContentList)
setupSameThemeOtherContentList(it.sameThemeOtherContentList)
isAlertPreview = it.creator.creatorId != SharedPreferenceManager.userId &&
!it.existOrdered &&
it.price > 0
}
viewModel.isContentPlayLoopLiveData.observe(this) {
if (it) {
binding.ivPlayLoop.setImageResource(R.drawable.btn_player_repeat)
} else {
binding.ivPlayLoop.setImageResource(R.drawable.btn_player_repeat_done)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun setupSameThemeOtherContentList(
sameThemeOtherContentList: List<OtherContentResponse>
) {
if (sameThemeOtherContentList.isEmpty()) {
binding.rvThemeOtherContent.visibility = View.GONE
binding.llThemeOtherContentPreparing.visibility = View.VISIBLE
} else {
binding.rvThemeOtherContent.visibility = View.VISIBLE
binding.llThemeOtherContentPreparing.visibility = View.GONE
sameThemeOtherContentAdapter.items.clear()
sameThemeOtherContentAdapter.items.addAll(sameThemeOtherContentList)
sameThemeOtherContentAdapter.notifyDataSetChanged()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun setupCreatorOtherContentListArea(
creatorOtherContentList: List<OtherContentResponse>
) {
if (creatorOtherContentList.isEmpty()) {
binding.rvCreatorOtherContent.visibility = View.GONE
binding.llCreatorOtherContentPreparing.visibility = View.VISIBLE
} else {
binding.rvCreatorOtherContent.visibility = View.VISIBLE
binding.llCreatorOtherContentPreparing.visibility = View.GONE
creatorOtherContentAdapter.items.clear()
creatorOtherContentAdapter.items.addAll(creatorOtherContentList)
creatorOtherContentAdapter.notifyDataSetChanged()
}
}
private fun setupCommentArea(response: GetAudioContentDetailResponse) {
if (response.isCommentAvailable) {
binding.llDonation.visibility = View.VISIBLE
binding.llComment.visibility = View.VISIBLE
binding.tvCommentCount.text = "${response.commentCount}"
if (response.commentCount > 0) {
binding.ivCommentProfile.load(response.commentList[0].profileUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
binding.tvCommentText.text = response.commentList[0].comment
binding.tvCommentText.visibility = View.VISIBLE
binding.rlInputComment.visibility = View.GONE
binding.llComment.setOnClickListener { showCommentBottomSheetDialog() }
} else {
binding.tvCommentText.visibility = View.GONE
binding.rlInputComment.visibility = View.VISIBLE
binding.ivCommentProfile.load(SharedPreferenceManager.profileImage) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
binding.ivCommentSend.setOnClickListener {
val comment = binding.etComment.text.toString()
binding.etComment.setText("")
viewModel.registerComment(audioContentId, comment)
}
binding.llComment.setOnClickListener {}
}
} else {
binding.llComment.visibility = View.GONE
binding.llDonation.visibility = View.GONE
}
}
private fun showCommentBottomSheetDialog() {
val dialog = AudioContentCommentFragment(audioContentId = audioContentId)
dialog.show(
supportFragmentManager,
dialog.tag
)
}
private fun setupPurchaseButton(response: GetAudioContentDetailResponse) {
if (
response.price > 0 &&
!response.existOrdered &&
response.orderType == null &&
response.creator.creatorId != SharedPreferenceManager.userId
) {
binding.llPurchase.visibility = View.VISIBLE
binding.tvPrice.text = response.price.toString()
binding.llPurchase.setOnClickListener {
showOrderDialog(audioContent = response)
}
} else {
binding.llPurchase.visibility = View.GONE
}
}
@SuppressLint("SetTextI18n")
private fun setupPlayArea(response: GetAudioContentDetailResponse) {
Glide
.with(this)
.load(response.coverImageUrl)
.centerCrop()
.placeholder(R.drawable.bg_black)
.apply(RequestOptions().override((screenWidth - 13.3f.dpToPx()).toInt()))
.into(binding.ivCover)
binding.ivPlayOrPause.setOnClickListener {
startService(
Intent(this, AudioContentPlayService::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL, response.coverImageUrl)
putExtra(Constants.EXTRA_AUDIO_CONTENT_URL, response.contentUrl)
putExtra(Constants.EXTRA_NICKNAME, response.creator.nickname)
putExtra(Constants.EXTRA_AUDIO_CONTENT_TITLE, response.title)
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, response.contentId)
putExtra(Constants.EXTRA_AUDIO_CONTENT_CREATOR_ID, response.creator.creatorId)
putExtra(Constants.EXTRA_AUDIO_CONTENT_FREE, response.price <= 0)
putExtra(
Constants.EXTRA_AUDIO_CONTENT_PREVIEW,
!response.existOrdered && response.price > 0
)
}
)
}
binding.tvTotalDuration.text = " / ${response.duration}"
}
@SuppressLint("SetTextI18n")
private fun setupInfoArea(response: GetAudioContentDetailResponse) {
binding.tvTheme.text = response.themeStr
binding.tv19.visibility = if (response.isAdult) {
View.VISIBLE
} else {
View.GONE
}
if (response.orderType != null && response.orderType == OrderType.KEEP) {
binding.tvPurchased.visibility = View.VISIBLE
binding.tvRental.visibility = View.GONE
binding.tvRemainingTime.visibility = View.GONE
} else if (response.orderType != null && response.orderType == OrderType.RENTAL) {
binding.tvPurchased.visibility = View.GONE
binding.tvRental.visibility = View.VISIBLE
binding.tvRemainingTime.visibility = View.VISIBLE
binding.tvRemainingTime.text = response.remainingTime
} else {
binding.tvPurchased.visibility = View.GONE
binding.tvRental.visibility = View.GONE
binding.tvRemainingTime.visibility = View.GONE
}
binding.tvTitle.text = response.title
binding.tvDetail.text = response.detail
binding.tvDetail.setOnClickListener { viewModel.toggleExpandDetail() }
if (response.tag.isNotBlank()) {
binding.tvTag.visibility = View.VISIBLE
binding.tvTag.text = response.tag
} else {
binding.tvTag.visibility = View.GONE
}
binding.ivLike.setImageResource(
if (response.isLike) {
R.drawable.ic_audio_content_heart_pressed
} else {
R.drawable.ic_audio_content_heart_normal
}
)
binding.tvLike.text = "${response.likeCount}"
binding.llLike.setOnClickListener {
viewModel.likeContent(contentId = audioContentId) {
val likeCount = binding.tvLike.text.toString().toInt()
if (it) {
binding.tvLike.text = "${likeCount + 1}"
binding.ivLike.setImageResource(R.drawable.ic_audio_content_heart_pressed)
} else {
binding.tvLike.text = if (likeCount - 1 < 0) {
"0"
} else {
"${likeCount - 1}"
}
binding.ivLike.setImageResource(R.drawable.ic_audio_content_heart_normal)
}
}
}
binding.tvShare.setOnClickListener {
viewModel.shareAudioContent(
audioContentId = audioContentId,
contentImage = response.coverImageUrl,
contentTitle = "${response.title} - ${response.creator.nickname}"
) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_TEXT, it)
val shareIntent = Intent.createChooser(intent, "오디오콘텐츠 공유")
startActivity(shareIntent)
}
}
}
private fun setupMosaicArea(isMosaic: Boolean) {
if (isMosaic) {
binding.alert19Bg.visibility = View.VISIBLE
binding.llAlert19.visibility = View.VISIBLE
binding.tvAuth.setOnClickListener {
Auth.auth(this, applicationContext) { data ->
val bootpayResponse = Gson().fromJson(data, BootpayResponse::class.java)
val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
runOnUiThread {
viewModel.authVerify(audioContentId = audioContentId, request = request)
}
}
}
} else {
binding.alert19Bg.visibility = View.GONE
binding.llAlert19.visibility = View.GONE
binding.tvAuth.setOnClickListener {}
}
}
private fun setupCreatorArea(creator: AudioContentCreator) {
binding.rlProfile.setOnClickListener {
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, creator.creatorId)
}
)
}
binding.ivProfile.load(creator.profileImageUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
binding.tvProfileNickname.text = creator.nickname
if (creator.creatorId != SharedPreferenceManager.userId) {
binding.ivFollow.visibility = View.VISIBLE
if (creator.isFollowing) {
binding.ivFollow.setImageResource(R.drawable.btn_notification_selected)
binding.ivFollow.setOnClickListener {
viewModel.unRegisterNotification(
contentId = audioContentId,
creatorId = creator.creatorId
)
}
} else {
binding.ivFollow.setImageResource(R.drawable.btn_notification)
binding.ivFollow.setOnClickListener {
viewModel.registerNotification(
contentId = audioContentId,
creatorId = creator.creatorId
)
}
}
} else {
binding.ivFollow.visibility = View.GONE
}
}
private fun showOrderDialog(audioContent: GetAudioContentDetailResponse) {
val dialog = AudioContentOrderFragment(
price = audioContent.price,
onClickKeep = { showOrderConfirmDialog(audioContent, OrderType.KEEP) },
onClickRental = { showOrderConfirmDialog(audioContent, OrderType.RENTAL) }
)
dialog.show(
supportFragmentManager,
dialog.tag
)
}
private fun showOrderConfirmDialog(
audioContent: GetAudioContentDetailResponse,
orderType: OrderType
) {
AudioContentOrderConfirmDialog(
activity = this,
layoutInflater = layoutInflater,
title = audioContent.title,
theme = audioContent.themeStr,
coverImageUrl = audioContent.coverImageUrl,
isAdult = audioContent.isAdult,
profileImageUrl = audioContent.creator.profileImageUrl,
nickname = audioContent.creator.nickname,
duration = audioContent.duration,
orderType = orderType,
price = audioContent.price,
confirmButtonClick = {
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.CONDITIONAL_STOP.name
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.contentId)
}
)
binding.rlPreviewAlert.visibility = View.GONE
viewModel.order(
contentId = audioContent.contentId,
orderType = orderType
)
},
).show(screenWidth)
}
inner class AudioContentReceiver : BroadcastReceiver() {
@SuppressLint("SetTextI18n")
override fun onReceive(context: Context?, intent: Intent?) {
val nextAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getSerializableExtra(
Constants.EXTRA_AUDIO_CONTENT_NEXT_ACTION,
AudioContentPlayService.MusicAction::class.java
)
} else {
intent?.getSerializableExtra(Constants.EXTRA_AUDIO_CONTENT_NEXT_ACTION)
}
val duration = intent?.getIntExtra(Constants.EXTRA_AUDIO_CONTENT_DURATION, 0)
val progress = intent?.getIntExtra(Constants.EXTRA_AUDIO_CONTENT_PROGRESS, 0)
val contentId = intent?.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
val isPlaying = intent?.getBooleanExtra(Constants.EXTRA_AUDIO_CONTENT_PLAYING, false)
val changeUi = intent?.getBooleanExtra(Constants.EXTRA_AUDIO_CONTENT_CHANGE_UI, false)
val alertPreview = intent?.getBooleanExtra(
Constants.EXTRA_AUDIO_CONTENT_ALERT_PREVIEW,
false
)
val isLoading = intent?.getBooleanExtra(
Constants.EXTRA_AUDIO_CONTENT_LOADING,
false
)
viewModel.isLoading.value = isLoading ?: false
if (this@AudioContentDetailActivity.audioContentId == contentId) {
runOnUiThread {
if (changeUi != null && changeUi) {
binding.ivPlayOrPause.setImageResource(
if (isPlaying != null && isPlaying) {
R.drawable.btn_audio_content_pause
} else {
R.drawable.btn_audio_content_play
}
)
}
}
if (duration != null && duration > 0) {
binding.sbProgress.max = duration
binding.tvTotalDuration.text = " / ${Utils.convertDurationToString(duration)}"
}
if (progress != null && progress > 0) {
binding.sbProgress.progress = progress
binding.tvCurrentDuration.text = Utils.convertDurationToString(progress)
}
if (alertPreview != null && alertPreview && isAlertPreview) {
viewModel.toggleShowPreviewAlert()
}
if (nextAction != null) {
startService(
Intent(
this@AudioContentDetailActivity,
AudioContentPlayService::class.java
).apply {
action = (nextAction as AudioContentPlayService.MusicAction).name
}
)
}
}
}
}
}

View File

@ -0,0 +1,475 @@
package kr.co.vividnext.sodalive.audio_content.detail
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.firebase.dynamiclinks.ShortDynamicLink
import com.google.firebase.dynamiclinks.ktx.androidParameters
import com.google.firebase.dynamiclinks.ktx.dynamicLinks
import com.google.firebase.dynamiclinks.ktx.iosParameters
import com.google.firebase.dynamiclinks.ktx.shortLinkAsync
import com.google.firebase.dynamiclinks.ktx.socialMetaTagParameters
import com.google.firebase.ktx.Firebase
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.audio_content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.audio_content.order.OrderType
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.mypage.auth.AuthRepository
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.report.ReportRepository
import kr.co.vividnext.sodalive.report.ReportRequest
import kr.co.vividnext.sodalive.report.ReportType
class AudioContentDetailViewModel(
private val repository: AudioContentRepository,
private val authRepository: AuthRepository,
private val reportRepository: ReportRepository,
private val commentRepository: AudioContentCommentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
var isLoading = MutableLiveData(false)
private set
private var _audioContentLiveData = MutableLiveData<GetAudioContentDetailResponse>()
val audioContentLiveData: LiveData<GetAudioContentDetailResponse>
get() = _audioContentLiveData
private val _isExpandDetail = MutableLiveData(false)
val isExpandDetail: LiveData<Boolean>
get() = _isExpandDetail
private val _isShowPreviewAlert = MutableLiveData(false)
val isShowPreviewAlert: LiveData<Boolean>
get() = _isShowPreviewAlert
private val _isContentPlayLoopLiveData = MutableLiveData(
SharedPreferenceManager.isContentPlayLoop
)
val isContentPlayLoopLiveData: LiveData<Boolean>
get() = _isContentPlayLoopLiveData
fun getAudioContentDetail(audioContentId: Long, onFailure: (() -> Unit)? = null) {
if (!isLoading.value!!) {
isLoading.value = true
}
compositeDisposable.add(
repository.getAudioContentDetail(
audioContentId = audioContentId,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_audioContentLiveData.postValue(it.data!!)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
if (onFailure != null) {
onFailure()
}
}
isLoading.value = false
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
if (onFailure != null) {
onFailure()
}
}
)
)
}
fun registerNotification(contentId: Long, creatorId: Long) {
isLoading.value = true
compositeDisposable.add(
repository.registerNotification(
creatorId,
"Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
getAudioContentDetail(contentId)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
isLoading.value = false
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun unRegisterNotification(contentId: Long, creatorId: Long) {
isLoading.value = true
compositeDisposable.add(
repository.unRegisterNotification(
creatorId,
"Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
getAudioContentDetail(contentId)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
isLoading.value = false
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun toggleExpandDetail() {
_isExpandDetail.value = !_isExpandDetail.value!!
}
fun toggleShowPreviewAlert() {
_isShowPreviewAlert.value = !_isShowPreviewAlert.value!!
}
fun order(contentId: Long, orderType: OrderType) {
isLoading.value = true
compositeDisposable.add(
repository.orderContent(
contentId = contentId,
orderType = orderType,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
getAudioContentDetail(audioContentId = contentId)
_toastLiveData.postValue("구매가 완료되었습니다.")
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
isLoading.value = false
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun authVerify(audioContentId: Long, request: AuthVerifyRequest) {
if (!isLoading.value!!) {
isLoading.value = true
}
compositeDisposable.add(
authRepository.verify(request, "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
getAudioContentDetail(audioContentId)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
isLoading.value = false
}
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun registerComment(audioContentId: Long, comment: String) {
if (!isLoading.value!!) {
isLoading.value = true
}
compositeDisposable.add(
commentRepository.registerComment(
contentId = audioContentId,
comment = comment,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
getAudioContentDetail(audioContentId)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
isLoading.value = false
}
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun likeContent(contentId: Long, onSuccess: (Boolean) -> Unit) {
if (!isLoading.value!!) {
isLoading.value = true
}
compositeDisposable.add(
repository.likeContent(
request = PutAudioContentLikeRequest(contentId = contentId),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
isLoading.value = false
if (it.success) {
if (it.data != null) {
onSuccess(it.data.like)
} else {
getAudioContentDetail(contentId)
}
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun shareAudioContent(
audioContentId: Long,
contentImage: String,
contentTitle: String,
onSuccess: (String) -> Unit
) {
isLoading.value = true
Firebase.dynamicLinks.shortLinkAsync(ShortDynamicLink.Suffix.SHORT) {
link = Uri.parse("https://yozm.day/?audio_content_id=$audioContentId")
domainUriPrefix = "https://yozm.page.link"
androidParameters { }
iosParameters("kr.co.vividnext.yozm") {
appStoreId = "1630284226"
}
socialMetaTagParameters {
title = contentTitle
description = "지금 요즘라이브에서 이 콘텐츠 감상하기"
imageUrl = contentImage.toUri()
}
}.addOnSuccessListener {
val uri = it.shortLink
if (uri != null) {
val message = uri.toString()
onSuccess(message)
} else {
_toastLiveData.postValue("공유링크를 생성하지 못했습니다.\n다시 시도해 주세요.")
}
}.addOnFailureListener {
_toastLiveData.postValue("공유링크를 생성하지 못했습니다.\n다시 시도해 주세요.")
}.addOnCompleteListener {
isLoading.value = false
}
}
fun deleteAudioContent(audioContentId: Long, onSuccess: () -> Unit) {
isLoading.value = true
compositeDisposable.add(
repository.deleteAudioContent(
audioContentId,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
isLoading.value = false
if (it.success) {
_toastLiveData.postValue(
"삭제되었습니다."
)
onSuccess()
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun report(type: ReportType, contentId: Long, reason: String) {
isLoading.value = true
val request = ReportRequest(type = type, reason = reason, contentId = contentId)
compositeDisposable.add(
reportRepository.report(
request = request,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"신고가 접수되었습니다."
)
}
isLoading.value = false
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("신고가 접수되었습니다.")
}
)
)
}
fun togglePlayLoop() {
val isPlayLoop = !SharedPreferenceManager.isContentPlayLoop
SharedPreferenceManager.isContentPlayLoop = isPlayLoop
_isContentPlayLoopLiveData.value = isPlayLoop
}
fun donation(contentId: Long, can: Int, comment: String, onSuccess: () -> Unit) {
isLoading.value = true
compositeDisposable.add(
repository.donation(
contentId = contentId,
can = can,
comment = comment,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
isLoading.value = false
if (it.success) {
SharedPreferenceManager.can -= can
_toastLiveData.postValue(
"${can.moneyFormat()}캔을 후원하였습니다."
)
onSuccess()
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.audio_content.detail
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import android.view.WindowManager
import android.widget.RadioButton
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import kr.co.vividnext.sodalive.databinding.DialogAudioContentReportBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class AudioContentReportDialog(
activity: Activity,
layoutInflater: LayoutInflater,
confirmButtonClick: (String) -> Unit
) {
private val alertDialog: AlertDialog
val dialogView = DialogAudioContentReportBinding.inflate(layoutInflater)
var reason = ""
init {
val dialogBuilder = AlertDialog.Builder(activity)
dialogBuilder.setView(dialogView.root)
alertDialog = dialogBuilder.create()
alertDialog.setCancelable(false)
alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialogView.tvCancel.setOnClickListener {
alertDialog.dismiss()
}
dialogView.tvReport.setOnClickListener {
if (reason.isNotBlank()) {
alertDialog.dismiss()
confirmButtonClick(reason)
} else {
Toast.makeText(activity, "신고 이유를 선택하세요.", Toast.LENGTH_LONG).show()
}
}
dialogView.radioGroup.setOnCheckedChangeListener { radioGroup, checkedId ->
val radioButton = radioGroup.findViewById<RadioButton>(checkedId)
reason = radioButton.text.toString()
}
}
fun show(width: Int) {
alertDialog.show()
val lp = WindowManager.LayoutParams()
lp.copyFrom(alertDialog.window?.attributes)
lp.width = width - (26.7f.dpToPx()).toInt()
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
alertDialog.window?.attributes = lp
}
}

View File

@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.audio_content.detail
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.audio_content.comment.GetAudioContentCommentListItem
import kr.co.vividnext.sodalive.audio_content.order.OrderType
data class GetAudioContentDetailResponse(
@SerializedName("contentId") val contentId: Long,
@SerializedName("title") val title: String,
@SerializedName("detail") val detail: String,
@SerializedName("coverImageUrl") val coverImageUrl: String,
@SerializedName("contentUrl") val contentUrl: String,
@SerializedName("themeStr") val themeStr: String,
@SerializedName("tag") val tag: String,
@SerializedName("price") val price: Int,
@SerializedName("duration") val duration: String,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("isMosaic") val isMosaic: Boolean,
@SerializedName("existOrdered") val existOrdered: Boolean,
@SerializedName("orderType") val orderType: OrderType?,
@SerializedName("remainingTime") val remainingTime: String?,
@SerializedName("creatorOtherContentList")
val creatorOtherContentList: List<OtherContentResponse>,
@SerializedName("sameThemeOtherContentList")
val sameThemeOtherContentList: List<OtherContentResponse>,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean,
@SerializedName("isLike") val isLike: Boolean,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("commentList") val commentList: List<GetAudioContentCommentListItem>,
@SerializedName("commentCount") val commentCount: Int,
@SerializedName("creator") val creator: AudioContentCreator
)
data class OtherContentResponse(
@SerializedName("contentId") val contentId: Long,
@SerializedName("title") val title: String,
@SerializedName("coverUrl") val coverUrl: String,
)
data class AudioContentCreator(
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("isFollowing") val isFollowing: Boolean
)

View File

@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.audio_content.detail
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioOtherContentBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class OtherContentAdapter(
private val onClickItem: (Long) -> Unit
) : RecyclerView.Adapter<OtherContentAdapter.ViewHolder>() {
val items = mutableListOf<OtherContentResponse>()
inner class ViewHolder(
private val binding: ItemAudioOtherContentBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: OtherContentResponse) {
binding.ivCover.load(item.coverUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(2.7f.dpToPx()))
}
binding.tvTitle.text = item.title
binding.root.setOnClickListener { onClickItem(item.contentId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemAudioOtherContentBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.count()
}

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.audio_content.detail
import com.google.gson.annotations.SerializedName
data class PutAudioContentLikeRequest(
@SerializedName("contentId") val contentId: Long
)
data class PutAudioContentLikeResponse(
@SerializedName("like") val like: Boolean
)

View File

@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.audio_content.donation
import com.google.gson.annotations.SerializedName
data class AudioContentDonationRequest(
@SerializedName("contentId") val contentId: Long,
@SerializedName("donationCan") val donationCan: Int,
@SerializedName("comment") val comment: String,
@SerializedName("container") val container: String = "aos"
)

View File

@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.widget.FrameLayout
import android.widget.ImageView
import coil.load
import coil.transform.RoundedCornersTransformation
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.bannerview.BaseViewHolder
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.dpToPx
class AudioContentMainBannerAdapter(
private val itemWidth: Int,
private val itemHeight: Int,
private val onClick: (GetAudioContentBannerResponse) -> Unit
) : BaseBannerAdapter<GetAudioContentBannerResponse>() {
override fun bindData(
holder: BaseViewHolder<GetAudioContentBannerResponse>,
data: GetAudioContentBannerResponse,
position: Int,
pageSize: Int
) {
val ivBanner = holder.findViewById<ImageView>(R.id.iv_recommend_live)
val layoutParams = ivBanner.layoutParams as FrameLayout.LayoutParams
layoutParams.width = itemWidth
layoutParams.height = itemHeight
ivBanner.load(data.thumbnailImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(RoundedCornersTransformation(5.3f.dpToPx()))
}
ivBanner.layoutParams = layoutParams
ivBanner.setOnClickListener { onClick(data) }
}
override fun getLayoutId(viewType: Int): Int {
return R.layout.item_recommend_live
}
}

View File

@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.databinding.ItemAudioContentMainBinding
class AudioContentMainContentAdapter(
private val onClickItem: (Long) -> Unit,
private val onClickCreator: (Long) -> Unit,
) : RecyclerView.Adapter<AudioContentMainItemViewHolder>() {
private val items = mutableListOf<GetAudioContentMainItem>()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
) = AudioContentMainItemViewHolder(
ItemAudioContentMainBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
),
onClickItem = onClickItem,
onClickCreator = onClickCreator
)
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: AudioContentMainItemViewHolder, position: Int) {
holder.bind(items[position])
}
@SuppressLint("NotifyDataSetChanged")
fun addItems(items: List<GetAudioContentMainItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
}

View File

@ -0,0 +1,94 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.databinding.ItemAudioContentMainCurationBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class AudioContentMainCurationAdapter(
private val onClickItem: (Long) -> Unit,
private val onClickCreator: (Long) -> Unit,
) : RecyclerView.Adapter<AudioContentMainCurationAdapter.ViewHolder>() {
private val items = mutableListOf<GetAudioContentCurationResponse>()
inner class ViewHolder(
private val context: Context,
private val binding: ItemAudioContentMainCurationBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetAudioContentCurationResponse) {
binding.tvTitle.text = item.title
binding.tvDesc.text = item.description
setAudioContentList(item.audioContents)
}
private fun setAudioContentList(audioContents: List<GetAudioContentMainItem>) {
val adapter = AudioContentMainContentAdapter(onClickItem, onClickCreator)
binding.rvCuration.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
if (binding.rvCuration.itemDecorationCount == 0) {
binding.rvCuration.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 = 6.7f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
}
}
}
})
}
binding.rvCuration.adapter = adapter
adapter.addItems(audioContents)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
parent.context,
ItemAudioContentMainCurationBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.size
@SuppressLint("NotifyDataSetChanged")
fun addItems(items: List<GetAudioContentCurationResponse>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
}

View File

@ -0,0 +1,470 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.app.Service
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.LinearLayout
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.order.AudioContentOrderListActivity
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentMainBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.koin.android.ext.android.inject
import kotlin.math.roundToInt
class AudioContentMainFragment : BaseFragment<FragmentAudioContentMainBinding>(
FragmentAudioContentMainBinding::inflate
) {
private val viewModel: AudioContentMainViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var imm: InputMethodManager
private lateinit var newContentCreatorAdapter: AudioContentMainNewContentCreatorAdapter
private lateinit var bannerAdapter: AudioContentMainBannerAdapter
private lateinit var orderListAdapter: AudioContentMainContentAdapter
private lateinit var newContentThemeAdapter: AudioContentMainNewContentThemeAdapter
private lateinit var newContentAdapter: AudioContentMainContentAdapter
private lateinit var curationAdapter: AudioContentMainCurationAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
imm = requireContext().getSystemService(
Service.INPUT_METHOD_SERVICE
) as InputMethodManager
setupView()
bindData()
viewModel.getMain()
}
private fun setupView() {
if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
binding.llUploadContent.visibility = View.VISIBLE
binding.llUploadContent.setOnClickListener {
startActivity(
Intent(
requireActivity(),
AudioContentUploadActivity::class.java
)
)
}
} else {
binding.llUploadContent.visibility = View.GONE
}
setupNewContentCreator()
setupBanner()
setupOrderList()
setupNewContentTheme()
setupNewContent()
setupCuration()
binding.swipeRefreshLayout.setOnRefreshListener {
binding.swipeRefreshLayout.isRefreshing = false
viewModel.getMain()
}
}
private fun setupNewContentCreator() {
newContentCreatorAdapter = AudioContentMainNewContentCreatorAdapter {
val intent = Intent(requireContext(), UserProfileActivity::class.java)
intent.putExtra(Constants.EXTRA_USER_ID, it)
startActivity(intent)
}
binding.rvNewContentCreator.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.rvNewContentCreator.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 = 10.7f.dpToPx().toInt()
}
orderListAdapter.itemCount - 1 -> {
outRect.left = 10.7f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 10.7f.dpToPx().toInt()
outRect.right = 10.7f.dpToPx().toInt()
}
}
}
})
binding.rvNewContentCreator.adapter = newContentCreatorAdapter
}
private fun setupBanner() {
val layoutParams = binding
.rvBanner
.layoutParams as LinearLayout.LayoutParams
val pagerWidth = screenWidth.toDouble() - 26.7f.dpToPx()
val pagerHeight = (pagerWidth * 0.53).roundToInt()
layoutParams.width = pagerWidth.roundToInt()
layoutParams.height = pagerHeight
bannerAdapter = AudioContentMainBannerAdapter(pagerWidth.roundToInt(), pagerHeight) {
when (it.type) {
AudioContentBannerType.EVENT -> {
startActivity(
Intent(requireContext(), EventDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_EVENT, it.eventItem!!)
}
)
}
AudioContentBannerType.CREATOR -> {
startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, it.creatorId!!)
}
)
}
AudioContentBannerType.LINK -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.link!!)))
}
}
}
binding
.rvBanner
.layoutParams = layoutParams
binding.rvBanner.apply {
adapter = bannerAdapter as BaseBannerAdapter<Any>
setLifecycleRegistry(lifecycle)
setScrollDuration(1000)
setInterval(4 * 1000)
}.create()
binding
.rvBanner
.setIndicatorView(binding.indicatorBanner)
.setIndicatorStyle(IndicatorStyle.ROUND_RECT)
.setIndicatorSlideMode(IndicatorSlideMode.SMOOTH)
.setIndicatorVisibility(View.GONE)
.setIndicatorSliderColor(
ContextCompat.getColor(requireContext(), R.color.color_909090),
ContextCompat.getColor(requireContext(), R.color.color_9970ff)
)
.setIndicatorSliderWidth(4f.dpToPx().toInt(), 10f.dpToPx().toInt())
.setIndicatorHeight(4f.dpToPx().toInt())
}
private fun setupOrderList() {
orderListAdapter = AudioContentMainContentAdapter(
onClickItem = {
startActivity(
Intent(requireContext(), AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
}
)
},
onClickCreator = {
startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, it)
}
)
}
)
binding.rvMyStash.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.rvMyStash.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 = 6.7f.dpToPx().toInt()
}
orderListAdapter.itemCount - 1 -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
}
}
}
})
binding.rvMyStash.adapter = orderListAdapter
binding.tvMyStashViewAll.setOnClickListener {
startActivity(Intent(requireContext(), AudioContentOrderListActivity::class.java))
}
}
private fun setupNewContentTheme() {
newContentThemeAdapter = AudioContentMainNewContentThemeAdapter {
viewModel.getNewContentOfTheme(theme = it)
}
binding.rvNewContentTheme.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.rvNewContentTheme.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 4f.dpToPx().toInt()
}
orderListAdapter.itemCount - 1 -> {
outRect.left = 4f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 4f.dpToPx().toInt()
outRect.right = 4f.dpToPx().toInt()
}
}
}
})
binding.rvNewContentTheme.adapter = newContentThemeAdapter
}
private fun setupNewContent() {
newContentAdapter = AudioContentMainContentAdapter(
onClickItem = {
startActivity(
Intent(requireContext(), AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
}
)
},
onClickCreator = {
startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, it)
}
)
}
)
binding.rvNewContent.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.rvNewContent.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 = 6.7f.dpToPx().toInt()
}
orderListAdapter.itemCount - 1 -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
}
}
}
})
binding.rvNewContent.adapter = newContentAdapter
}
private fun setupCuration() {
curationAdapter = AudioContentMainCurationAdapter(
onClickItem = {
startActivity(
Intent(requireContext(), AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
}
)
},
onClickCreator = {
startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, it)
}
)
}
)
binding.rvCuration.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.VERTICAL,
false
)
binding.rvCuration.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.top = 40f.dpToPx().toInt()
outRect.bottom = 20f.dpToPx().toInt()
}
curationAdapter.itemCount - 1 -> {
outRect.top = 20f.dpToPx().toInt()
outRect.bottom = 40f.dpToPx().toInt()
}
else -> {
outRect.top = 20f.dpToPx().toInt()
outRect.bottom = 20f.dpToPx().toInt()
}
}
}
})
binding.rvCuration.adapter = curationAdapter
}
private fun bindData() {
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(viewLifecycleOwner) {
it?.let { Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show() }
}
viewModel.newContentUploadCreatorListLiveData.observe(viewLifecycleOwner) {
newContentCreatorAdapter.addItems(it)
binding.rvNewContentCreator.visibility = if (
newContentCreatorAdapter.itemCount <= 0 && it.isEmpty()
) {
View.GONE
} else {
View.VISIBLE
}
}
viewModel.bannerLiveData.observe(viewLifecycleOwner) {
if (bannerAdapter.itemCount <= 0 && it.isEmpty()) {
binding.rvBanner.visibility = View.GONE
binding.indicatorBanner.visibility = View.GONE
} else {
binding.rvBanner.visibility = View.VISIBLE
binding.indicatorBanner.visibility = View.VISIBLE
binding.rvBanner.refreshData(it)
}
}
viewModel.orderListLiveData.observe(viewLifecycleOwner) {
orderListAdapter.addItems(it)
binding.llMyStash.visibility = if (
orderListAdapter.itemCount <= 0 && it.isEmpty()
) {
View.GONE
} else {
View.VISIBLE
}
}
viewModel.newContentListLiveData.observe(viewLifecycleOwner) {
newContentAdapter.addItems(it)
}
viewModel.themeListLiveData.observe(viewLifecycleOwner) {
binding.llNewContent.visibility = View.VISIBLE
newContentThemeAdapter.addItems(it)
}
viewModel.curationListLiveData.observe(viewLifecycleOwner) {
curationAdapter.addItems(it)
binding.rvCuration.visibility = if (
curationAdapter.itemCount <= 0 && it.isEmpty()
) {
View.GONE
} else {
View.VISIBLE
}
}
}
}

View File

@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentMainBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class AudioContentMainItemViewHolder(
private val binding: ItemAudioContentMainBinding,
private val onClickItem: (Long) -> Unit,
private val onClickCreator: (Long) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetAudioContentMainItem) {
binding.ivAudioContentCoverImage.load(item.coverImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(RoundedCornersTransformation(2.7f.dpToPx()))
}
binding.ivAudioContentCreator.load(item.creatorProfileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
binding.tvAudioContentTitle.text = item.title
binding.tvAudioContentCreatorNickname.text = item.creatorNickname
binding.ivAudioContentCreator.setOnClickListener { onClickCreator(item.creatorId) }
binding.iv19.visibility = if (item.isAdult) {
View.VISIBLE
} else {
View.GONE
}
binding.root.setOnClickListener { onClickItem(item.contentId) }
}
}

View File

@ -0,0 +1,52 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentMainNewContentCreatorBinding
class AudioContentMainNewContentCreatorAdapter(
private val onClickItem: (Long) -> Unit
) : RecyclerView.Adapter<AudioContentMainNewContentCreatorAdapter.ViewHolder>() {
private val items = mutableListOf<GetNewContentUploadCreator>()
inner class ViewHolder(
private val binding: ItemAudioContentMainNewContentCreatorBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetNewContentUploadCreator) {
binding.tvNewContentCreator.text = item.creatorNickname
binding.ivNewContentCreator.load(item.creatorProfileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
binding.root.setOnClickListener { onClickItem(item.creatorId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemAudioContentMainNewContentCreatorBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
@SuppressLint("NotifyDataSetChanged")
fun addItems(items: List<GetNewContentUploadCreator>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
}

View File

@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentMainNewContentThemeBinding
class AudioContentMainNewContentThemeAdapter(
private val onClickItem: (String) -> Unit
) : RecyclerView.Adapter<AudioContentMainNewContentThemeAdapter.ViewHolder>() {
private val themeList = mutableListOf<String>()
private var selectedTheme = ""
inner class ViewHolder(
private val context: Context,
private val binding: ItemAudioContentMainNewContentThemeBinding
) : RecyclerView.ViewHolder(binding.root) {
@SuppressLint("NotifyDataSetChanged")
fun bind(theme: String) {
if (theme == selectedTheme || (selectedTheme == "" && theme == "전체")) {
binding.tvTheme.setBackgroundResource(
R.drawable.bg_round_corner_16_7_transparent_9970ff
)
binding.tvTheme.setTextColor(ContextCompat.getColor(context, R.color.color_9970ff))
} else {
binding.tvTheme.setBackgroundResource(
R.drawable.bg_round_corner_16_7_transparent_777777
)
binding.tvTheme.setTextColor(ContextCompat.getColor(context, R.color.color_777777))
}
binding.tvTheme.text = theme
binding.root.setOnClickListener {
onClickItem(theme)
selectedTheme = theme
notifyDataSetChanged()
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun addItems(themeList: List<String>) {
this.selectedTheme = ""
this.themeList.clear()
this.themeList.addAll(themeList)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
parent.context,
ItemAudioContentMainNewContentThemeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun getItemCount() = themeList.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(themeList[position])
}
}

View File

@ -0,0 +1,121 @@
package kr.co.vividnext.sodalive.audio_content.main
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class AudioContentMainViewModel(
private val repository: AudioContentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private var _newContentUploadCreatorListLiveData =
MutableLiveData<List<GetNewContentUploadCreator>>()
val newContentUploadCreatorListLiveData: LiveData<List<GetNewContentUploadCreator>>
get() = _newContentUploadCreatorListLiveData
private var _newContentListLiveData = MutableLiveData<List<GetAudioContentMainItem>>()
val newContentListLiveData: LiveData<List<GetAudioContentMainItem>>
get() = _newContentListLiveData
private var _bannerLiveData = MutableLiveData<List<GetAudioContentBannerResponse>>()
val bannerLiveData: LiveData<List<GetAudioContentBannerResponse>>
get() = _bannerLiveData
private var _orderListLiveData = MutableLiveData<List<GetAudioContentMainItem>>()
val orderListLiveData: LiveData<List<GetAudioContentMainItem>>
get() = _orderListLiveData
private var _themeListLiveData = MutableLiveData<List<String>>()
val themeListLiveData: LiveData<List<String>>
get() = _themeListLiveData
private var _curationListLiveData = MutableLiveData<List<GetAudioContentCurationResponse>>()
val curationListLiveData: LiveData<List<GetAudioContentCurationResponse>>
get() = _curationListLiveData
fun getMain() {
_isLoading.value = true
compositeDisposable.add(
repository.getMain(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
val data = it.data
_newContentUploadCreatorListLiveData.value =
data.newContentUploadCreatorList
_newContentListLiveData.value = data.newContentList
_orderListLiveData.value = data.orderList
_bannerLiveData.value = data.bannerList
_curationListLiveData.value = data.curationList
val themeList = listOf("전체").union(data.themeList).toList()
_themeListLiveData.value = themeList
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun getNewContentOfTheme(theme: String) {
compositeDisposable.add(
repository.getNewContentOfTheme(
theme = if (theme == "전체") {
""
} else {
theme
},
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_newContentListLiveData.value = it.data!!
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.audio_content.main
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.settings.event.EventItem
data class GetAudioContentMainResponse(
@SerializedName("newContentUploadCreatorList")
val newContentUploadCreatorList: List<GetNewContentUploadCreator>,
@SerializedName("bannerList") val bannerList: List<GetAudioContentBannerResponse>,
@SerializedName("orderList") val orderList: List<GetAudioContentMainItem>,
@SerializedName("themeList") val themeList: List<String>,
@SerializedName("newContentList") val newContentList: List<GetAudioContentMainItem>,
@SerializedName("curationList") val curationList: List<GetAudioContentCurationResponse>
)
data class GetNewContentUploadCreator(
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileImageUrl") val creatorProfileImageUrl: String
)
data class GetAudioContentMainItem(
@SerializedName("contentId") val contentId: Long,
@SerializedName("coverImageUrl") val coverImageUrl: String,
@SerializedName("title") val title: String,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorProfileImageUrl") val creatorProfileImageUrl: String,
@SerializedName("creatorNickname") val creatorNickname: String
)
data class GetAudioContentCurationResponse(
@SerializedName("title") val title: String,
@SerializedName("description") val description: String,
@SerializedName("audioContents") val audioContents: List<GetAudioContentMainItem>
)
data class GetAudioContentBannerResponse(
@SerializedName("type") val type: AudioContentBannerType,
@SerializedName("thumbnailImageUrl") val thumbnailImageUrl: String,
@SerializedName("eventItem") val eventItem: EventItem?,
@SerializedName("creatorId") val creatorId: Long?,
@SerializedName("link") val link: String?
)
enum class AudioContentBannerType {
@SerializedName("EVENT")
EVENT,
@SerializedName("CREATOR")
CREATOR,
@SerializedName("LINK")
LINK
}

View File

@ -0,0 +1,288 @@
package kr.co.vividnext.sodalive.audio_content.modify
import android.Manifest
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.setPadding
import coil.load
import coil.transform.RoundedCornersTransformation
import com.github.dhaval2404.imagepicker.ImagePicker
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
import com.jakewharton.rxbinding4.widget.textChanges
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.RealPathUtil
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentModifyBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class AudioContentModifyActivity : BaseActivity<ActivityAudioContentModifyBinding>(
ActivityAudioContentModifyBinding::inflate
) {
private val viewModel: AudioContentModifyViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private val imageResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == RESULT_OK) {
val fileUri = data?.data
if (fileUri != null) {
binding.ivCover.setPadding(0)
binding.ivCover.background = null
binding.ivCover.load(fileUri) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(13.3f.dpToPx()))
}
viewModel.coverImageUri = fileUri
} else {
Toast.makeText(
this,
"잘못된 파일입니다.\n다시 선택해 주세요.",
Toast.LENGTH_SHORT
).show()
}
} else if (resultCode == ImagePicker.RESULT_ERROR) {
Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
if (audioContentId <= 0) {
Toast.makeText(applicationContext, "잘못된 요청입니다.", Toast.LENGTH_LONG).show()
finish()
}
checkPermissions()
viewModel.getRealPathFromURI = {
RealPathUtil.getRealPath(applicationContext, it)
}
bindData()
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = "콘텐츠 수정"
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.ivPhotoPicker.setOnClickListener {
ImagePicker.with(this)
.crop()
.galleryOnly()
.galleryMimeTypes( // Exclude gif images
mimeTypes = arrayOf(
"image/png",
"image/jpg",
"image/jpeg"
)
)
.createIntent { imageResult.launch(it) }
}
binding.llCommentNo.setOnClickListener { viewModel.setAvailableComment(false) }
binding.llCommentYes.setOnClickListener { viewModel.setAvailableComment(true) }
binding.tvCancel.setOnClickListener { finish() }
binding.tvModify.setOnClickListener {
viewModel.modifyAudioContent { finish() }
}
}
private fun checkPermissions() {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(Manifest.permission.READ_MEDIA_AUDIO, Manifest.permission.READ_MEDIA_IMAGES)
} else {
listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
TedPermission.create()
.setPermissionListener(object : PermissionListener {
override fun onPermissionGranted() {
}
override fun onPermissionDenied(deniedPermissions: MutableList<String>?) {
finish()
}
})
.setDeniedMessage(R.string.read_storage_permission_denied_message)
.setPermissions(*permissions.toTypedArray())
.check()
}
@SuppressLint("SetTextI18n")
private fun bindData() {
compositeDisposable.add(
binding.etTitle.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.title = it.toString()
}
)
compositeDisposable.add(
binding.etDetail.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
binding.tvNumberOfCharacters.text = "${it.length}"
viewModel.detail = it.toString()
}
)
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.isAvailableCommentLiveData.observe(this) {
if (it) {
binding.ivCommentYes.visibility = View.VISIBLE
binding.tvCommentYes.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llCommentYes.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.ivCommentNo.visibility = View.GONE
binding.tvCommentNo.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.llCommentNo.setBackgroundResource(
R.drawable.bg_round_corner_6_7_1f1734_9970ff
)
} else {
binding.ivCommentNo.visibility = View.VISIBLE
binding.tvCommentNo.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llCommentNo.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.ivCommentYes.visibility = View.GONE
binding.tvCommentYes.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.llCommentYes
.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734_9970ff)
}
}
viewModel.isAdultShowUiLiveData.observe(this) {
if (it) {
binding.llSetAdult.visibility = View.VISIBLE
binding.llAgeAll.setOnClickListener {
viewModel.setAdult(false)
}
binding.llAge19.setOnClickListener {
viewModel.setAdult(true)
}
viewModel.isAdultLiveData.observe(this) {
if (it) {
binding.ivAgeAll.visibility = View.GONE
binding.llAgeAll.setBackgroundResource(
R.drawable.bg_round_corner_6_7_1f1734
)
binding.tvAgeAll.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.ivAge19.visibility = View.VISIBLE
binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.tvAge19.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
} else {
binding.ivAge19.visibility = View.GONE
binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734)
binding.tvAge19.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.ivAgeAll.visibility = View.VISIBLE
binding.llAgeAll.setBackgroundResource(
R.drawable.bg_round_corner_6_7_9970ff
)
binding.tvAgeAll.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
}
}
} else {
binding.llSetAdult.visibility = View.GONE
}
}
viewModel.coverImageLiveData.observe(this) {
binding.ivCover.setPadding(0)
binding.ivCover.background = null
binding.ivCover.load(it) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(13.3f.dpToPx()))
}
}
viewModel.titleLiveData.observe(this) {
binding.etTitle.setText(it)
}
viewModel.detailLiveData.observe(this) {
binding.etDetail.setText(it)
}
}
}

View File

@ -0,0 +1,212 @@
package kr.co.vividnext.sodalive.audio_content.modify
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import java.io.File
class AudioContentModifyViewModel(
private val repository: AudioContentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _isAdultLiveData = MutableLiveData(false)
val isAdultLiveData: LiveData<Boolean>
get() = _isAdultLiveData
private val _isAvailableCommentLiveData = MutableLiveData(false)
val isAvailableCommentLiveData: LiveData<Boolean>
get() = _isAvailableCommentLiveData
private val _titleLiveData = MutableLiveData("")
val titleLiveData: LiveData<String>
get() = _titleLiveData
private val _detailLiveData = MutableLiveData("")
val detailLiveData: LiveData<String>
get() = _detailLiveData
private val _coverImageLiveData = MutableLiveData("")
val coverImageLiveData: LiveData<String>
get() = _coverImageLiveData
private val _isAdultShowUiLiveData = MutableLiveData(true)
val isAdultShowUiLiveData: LiveData<Boolean>
get() = _isAdultShowUiLiveData
lateinit var getRealPathFromURI: (Uri) -> String?
var contentId: Long = 0
var title: String? = null
var detail: String? = null
var coverImageUri: Uri? = null
fun setAdult(isAdult: Boolean) {
_isAdultLiveData.postValue(isAdult)
}
fun setAvailableComment(isAvailableComment: Boolean) {
_isAvailableCommentLiveData.postValue(isAvailableComment)
}
fun getAudioContentDetail(audioContentId: Long, onFailure: (() -> Unit)? = null) {
this.contentId = audioContentId
_isLoading.value = true
compositeDisposable.add(
repository.getAudioContentDetail(
audioContentId = audioContentId,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_titleLiveData.value = it.data.title
_detailLiveData.value = it.data.detail
_coverImageLiveData.value = it.data.coverImageUrl
_isAvailableCommentLiveData.value = it.data.isCommentAvailable
_isAdultLiveData.value = it.data.isAdult
_isAdultShowUiLiveData.value = !it.data.isAdult
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
if (onFailure != null) {
onFailure()
}
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
if (onFailure != null) {
onFailure()
}
}
)
)
}
fun modifyAudioContent(onSuccess: () -> Unit) {
if (!_isLoading.value!! && contentId > 0 && validateData()) {
_isLoading.value = true
val request = ModifyAudioContentRequest(
contentId = contentId,
title = title,
detail = detail,
isAdult = _isAdultLiveData.value!!,
isCommentAvailable = _isAvailableCommentLiveData.value!!
)
val requestJson = Gson().toJson(request)
val coverImage = if (coverImageUri != null) {
val file = File(getRealPathFromURI(coverImageUri!!))
MultipartBody.Part.createFormData(
"coverImage",
file.name,
body = object : RequestBody() {
override fun contentType(): MediaType {
return "image/*".toMediaType()
}
override fun writeTo(sink: BufferedSink) {
file.inputStream().use { inputStream ->
val buffer = ByteArray(1024)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
}
}
}
override fun contentLength(): Long {
return file.length()
}
}
)
} else {
null
}
compositeDisposable.add(
repository.modifyAudioContent(
coverImage = coverImage,
request = requestJson.toRequestBody("text/plain".toMediaType()),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
_toastLiveData.postValue("수정되었습니다.")
onSuccess()
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
_isLoading.postValue(false)
},
{
_isLoading.postValue(false)
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
)
)
}
}
private fun validateData(): Boolean {
if (title != null && title!!.isBlank()) {
_toastLiveData.postValue("제목을 입력해 주세요.")
return false
}
if (detail != null && (detail!!.isBlank() || detail!!.length < 5)) {
_toastLiveData.postValue("내용을 5자 이상 입력해 주세요.")
return false
}
return true
}
}

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.audio_content.modify
import com.google.gson.annotations.SerializedName
data class ModifyAudioContentRequest(
@SerializedName("contentId") val contentId: Long,
@SerializedName("title") val title: String?,
@SerializedName("detail") val detail: String?,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean
)

View File

@ -0,0 +1,100 @@
package kr.co.vividnext.sodalive.audio_content.order
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import androidx.appcompat.app.AlertDialog
import coil.load
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.DialogAudioContentOrderConfirmBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kotlin.math.ceil
class AudioContentOrderConfirmDialog(
activity: Activity,
layoutInflater: LayoutInflater,
title: String,
theme: String,
coverImageUrl: String,
isAdult: Boolean,
profileImageUrl: String,
nickname: String,
duration: String,
orderType: OrderType,
price: Int,
confirmButtonClick: () -> Unit,
) {
private val alertDialog: AlertDialog
val dialogView = DialogAudioContentOrderConfirmBinding.inflate(layoutInflater)
init {
val dialogBuilder = AlertDialog.Builder(activity)
dialogBuilder.setView(dialogView.root)
alertDialog = dialogBuilder.create()
alertDialog.setCancelable(false)
alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialogView.tvTitle.text = title
dialogView.tvTheme.text = theme
dialogView.tvProfileNickname.text = nickname
dialogView.ivCover.load(coverImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(RoundedCornersTransformation(4f))
}
dialogView.ivProfile.load(profileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
dialogView.tvDuration.text = duration
dialogView.tvPrice.text = if (orderType == OrderType.RENTAL) {
"${ceil(price * 0.7).toInt()}"
} else {
"$price"
}
dialogView.iv19.visibility = if (isAdult) {
View.VISIBLE
} else {
View.GONE
}
dialogView.tvNotice.text = if (orderType == OrderType.RENTAL) {
"콘텐츠를 대여하시겠습니까?\n아래 코인이 차감됩니다."
} else {
"콘텐츠를 소장하시겠습니까?\n아래 코인이 차감됩니다."
}
dialogView.tvCancel.setOnClickListener {
alertDialog.dismiss()
}
dialogView.tvConfirm.setOnClickListener {
alertDialog.dismiss()
confirmButtonClick()
}
}
fun show(width: Int) {
alertDialog.show()
val lp = WindowManager.LayoutParams()
lp.copyFrom(alertDialog.window?.attributes)
lp.width = width - (26.7f.dpToPx()).toInt()
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
alertDialog.window?.attributes = lp
}
}

View File

@ -0,0 +1,44 @@
package kr.co.vividnext.sodalive.audio_content.order
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentOrderBinding
import kotlin.math.ceil
class AudioContentOrderFragment(
private val price: Int,
private val onClickRental: () -> Unit,
private val onClickKeep: () -> Unit
) : BottomSheetDialogFragment() {
private lateinit var binding: FragmentAudioContentOrderBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentAudioContentOrderBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.tvKeep.text = "$price"
binding.tvRental.text = "${ceil(price * 0.7).toInt()}"
binding.llKeep.setOnClickListener {
onClickKeep()
dismiss()
}
binding.llRental.setOnClickListener {
onClickRental()
dismiss()
}
}
}

View File

@ -0,0 +1,110 @@
package kr.co.vividnext.sodalive.audio_content.order
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
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.ActivityAudioContentOrderListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class AudioContentOrderListActivity : BaseActivity<ActivityAudioContentOrderListBinding>(
ActivityAudioContentOrderListBinding::inflate
) {
private val viewModel: AudioContentOrderListViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: AudioContentOrderListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
viewModel.getAudioContentOrderList { finish() }
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = "구매목록"
binding.toolbar.tvBack.setOnClickListener { finish() }
adapter = AudioContentOrderListAdapter {
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java)
.apply { putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it) }
)
}
binding.rvOrderList.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.VERTICAL,
false
)
binding.rvOrderList.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 13.3f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 13.3f.dpToPx().toInt()
}
else -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
}
}
})
binding.rvOrderList.adapter = adapter
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.orderList.observe(this) {
if (viewModel.page == 2) {
adapter.items.clear()
}
adapter.items.addAll(it)
adapter.notifyDataSetChanged()
}
}
}

View File

@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.audio_content.order
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentOrderListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
class AudioContentOrderListAdapter(
private val onItemClick: (Long) -> Unit
) : RecyclerView.Adapter<AudioContentOrderListAdapter.ViewHolder>() {
var items = mutableSetOf<GetAudioContentOrderListItem>()
inner class ViewHolder(
private val binding: ItemAudioContentOrderListBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetAudioContentOrderListItem) {
binding.ivCover.load(item.coverImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(RoundedCornersTransformation(5.3f.dpToPx()))
}
binding.tvTitle.text = item.title
binding.tvTheme.text = item.themeStr
binding.tvDuration.text = item.duration
binding.tvLikeCount.text = item.likeCount.moneyFormat()
binding.tvCommentCount.text = item.commentCount.moneyFormat()
if (item.orderType == OrderType.RENTAL) {
binding.tvRental.visibility = View.VISIBLE
binding.tvPurchased.visibility = View.GONE
} else {
binding.tvPurchased.visibility = View.VISIBLE
binding.tvRental.visibility = View.GONE
}
binding.root.setOnClickListener { onItemClick(item.contentId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemAudioContentOrderListBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items.toList()[position])
}
override fun getItemCount() = items.size
}

View File

@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.audio_content.order
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class AudioContentOrderListViewModel(
private val repository: AudioContentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private var _orderList = MutableLiveData<List<GetAudioContentOrderListItem>>()
val orderList: LiveData<List<GetAudioContentOrderListItem>>
get() = _orderList
private var isLast = false
var page = 1
private val size = 10
fun getAudioContentOrderList(onFailure: (() -> Unit)? = null) {
_isLoading.value = true
compositeDisposable.add(
repository.getAudioContentOrderList(
page = page,
size = size,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
if (it.data.items.isNotEmpty()) {
page += 1
_orderList.postValue(it.data.items)
} else {
isLast = true
}
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
if (onFailure != null) {
onFailure()
}
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
if (onFailure != null) {
onFailure()
}
}
)
)
}
}

View File

@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.audio_content.order
import com.google.gson.annotations.SerializedName
data class GetAudioContentOrderListResponse(
@SerializedName("totalCount") val totalCount: Int,
@SerializedName("items") val items: List<GetAudioContentOrderListItem>
)
data class GetAudioContentOrderListItem(
@SerializedName("contentId") val contentId: Long,
@SerializedName("coverImageUrl") val coverImageUrl: String,
@SerializedName("title") val title: String,
@SerializedName("themeStr") val themeStr: String,
@SerializedName("duration") val duration: String?,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("orderType") val orderType: OrderType,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("commentCount") val commentCount: Int,
)

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.audio_content.order
import com.google.gson.annotations.SerializedName
data class OrderRequest(
@SerializedName("contentId") val contentId: Long,
@SerializedName("orderType") val orderType: OrderType,
@SerializedName("container") val container: String
)

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.audio_content.order
import com.google.gson.annotations.SerializedName
enum class OrderType {
@SerializedName("RENTAL")
RENTAL,
@SerializedName("KEEP")
KEEP
}

View File

@ -0,0 +1,435 @@
package kr.co.vividnext.sodalive.audio_content.upload
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import coil.load
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import com.github.dhaval2404.imagepicker.ImagePicker
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
import com.jakewharton.rxbinding4.widget.textChanges
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.upload.theme.AudioContentThemeFragment
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.RealPathUtil
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentUploadBinding
import kr.co.vividnext.sodalive.dialog.LiveDialog
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
import java.io.File
class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBinding>(
ActivityAudioContentUploadBinding::inflate
) {
private val viewModel: AudioContentUploadViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private val themeFragment: AudioContentThemeFragment by lazy {
AudioContentThemeFragment(
getSelectedTheme = { viewModel.theme },
onItemClick = {
binding.ivTheme.load(it.image) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
binding.ivTheme.visibility = View.VISIBLE
}
binding.tvTheme.text = it.theme
viewModel.theme = it
}
)
}
private val imageResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == RESULT_OK) {
val fileUri = data?.data
if (fileUri != null) {
binding.ivCover.background = null
binding.ivCover.load(fileUri) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(RoundedCornersTransformation(13.3f.dpToPx()))
}
viewModel.coverImageUri = fileUri
} else {
Toast.makeText(
this,
"잘못된 파일입니다.\n다시 선택해 주세요.",
Toast.LENGTH_SHORT
).show()
}
} else if (resultCode == ImagePicker.RESULT_ERROR) {
Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
}
}
private val selectAudioActivityResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == RESULT_OK) {
val fileUri = data?.data
if (fileUri != null) {
binding.tvSelectContent.text = getFileName(fileUri)
viewModel.contentUri = fileUri
} else {
Toast.makeText(
this,
"잘못된 파일입니다.\n다시 선택해 주세요.",
Toast.LENGTH_SHORT
).show()
}
} else if (resultCode == ImagePicker.RESULT_ERROR) {
binding.tvSelectContent.text = "파일 선택"
viewModel.contentUri = null
Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkPermissions()
viewModel.getRealPathFromURI = {
RealPathUtil.getRealPath(applicationContext, it)
}
bindData()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = "콘텐츠 등록"
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.llTheme.setOnClickListener {
if (themeFragment.isAdded) return@setOnClickListener
themeFragment.show(supportFragmentManager, themeFragment.tag)
}
binding.ivPhotoPicker.setOnClickListener {
ImagePicker.with(this)
.crop()
.galleryOnly()
.galleryMimeTypes( // Exclude gif images
mimeTypes = arrayOf(
"image/png",
"image/jpg",
"image/jpeg"
)
)
.createIntent { imageResult.launch(it) }
}
binding.tvSelectContent.setOnClickListener {
val intent = Intent().apply {
type = "audio/*"
action = Intent.ACTION_GET_CONTENT
addCategory(Intent.CATEGORY_OPENABLE)
}
selectAudioActivityResultLauncher.launch(
Intent.createChooser(
intent,
"Select Audio"
)
)
}
if (SharedPreferenceManager.isAuth) {
binding.llSetAdult.visibility = View.VISIBLE
} else {
binding.llSetAdult.visibility = View.GONE
}
binding.llPricePaid.setOnClickListener { viewModel.setPriceFree(false) }
binding.llPriceFree.setOnClickListener { viewModel.setPriceFree(true) }
binding.llCommentNo.setOnClickListener { viewModel.setAvailableComment(false) }
binding.llCommentYes.setOnClickListener { viewModel.setAvailableComment(true) }
binding.tvCancel.setOnClickListener { finish() }
binding.tvUpload.setOnClickListener {
viewModel.uploadAudioContent {
LiveDialog(
activity = this,
layoutInflater = layoutInflater,
title = "콘텐츠 업로드",
desc = "등록한 콘텐츠가 업로드 중입니다.\n" +
"콘텐츠 등록이 완료되면 알림을 보내드립니다.\n" +
"이 페이지를 나가도 콘텐츠는 자동으로 등록됩니다.",
confirmButtonTitle = "확인",
confirmButtonClick = { finish() },
).show(screenWidth)
}
}
}
private fun checkPermissions() {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(Manifest.permission.READ_MEDIA_AUDIO, Manifest.permission.READ_MEDIA_IMAGES)
} else {
listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
TedPermission.create()
.setPermissionListener(object : PermissionListener {
override fun onPermissionGranted() {
}
override fun onPermissionDenied(deniedPermissions: MutableList<String>?) {
finish()
}
})
.setDeniedMessage(R.string.read_storage_permission_denied_message)
.setPermissions(*permissions.toTypedArray())
.check()
}
@SuppressLint("SetTextI18n")
private fun bindData() {
compositeDisposable.add(
binding.etTitle.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.title = it.toString()
}
)
compositeDisposable.add(
binding.etDetail.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
binding.tvNumberOfCharacters.text = "${it.length}"
viewModel.detail = it.toString()
}
)
compositeDisposable.add(
binding.etTag.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.tags = it.toString()
}
)
compositeDisposable.add(
binding.etSetPrice.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
val price = it.toString().toIntOrNull()
if (price != null) {
viewModel.price = price.toInt()
} else {
viewModel.price = 0
if (it.isNotBlank()) {
binding.etSetPrice.setText(it.substring(0, it.length - 1))
binding.etSetPrice.setSelection(it.length - 1)
}
}
}
)
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "콘텐츠를 업로드 하는 중입니다.")
} else {
loadingDialog.dismiss()
}
}
viewModel.isPriceFreeLiveData.observe(this) {
if (it) {
viewModel.price = 0
binding.etSetPrice.setText("0")
binding.llSetPrice.visibility = View.GONE
binding.ivPriceFree.visibility = View.VISIBLE
binding.tvPriceFree.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llPriceFree.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.ivPricePaid.visibility = View.GONE
binding.tvPricePaid.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.llPricePaid.setBackgroundResource(
R.drawable.bg_round_corner_6_7_1f1734_9970ff
)
} else {
binding.llSetPrice.visibility = View.VISIBLE
binding.ivPricePaid.visibility = View.VISIBLE
binding.tvPricePaid.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llPricePaid.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.ivPriceFree.visibility = View.GONE
binding.tvPriceFree.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.llPriceFree.setBackgroundResource(
R.drawable.bg_round_corner_6_7_1f1734_9970ff
)
}
}
viewModel.isAvailableCommentLiveData.observe(this) {
if (it) {
binding.ivCommentYes.visibility = View.VISIBLE
binding.tvCommentYes.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llCommentYes.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.ivCommentNo.visibility = View.GONE
binding.tvCommentNo.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.llCommentNo.setBackgroundResource(
R.drawable.bg_round_corner_6_7_1f1734_9970ff
)
} else {
binding.ivCommentNo.visibility = View.VISIBLE
binding.tvCommentNo.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llCommentNo.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.ivCommentYes.visibility = View.GONE
binding.tvCommentYes.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.llCommentYes
.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734_9970ff)
}
}
if (SharedPreferenceManager.isAuth) {
binding.llAgeAll.setOnClickListener {
viewModel.setAdult(false)
}
binding.llAge19.setOnClickListener {
viewModel.setAdult(true)
}
viewModel.isAdultLiveData.observe(this) {
if (it) {
binding.ivAgeAll.visibility = View.GONE
binding.llAgeAll.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734)
binding.tvAgeAll.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.ivAge19.visibility = View.VISIBLE
binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.tvAge19.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
} else {
binding.ivAge19.visibility = View.GONE
binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_1f1734)
binding.tvAge19.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_9970ff
)
)
binding.ivAgeAll.visibility = View.VISIBLE
binding.llAgeAll.setBackgroundResource(R.drawable.bg_round_corner_6_7_9970ff)
binding.tvAgeAll.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
}
}
}
}
private fun getFileName(uri: Uri): String? {
val scheme = uri.scheme
var fileName: String? = null
if (scheme == "content") {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
fileName = cursor.getString(nameIndex)
}
}
} else if (scheme == "file") {
val file = File(uri.path ?: "")
fileName = file.name
}
return fileName
}
}

View File

@ -0,0 +1,221 @@
package kr.co.vividnext.sodalive.audio_content.upload
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.audio_content.upload.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import java.io.File
class AudioContentUploadViewModel(
private val repository: AudioContentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _isAdultLiveData = MutableLiveData(false)
val isAdultLiveData: LiveData<Boolean>
get() = _isAdultLiveData
private val _isAvailableCommentLiveData = MutableLiveData(true)
val isAvailableCommentLiveData: LiveData<Boolean>
get() = _isAvailableCommentLiveData
private val _isPriceFreeLiveData = MutableLiveData(true)
val isPriceFreeLiveData: LiveData<Boolean>
get() = _isPriceFreeLiveData
lateinit var getRealPathFromURI: (Uri) -> String?
var title = ""
var detail = ""
var tags = ""
var price = 0
var theme: GetAudioContentThemeResponse? = null
var coverImageUri: Uri? = null
var contentUri: Uri? = null
fun setAdult(isAdult: Boolean) {
_isAdultLiveData.postValue(isAdult)
}
fun setAvailableComment(isAvailableComment: Boolean) {
_isAvailableCommentLiveData.postValue(isAvailableComment)
}
fun setPriceFree(isPriceFree: Boolean) {
_isPriceFreeLiveData.postValue(isPriceFree)
}
fun uploadAudioContent(onSuccess: () -> Unit) {
if (!_isLoading.value!! && validateData()) {
_isLoading.postValue(true)
val request = CreateAudioContentRequest(
title = title,
detail = detail,
tags = tags,
price = price,
themeId = theme!!.id,
isAdult = _isAdultLiveData.value!!,
isCommentAvailable = _isAvailableCommentLiveData.value!!
)
val requestJson = Gson().toJson(request)
val coverImage = if (coverImageUri != null) {
val file = File(getRealPathFromURI(coverImageUri!!))
MultipartBody.Part.createFormData(
"coverImage",
file.name,
body = object : RequestBody() {
override fun contentType(): MediaType {
return "image/*".toMediaType()
}
override fun writeTo(sink: BufferedSink) {
file.inputStream().use { inputStream ->
val buffer = ByteArray(1024)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
}
}
}
override fun contentLength(): Long {
return file.length()
}
}
)
} else {
null
}
val contentFile = if (contentUri != null) {
val file = File(getRealPathFromURI(contentUri!!))
MultipartBody.Part.createFormData(
"contentFile",
file.name,
body = object : RequestBody() {
override fun contentType(): MediaType {
return "audio/*".toMediaType()
}
override fun writeTo(sink: BufferedSink) {
file.inputStream().use { inputStream ->
val buffer = ByteArray(512)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
}
}
}
override fun contentLength(): Long {
return file.length()
}
}
)
} else {
null
}
if (coverImage == null) {
_toastLiveData.postValue("커버이미지를 선택해 주세요.")
return
}
if (contentFile == null) {
_toastLiveData.postValue("오디오 콘텐츠를 선택해 주세요.")
return
}
compositeDisposable.add(
repository.uploadAudioContent(
coverImage = coverImage,
contentFile = contentFile,
request = requestJson.toRequestBody("text/plain".toMediaType()),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
onSuccess()
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
_isLoading.postValue(false)
},
{
_isLoading.postValue(false)
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
)
)
}
}
private fun validateData(): Boolean {
if (title.isBlank()) {
_toastLiveData.postValue("제목을 입력해 주세요.")
return false
}
if (detail.isBlank() || detail.length < 5) {
_toastLiveData.postValue("내용을 5자 이상 입력해 주세요.")
return false
}
if (theme == null) {
_toastLiveData.postValue("테마를 선택해 주세요.")
return false
}
if (coverImageUri == null) {
_toastLiveData.postValue("커버이미지를 선택해 주세요.")
return false
}
if (contentUri == null) {
_toastLiveData.postValue("오디오 콘텐츠를 선택해 주세요.")
return false
}
if (!isPriceFreeLiveData.value!! && price < 10) {
_toastLiveData.postValue("콘텐츠의 최소금액은 10코인 입니다.")
return false
}
return true
}
}

View File

@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.audio_content.upload
import com.google.gson.annotations.SerializedName
data class CreateAudioContentRequest(
@SerializedName("title") val title: String,
@SerializedName("detail") val detail: String,
@SerializedName("tags") val tags: String,
@SerializedName("price") val price: Int,
@SerializedName("themeId") val themeId: Long,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean,
)

View File

@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.audio_content.upload.theme
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemAudioContentThemeBinding
class AudioContentThemeAdapter(
private val selectedTheme: GetAudioContentThemeResponse?,
private val onItemClick: (GetAudioContentThemeResponse) -> Unit
) : RecyclerView.Adapter<AudioContentThemeAdapter.ViewHolder>() {
inner class ViewHolder(
private val context: Context,
private val binding: ItemAudioContentThemeBinding
) : RecyclerView.ViewHolder(binding.root) {
private var isChecked = false
fun bind(item: GetAudioContentThemeResponse) {
if (selectedTheme == item) {
binding.ivThemeChecked.visibility = View.VISIBLE
binding.tvTheme.setTextColor(ContextCompat.getColor(context, R.color.color_9970ff))
isChecked = true
} else {
binding.ivThemeChecked.visibility = View.GONE
binding.tvTheme.setTextColor(ContextCompat.getColor(context, R.color.color_bbbbbb))
isChecked = false
}
binding.ivTheme.load(item.image) {
crossfade(true)
placeholder(R.drawable.ic_logo)
transformations(CircleCropTransformation())
}
binding.tvTheme.text = item.theme
binding.root.setOnClickListener { onItemClick(item) }
}
}
val items = mutableSetOf<GetAudioContentThemeResponse>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
parent.context,
ItemAudioContentThemeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items.toList()[position])
}
override fun getItemCount() = items.size
}

View File

@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.audio_content.upload.theme
import android.annotation.SuppressLint
import android.app.Dialog
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class AudioContentThemeFragment(
private val getSelectedTheme: () -> GetAudioContentThemeResponse?,
private val onItemClick: (GetAudioContentThemeResponse) -> Unit
) : BottomSheetDialogFragment() {
private val viewModel: AudioContentThemeViewModel by inject()
private lateinit var adapter: AudioContentThemeAdapter
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener {
val d = it as BottomSheetDialog
val bottomSheet = d.findViewById<FrameLayout>(
com.google.android.material.R.id.design_bottom_sheet
)
if (bottomSheet != null) {
BottomSheetBehavior.from(bottomSheet).state = BottomSheetBehavior.STATE_EXPANDED
}
}
return dialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_audio_content_theme, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<ImageView>(R.id.iv_close).setOnClickListener {
dialog?.dismiss()
}
setupAdapter(view)
bindData()
viewModel.getThemes()
}
private fun setupAdapter(view: View) {
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_themes)
adapter = AudioContentThemeAdapter(getSelectedTheme()) {
onItemClick(it)
dialog?.dismiss()
}
recyclerView.setHasFixedSize(true)
recyclerView.layoutManager = GridLayoutManager(requireContext(), 4)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.top = 13.3f.dpToPx().toInt()
outRect.bottom = 13.3f.dpToPx().toInt()
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
}
})
recyclerView.adapter = adapter
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.toastLiveData.observe(viewLifecycleOwner) {
it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
}
viewModel.themeLiveData.observe(viewLifecycleOwner) {
adapter.items.addAll(it)
adapter.notifyDataSetChanged()
}
}
}

View File

@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.audio_content.upload.theme
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class AudioContentThemeViewModel(private val repository: AudioContentRepository) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private val _themeLiveData = MutableLiveData<List<GetAudioContentThemeResponse>>()
val themeLiveData: LiveData<List<GetAudioContentThemeResponse>>
get() = _themeLiveData
fun getThemes() {
compositeDisposable.add(
repository.getAudioContentThemeList(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_themeLiveData.postValue(it.data!!)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.audio_content.upload.theme
import com.google.gson.annotations.SerializedName
data class GetAudioContentThemeResponse(
@SerializedName("id") val id: Long,
@SerializedName("theme") val theme: String,
@SerializedName("image") val image: String
)

View File

@ -10,7 +10,9 @@ object Constants {
const val PREF_USER_ROLE = "pref_user_role" const val PREF_USER_ROLE = "pref_user_role"
const val PREF_PUSH_TOKEN = "pref_push_token" const val PREF_PUSH_TOKEN = "pref_push_token"
const val PREF_PROFILE_IMAGE = "pref_profile_image" const val PREF_PROFILE_IMAGE = "pref_profile_image"
const val PREF_IS_CONTENT_PLAY_LOOP = "pref_is_content_play_loop"
const val PREF_IS_FOLLOWED_CREATOR_LIVE = "pref_is_followed_creator_live" const val PREF_IS_FOLLOWED_CREATOR_LIVE = "pref_is_followed_creator_live"
const val PREF_NOT_SHOWING_EVENT_POPUP_ID = "pref_not_showing_event_popup_id"
const val EXTRA_CAN = "extra_can" const val EXTRA_CAN = "extra_can"
const val EXTRA_DATA = "extra_data" const val EXTRA_DATA = "extra_data"
@ -30,7 +32,24 @@ object Constants {
const val EXTRA_ROOM_CHANNEL_NAME = "extra_room_channel_name" const val EXTRA_ROOM_CHANNEL_NAME = "extra_room_channel_name"
const val EXTRA_LIVE_RESERVATION_RESPONSE = "extra_live_reservation_response" const val EXTRA_LIVE_RESERVATION_RESPONSE = "extra_live_reservation_response"
const val EXTRA_CONTENT_ID = "extra_content_id" const val EXTRA_AUDIO_CONTENT_ID = "audio_content_id"
const val EXTRA_AUDIO_CONTENT_URL = "audio_content_url"
const val EXTRA_AUDIO_CONTENT_TITLE = "audio_content_title"
const val EXTRA_AUDIO_CONTENT_FREE = "audio_content_is_free"
const val EXTRA_AUDIO_CONTENT_PREVIEW = "audio_content_is_preview"
const val EXTRA_AUDIO_CONTENT_PLAYING = "audio_content_is_playing"
const val EXTRA_AUDIO_CONTENT_SHOWING = "audio_content_is_showing"
const val EXTRA_AUDIO_CONTENT_CHANGE_UI = "audio_content_change_ui"
const val EXTRA_AUDIO_CONTENT_PROGRESS = "audio_content_progress"
const val EXTRA_AUDIO_CONTENT_DURATION = "audio_content_duration"
const val EXTRA_AUDIO_CONTENT_COMMENT = "audio_content_comment"
const val EXTRA_AUDIO_CONTENT_LOADING = "audio_content_loading"
const val EXTRA_AUDIO_CONTENT_CREATOR_ID = "audio_content_creator_id"
const val EXTRA_AUDIO_CONTENT_NEXT_ACTION = "audio_content_next_action"
const val EXTRA_AUDIO_CONTENT_ALERT_PREVIEW = "audio_content_alert_preview"
const val EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL = "audio_content_cover_image_url"
const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2 const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2
const val ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver"
const val ACTION_MAIN_AUDIO_CONTENT_RECEIVER = "soda_live_action_main_content_receiver"
} }

View File

@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.common
import android.content.Context
import io.objectbox.BoxStore
import kr.co.vividnext.sodalive.audio_content.MyObjectBox
import kr.co.vividnext.sodalive.audio_content.PlaybackTracking
class ObjectBox(context: Context) {
private var store: BoxStore = MyObjectBox.builder()
.androidContext(context.applicationContext)
.build()
val playbackTrackingBox = store.boxFor(PlaybackTracking::class.java)
}

View File

@ -104,4 +104,16 @@ object SharedPreferenceManager {
set(value) { set(value) {
sharedPreferences[Constants.PREF_IS_FOLLOWED_CREATOR_LIVE] = value sharedPreferences[Constants.PREF_IS_FOLLOWED_CREATOR_LIVE] = value
} }
var isContentPlayLoop: Boolean
get() = sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP, false]
set(value) {
sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP] = value
}
var notShowingEventPopupId: Long
get() = sharedPreferences[Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, 0]
set(value) {
sharedPreferences[Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID] = value
}
} }

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.common
object Utils {
fun convertDurationToString(duration: Int): String {
val durationSeconds = duration / 1000
val hours = (durationSeconds / 3600)
val minutes = ((durationSeconds % 3600) / 60)
val seconds = (durationSeconds % 60)
return "%02d:%02d:%02d".format(hours, minutes, seconds)
}
}

View File

@ -1,9 +0,0 @@
package kr.co.vividnext.sodalive.content.main
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentContentMainBinding
class ContentMainFragment : BaseFragment<FragmentContentMainBinding>(
FragmentContentMainBinding::inflate
) {
}

View File

@ -3,6 +3,18 @@ package kr.co.vividnext.sodalive.di
import android.content.Context import android.content.Context
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import kr.co.vividnext.sodalive.BuildConfig import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.audio_content.AudioContentApi
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.audio_content.AudioContentViewModel
import kr.co.vividnext.sodalive.audio_content.comment.AudioContentCommentListViewModel
import kr.co.vividnext.sodalive.audio_content.comment.AudioContentCommentReplyViewModel
import kr.co.vividnext.sodalive.audio_content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailViewModel
import kr.co.vividnext.sodalive.audio_content.main.AudioContentMainViewModel
import kr.co.vividnext.sodalive.audio_content.modify.AudioContentModifyViewModel
import kr.co.vividnext.sodalive.audio_content.order.AudioContentOrderListViewModel
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadViewModel
import kr.co.vividnext.sodalive.audio_content.upload.theme.AudioContentThemeViewModel
import kr.co.vividnext.sodalive.common.ApiBuilder import kr.co.vividnext.sodalive.common.ApiBuilder
import kr.co.vividnext.sodalive.explorer.ExplorerApi import kr.co.vividnext.sodalive.explorer.ExplorerApi
import kr.co.vividnext.sodalive.explorer.ExplorerRepository import kr.co.vividnext.sodalive.explorer.ExplorerRepository
@ -112,6 +124,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
single { ApiBuilder().build(get(), ExplorerApi::class.java) } single { ApiBuilder().build(get(), ExplorerApi::class.java) }
single { ApiBuilder().build(get(), MessageApi::class.java) } single { ApiBuilder().build(get(), MessageApi::class.java) }
single { ApiBuilder().build(get(), NoticeApi::class.java) } single { ApiBuilder().build(get(), NoticeApi::class.java) }
single { ApiBuilder().build(get(), AudioContentApi::class.java) }
} }
private val viewModelModule = module { private val viewModelModule = module {
@ -146,6 +159,15 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { SettingsViewModel(get()) } viewModel { SettingsViewModel(get()) }
viewModel { TextMessageDetailViewModel(get()) } viewModel { TextMessageDetailViewModel(get()) }
viewModel { LiveReservationStatusViewModel(get()) } viewModel { LiveReservationStatusViewModel(get()) }
viewModel { AudioContentMainViewModel(get()) }
viewModel { AudioContentViewModel(get()) }
viewModel { AudioContentOrderListViewModel(get()) }
viewModel { AudioContentUploadViewModel(get()) }
viewModel { AudioContentModifyViewModel(get()) }
viewModel { AudioContentThemeViewModel(get()) }
viewModel { AudioContentDetailViewModel(get(), get(), get(), get()) }
viewModel { AudioContentCommentListViewModel(get()) }
viewModel { AudioContentCommentReplyViewModel(get()) }
} }
private val repositoryModule = module { private val repositoryModule = module {
@ -161,6 +183,8 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { ExplorerRepository(get()) } factory { ExplorerRepository(get()) }
factory { MessageRepository(get()) } factory { MessageRepository(get()) }
factory { NoticeRepository(get()) } factory { NoticeRepository(get()) }
factory { AudioContentRepository(get(), get()) }
factory { AudioContentCommentRepository(get()) }
} }
private val moduleList = listOf( private val moduleList = listOf(

View File

@ -63,7 +63,7 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
val audioContentId = messageData["content_id"] val audioContentId = messageData["content_id"]
if (audioContentId != null) { if (audioContentId != null) {
intent.putExtra(Constants.EXTRA_CONTENT_ID, audioContentId.toLong()) intent.putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId.toLong())
} }
val pendingIntent = val pendingIntent =

View File

@ -11,10 +11,10 @@ import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission import com.gun0912.tedpermission.normal.TedPermission
import com.orhanobut.logger.Logger import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.main.AudioContentMainFragment
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.content.main.ContentMainFragment
import kr.co.vividnext.sodalive.databinding.ActivityMainBinding import kr.co.vividnext.sodalive.databinding.ActivityMainBinding
import kr.co.vividnext.sodalive.databinding.ItemMainTabBinding import kr.co.vividnext.sodalive.databinding.ItemMainTabBinding
import kr.co.vividnext.sodalive.explorer.ExplorerFragment import kr.co.vividnext.sodalive.explorer.ExplorerFragment
@ -189,7 +189,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
if (fragment == null) { if (fragment == null) {
fragment = when (currentTab) { fragment = when (currentTab) {
MainViewModel.CurrentTab.LIVE -> liveFragment MainViewModel.CurrentTab.LIVE -> liveFragment
MainViewModel.CurrentTab.CONTENT -> ContentMainFragment() MainViewModel.CurrentTab.CONTENT -> AudioContentMainFragment()
MainViewModel.CurrentTab.EXPLORER -> ExplorerFragment() MainViewModel.CurrentTab.EXPLORER -> ExplorerFragment()
MainViewModel.CurrentTab.MESSAGE -> MessageFragment() MainViewModel.CurrentTab.MESSAGE -> MessageFragment()
MainViewModel.CurrentTab.MY -> MyPageFragment() MainViewModel.CurrentTab.MY -> MyPageFragment()

View File

@ -7,7 +7,7 @@ data class ReportRequest(
@SerializedName("reason") val reason: String, @SerializedName("reason") val reason: String,
@SerializedName("reportedAccountId") val reportedAccountId: Long? = null, @SerializedName("reportedAccountId") val reportedAccountId: Long? = null,
@SerializedName("cheersId") val cheersId: Long? = null, @SerializedName("cheersId") val cheersId: Long? = null,
@SerializedName("audioContentId") val audioContentId: Long? = null, @SerializedName("audioContentId") val contentId: Long? = null,
) )
enum class ReportType { enum class ReportType {

View File

@ -145,7 +145,7 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding
) )
} else if (audioContentIdString != null) { } else if (audioContentIdString != null) {
bundleOf( bundleOf(
Constants.EXTRA_CONTENT_ID to audioContentIdString.toLong() Constants.EXTRA_AUDIO_CONTENT_ID to audioContentIdString.toLong()
) )
} else { } else {
null null

View File

@ -74,13 +74,13 @@ interface UserApi {
@POST("/member/creator/follow") @POST("/member/creator/follow")
fun creatorFollow( fun creatorFollow(
request: Any, request: CreatorFollowRequestRequest,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<Any>> ): Single<ApiResponse<Any>>
@POST("/member/creator/unfollow") @POST("/member/creator/unfollow")
fun creatorUnFollow( fun creatorUnFollow(
request: Any, request: CreatorFollowRequestRequest,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<Any>> ): Single<ApiResponse<Any>>

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<solid android:color="@color/color_cc979797" />
</shape>
</item>
<item android:id="@android:id/secondaryProgress">
<clip>
<shape>
<solid android:color="@color/color_cc979797" />
</shape>
</clip>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<solid android:color="@color/color_9970ff" />
</shape>
</clip>
</item>
</layer-list>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/black" />
<stroke android:width="0dp" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_2d7390" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_2d7390" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_4d6aa4" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_4d6aa4" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_548f7d" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_548f7d" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_59548f" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_59548f" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_973a3a" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_973a3a" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_d38c38" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_d38c38" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_d85e37" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_d85e37" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_303030" />
<corners android:radius="13.3dp" />
<stroke
android:width="1dp"
android:color="@color/color_303030" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_19ffffff" />
<corners android:radius="26.7dp" />
<stroke android:width="0dp" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_26ffffff" />
<corners android:radius="26.7dp" />
<stroke
android:width="1dp"
android:color="@color/color_26ffffff" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_28312b" />
<corners android:radius="2dp" />
<stroke
android:width="1dp"
android:color="@color/color_28312b" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_222222" />
<corners android:radius="2.6dp" />
<stroke
android:width="1dp"
android:color="@color/color_222222" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_26310f" />
<corners android:radius="2.6dp" />
<stroke
android:width="1dp"
android:color="@color/color_26310f" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_28312b" />
<corners android:radius="2.6dp" />
<stroke
android:width="1dp"
android:color="@color/color_28312b" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_30176f" />
<corners android:radius="2.6dp" />
<stroke
android:width="1dp"
android:color="@color/color_30176f" />
</shape>

Some files were not shown because too many files have changed in this diff Show More