Compare commits

...

83 Commits

Author SHA1 Message Date
79a6ecdc0b test(json): 난독화 안정성 검증을 추가한다 2026-07-09 17:39:07 +09:00
33f42e62b6 fix(content): 콘텐츠 개요 query 값을 고정한다 2026-07-09 17:39:01 +09:00
1b660fe542 fix(content): 전체 탭 query 값을 고정한다 2026-07-09 17:38:55 +09:00
d0539b00b0 fix(content): 오디오 랭킹 query 값을 고정한다 2026-07-09 17:38:46 +09:00
e089ef6f8a fix(creator-channel): 콘텐츠 정렬 query 값을 고정한다 2026-07-09 17:38:39 +09:00
a8a70df304 fix(series): 요일 query 값을 고정한다 2026-07-09 17:38:34 +09:00
a207a8f840 fix(home): 홈 랭킹 query 값을 고정한다 2026-07-09 17:38:28 +09:00
6c85f81f98 fix(json): DTO 직렬화 이름을 고정한다 2026-07-09 17:38:18 +09:00
6a88019851 docs(json): 난독화 안정화 요구를 기록한다 2026-07-09 17:38:11 +09:00
379263ec87 fix(content): 추천 오디오 카드 폭을 보정한다 2026-07-09 14:56:19 +09:00
f3d610633c docs(content): 추천 오디오 폭 보정 요구를 기록한다 2026-07-09 14:56:06 +09:00
335db22680 docs(content): 댓글 많은 오디오 UI 보정 기록을 추가한다 2026-07-09 08:46:35 +09:00
b71bb43dd6 fix(content): 최근 댓글 오디오 카드 UI를 보정한다 2026-07-09 08:46:21 +09:00
dace1416c5 fix(content): 추천 섹션 순서를 조정한다 2026-07-09 08:28:23 +09:00
1baf3ac7e0 fix(creator-channel): FanTalk 문구를 한 줄로 표시한다 2026-07-09 08:19:04 +09:00
f3a4578a66 feat(creator-channel): 홈 섹션 이동을 연결한다 2026-07-09 08:19:00 +09:00
b745c3d675 docs(creator-channel): 홈 섹션 이동 요구를 기록한다 2026-07-09 08:18:54 +09:00
f993d343a9 feat(creator-channel): FanTalk 상세 진입점을 연결한다 2026-07-09 07:44:21 +09:00
1ba7846f4c feat(creator-channel): FanTalk 목록 상세 클릭을 추가한다 2026-07-09 07:44:15 +09:00
da26b65a73 feat(creator-channel): FanTalk 상세 화면을 추가한다 2026-07-09 07:44:06 +09:00
04dcb3f09f feat(creator-channel): FanTalk 상세 상태를 구현한다 2026-07-09 07:43:59 +09:00
6916459c9e feat(creator-channel): FanTalk 상세 모델을 추가한다 2026-07-09 07:43:53 +09:00
643fb9fa8e feat(creator-channel): FanTalk 답글 API 응답을 연결한다 2026-07-09 07:43:47 +09:00
f9160ccb02 docs(creator-channel): FanTalk 상세 작업 계획을 기록한다 2026-07-09 07:43:40 +09:00
e2b166de11 docs(creator-channel): FanTalk 상세 요구사항을 기록한다 2026-07-09 07:43:31 +09:00
89f3511864 test(creator-channel): 댓글 답글 후속 계약을 검증한다 2026-07-09 01:28:48 +09:00
3271c9e142 feat(creator-channel): 더보기 메뉴 폰트를 고정한다 2026-07-09 01:28:42 +09:00
1f1069b05d feat(creator-channel): 답글 수정 입력 모드를 구현한다 2026-07-09 01:28:35 +09:00
a325a1bbe9 feat(creator-channel): 댓글 수정 입력 모드를 구현한다 2026-07-09 01:28:29 +09:00
255e6dc67d feat(creator-channel): 더보기 메뉴 문구를 정리한다 2026-07-09 01:28:24 +09:00
b2685959ad docs(creator-channel): 댓글 답글 후속 요구를 기록한다 2026-07-09 01:28:18 +09:00
536effab40 test(creator-channel): 커뮤니티 상세 UI 계약을 검증한다 2026-07-09 00:54:12 +09:00
81537b1e96 feat(creator-channel): 커뮤니티 목록 클릭을 연결한다 2026-07-09 00:54:06 +09:00
e691f78e2e feat(creator-channel): 커뮤니티 상세 진입점을 연결한다 2026-07-09 00:53:59 +09:00
6125b9f7d1 feat(creator-channel): 커뮤니티 답글 화면을 구현한다 2026-07-09 00:53:48 +09:00
d10373d265 feat(creator-channel): 커뮤니티 상세 화면을 구현한다 2026-07-09 00:53:41 +09:00
5c1eb6204a feat(creator-channel): 커뮤니티 전송 아이콘을 추가한다 2026-07-09 00:53:35 +09:00
c3884ea917 feat(creator-channel): 커뮤니티 답글 화면 리소스를 추가한다 2026-07-09 00:53:30 +09:00
bc3fa9546b feat(creator-channel): 커뮤니티 상세 화면 리소스를 추가한다 2026-07-09 00:53:24 +09:00
ea66b6da1e fix(common): UTC 상대시간 파싱을 보완한다 2026-07-09 00:53:17 +09:00
c9e47214ea feat(creator-channel): 커뮤니티 답글 상태 관리를 추가한다 2026-07-09 00:53:12 +09:00
c04d603ac0 feat(creator-channel): 커뮤니티 상세 상태 관리를 추가한다 2026-07-09 00:53:06 +09:00
9e524d626a feat(creator-channel): 커뮤니티 상세 조회 계약을 추가한다 2026-07-09 00:52:59 +09:00
e0dc0c2cb7 docs(creator-channel): 커뮤니티 상세 요구사항을 기록한다 2026-07-09 00:52:49 +09:00
cbd4f04f1b docs(creator-channel): 후원 카드 공통화 작업을 기록한다 2026-07-08 18:44:15 +09:00
eeb777fa01 test(donation): 후원 header 색상 경계를 검증한다 2026-07-08 18:44:09 +09:00
9272db913c test(creator-channel): 후원 카드 바인더 계약을 검증한다 2026-07-08 18:43:56 +09:00
ff4c0104c5 refactor(creator-channel): 후원 카드 바인딩을 공통화한다 2026-07-08 18:43:47 +09:00
31ce0629d2 docs(creator-channel): 후원 header 색상 검증을 기록한다 2026-07-08 16:33:01 +09:00
6ab7878221 fix(creator-channel): 홈 후원 고액 header 색상을 보정한다 2026-07-08 16:32:54 +09:00
ddc18386fb fix(donation): 후원 내역 고액 header 색상을 보정한다 2026-07-08 16:32:46 +09:00
809ed5ce23 refactor(modal): V2 확인 모달 사용처를 교체한다 2026-07-08 16:04:01 +09:00
8e086c4888 feat(modal): V2 공통 모달창을 추가한다 2026-07-08 14:20:27 +09:00
bc6443e5bd docs(modal): V2 공통 모달창 계획을 기록한다 2026-07-08 14:20:20 +09:00
8d205690b3 feat(creator-channel): FanTalk 글쓰기 진입점을 연결한다 2026-07-08 03:44:54 +09:00
25af312d91 feat(creator-channel): FanTalk 글 입력 화면을 연결한다 2026-07-08 03:44:44 +09:00
b020b8096b chore(creator-channel): FanTalk 글 입력 다국어 문구를 추가한다 2026-07-08 03:44:36 +09:00
87f6bec138 feat(creator-channel): FanTalk 글 입력 기본 리소스를 추가한다 2026-07-08 03:44:31 +09:00
34266abd90 feat(creator-channel): FanTalk 글 입력 상태 관리를 추가한다 2026-07-08 03:44:22 +09:00
e8e8a63d6d docs(creator-channel): FanTalk 글 입력 계획을 기록한다 2026-07-08 03:44:15 +09:00
d61867af4e docs(creator-channel): FanTalk 글 입력 PRD를 기록한다 2026-07-08 03:44:09 +09:00
daeb0f75a2 fix(home): 영문 요일 약어 표기를 보완한다 2026-07-06 14:19:07 +09:00
6c497ed1d2 docs(main): 오디오 미니 플레이어 작업을 기록한다 2026-07-06 14:00:48 +09:00
7b975ec54d fix(audio): 오디오 알림 route extra를 전달한다 2026-07-06 14:00:34 +09:00
e3d81792bf fix(main): 오디오 알림 이동과 미니 플레이어를 연결한다 2026-07-06 14:00:23 +09:00
5b9b7eafa7 docs(home): 메인 홈 추천 탭 지금 라이브 중 입장 작업을 기록한다 2026-07-06 02:35:04 +09:00
5489c86cdc fix(home): 메인 홈 추천 탭 지금 라이브 중 입장을 연결한다 2026-07-06 02:34:41 +09:00
b1f605fda4 docs(home): On Air 라이브 아이템 작업을 기록한다 2026-07-03 14:47:44 +09:00
f9030ffd13 fix(home): On Air 라이브 아이템 UI를 보완한다 2026-07-03 14:47:27 +09:00
f74f5a534d docs(home): 팔로잉 최근 소식 구현 검증을 기록한다 2026-06-30 23:54:21 +09:00
3f3bacebd9 fix(home): 팔로잉 최근 소식 nested payload를 반영한다 2026-06-30 23:54:10 +09:00
2041036530 test(home): 팔로잉 최근 소식 nested payload 검증을 추가한다 2026-06-30 23:53:58 +09:00
81a2b541a0 docs(home): 팔로잉 최근 소식 응답 변경을 정의한다 2026-06-30 23:53:47 +09:00
e6cb94179b docs(home): 팔로잉 크리에이터 전체 버튼을 기록한다 2026-06-30 17:43:29 +09:00
658b4f0a9d fix(home): 팔로잉 크리에이터 전체 이동을 연결한다 2026-06-30 17:43:14 +09:00
05cbbdfddc docs(main): title bar 추가 범위를 기록한다 2026-06-30 16:12:52 +09:00
3c66ea986f fix(main): 콘텐츠와 대화 title bar 이동을 연결한다 2026-06-30 16:12:16 +09:00
d1a0cf27a7 fix(home): title bar 메뉴 이동을 연결한다 2026-06-30 15:46:55 +09:00
1aef4b1d89 docs(home): title bar 메뉴 액션 계획을 기록한다 2026-06-30 15:46:36 +09:00
c745481ab2 docs(home): 팔로잉 최근 소식 검증을 기록한다 2026-06-30 15:04:26 +09:00
7e281a15a5 fix(home): 팔로잉 최근 소식 표시를 보완한다 2026-06-30 15:04:14 +09:00
c5e94bbc92 feat(feed): 콘텐츠 카테고리 타입을 보완한다 2026-06-30 15:04:04 +09:00
61fe8a9fee fix(feed): 콘텐츠 카드 레이아웃을 보완한다 2026-06-30 15:03:50 +09:00
160 changed files with 12772 additions and 492 deletions

View File

@@ -114,6 +114,18 @@
<activity android:name=".v2.main.MainV2Activity" />
<activity android:name=".v2.main.content.overview.ContentOverviewActivity" />
<activity android:name=".v2.creator.channel.CreatorChannelActivity" />
<activity
android:name=".v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity
android:name=".v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity
android:name=".v2.creator.channel.fantalk.write.CreatorChannelFanTalkWriteActivity"
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity
android:name=".v2.creator.channel.fantalk.detail.CreatorChannelFanTalkDetailActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".v2.live.onair.HomeOnAirLiveActivity" />
<activity
android:name=".v2.main.chat.dm.DmChatRoomActivity"

View File

@@ -1,3 +1,5 @@
@file:Suppress("ktlint:package-name", "ktlint:standard:package-name")
package kr.co.vividnext.sodalive.audio_content
import android.app.NotificationChannel
@@ -471,9 +473,17 @@ class AudioContentPlayService :
}
private fun updateNotification() {
val intent = Intent(this, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val intent = Intent(this, MainV2Activity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(
MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE,
MainV2Activity.ROUTE_AUDIO_DETAIL
)
contentId?.let {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
}
}
val pendingIntent = PendingIntent.getActivity(
this,
0,

View File

@@ -1,3 +1,5 @@
@file:Suppress("ktlint:package-name", "ktlint:standard:package-name")
package kr.co.vividnext.sodalive.audio_content.player
import android.app.PendingIntent
@@ -155,6 +157,10 @@ class AudioContentPlayerService : MediaSessionService() {
private fun initMediaSession() {
val contextIntent = Intent(applicationContext, MainV2Activity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(
MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE,
MainV2Activity.ROUTE_AUDIO_PLAYER
)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.audio_content.playlist.modify
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.audio_content.playlist.create.PlaylistContentIdAndOrder
@Keep
data class UpdatePlaylistRequest(
@SerializedName("title") val title: String? = null,
@SerializedName("desc") val desc: String? = null,

View File

@@ -5,7 +5,6 @@ import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.audio_content.series.main.by_genre.GetSeriesGenreListResponse
import kr.co.vividnext.sodalive.audio_content.series.main.home.SeriesHomeResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
@@ -23,7 +22,7 @@ interface SeriesMainApi {
@GET("/audio-content/series/main/day-of-week")
fun getDayOfWeekSeriesList(
@Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@Query("dayOfWeek") dayOfWeek: String,
@Query("page") page: Int,
@Query("size") size: Int,
@Header("Authorization") authHeader: String

View File

@@ -19,7 +19,7 @@ class SeriesMainRepository(
size: Int,
token: String
) = api.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek,
dayOfWeek = dayOfWeek.queryValue,
page = page - 1,
size = size,
authHeader = token

View File

@@ -1,17 +1,18 @@
package kr.co.vividnext.sodalive.chat.original
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class OriginalWorkListResponse(
val totalCount: Long,
val content: List<OriginalWorkListItemResponse>
@SerializedName("totalCount") val totalCount: Long,
@SerializedName("content") val content: List<OriginalWorkListItemResponse>
)
@Keep
data class OriginalWorkListItemResponse(
val id: Long,
val imageUrl: String?,
val title: String,
val contentType: String
@SerializedName("id") val id: Long,
@SerializedName("imageUrl") val imageUrl: String?,
@SerializedName("title") val title: String,
@SerializedName("contentType") val contentType: String
)

View File

@@ -2,7 +2,7 @@ package kr.co.vividnext.sodalive.common
import android.content.Context
import kr.co.vividnext.sodalive.R
import java.text.ParseException
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
@@ -10,8 +10,9 @@ import java.util.Locale
import java.util.TimeZone
fun formatUtcRelativeTimeText(context: Context, utcText: String?): String {
val pastMillis = parseServerUtcToMillis(utcText)
val parsedTime = parseServerUtcToMillis(utcText)
?: return context.getString(R.string.character_comment_time_just_now)
val pastMillis = parsedTime.millis
val nowMillis = System.currentTimeMillis()
var diff = nowMillis - pastMillis
@@ -21,6 +22,10 @@ fun formatUtcRelativeTimeText(context: Context, utcText: String?): String {
val hour = 60 * minute
val day = 24 * hour
if (parsedTime.isIsoText && diff < RECENT_ISO_TIME_GRACE_MILLIS) {
return context.getString(R.string.character_comment_time_just_now)
}
if (diff < minute) {
return context.getString(R.string.character_comment_time_just_now)
}
@@ -75,19 +80,25 @@ class AndroidUtcRelativeTimeTextFormatter(context: Context) : UtcRelativeTimeTex
override fun format(utcText: String?): String = formatUtcRelativeTimeText(applicationContext, utcText)
}
private fun parseServerUtcToMillis(utcText: String?): Long? {
private fun parseServerUtcToMillis(utcText: String?): ParsedServerTime? {
if (utcText.isNullOrBlank()) return null
val value = utcText.trim()
val value = utcText.trim().normalizeIsoFraction()
if (value.all { it.isDigit() }) {
return try {
value.toLong()
val number = value.toLong()
ParsedServerTime(
millis = if (value.length <= EPOCH_SECONDS_MAX_LENGTH) number * 1000 else number,
isIsoText = false
)
} catch (_: NumberFormatException) {
null
}
}
val patterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss",
@@ -99,11 +110,26 @@ private fun parseServerUtcToMillis(utcText: String?): Long? {
try {
val dateFormat = SimpleDateFormat(pattern, Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsed: Date? = dateFormat.parse(value)
if (parsed != null) return parsed.time
} catch (_: ParseException) {
val position = ParsePosition(0)
val parsed: Date? = dateFormat.parse(value, position)
if (parsed != null && position.index == value.length) {
return ParsedServerTime(millis = parsed.time, isIsoText = true)
}
} catch (_: RuntimeException) {
}
}
return null
}
private fun String.normalizeIsoFraction(): String {
return replace(Regex("""\.(\d{3})\d+"""), ".$1")
}
private data class ParsedServerTime(
val millis: Long,
val isIsoText: Boolean
)
private const val EPOCH_SECONDS_MAX_LENGTH = 10
private const val RECENT_ISO_TIME_GRACE_MILLIS = 10 * 60_000L

View File

@@ -181,10 +181,14 @@ import kr.co.vividnext.sodalive.user.signup.SignUpViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelApi
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail.CreatorChannelFanTalkDetailViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write.CreatorChannelFanTalkWriteViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesViewModel
import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveViewModel
@@ -452,7 +456,11 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { CreatorChannelAudioViewModel(get()) }
viewModel { CreatorChannelSeriesViewModel(get()) }
viewModel { CreatorChannelCommunityViewModel(get(), get()) }
viewModel { CreatorChannelCommunityDetailViewModel(get(), get(), get()) }
viewModel { CreatorChannelCommunityReplyViewModel(get(), get(), get()) }
viewModel { CreatorChannelFanTalkViewModel(get(), get()) }
viewModel { CreatorChannelFanTalkDetailViewModel(get(), get()) }
viewModel { CreatorChannelFanTalkWriteViewModel(get()) }
viewModel { CreatorChannelDonationViewModel(get(), get()) }
viewModel { PushNotificationListViewModel(get()) }
viewModel { CharacterTabViewModel(get()) }

View File

@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDon
import kr.co.vividnext.sodalive.explorer.profile.donation.GetDonationAllResponse
import kr.co.vividnext.sodalive.explorer.profile.follow.GetFollowerListResponse
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
@@ -73,13 +74,13 @@ interface ExplorerApi {
fun writeCheers(
@Body request: PostWriteCheersRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
): Single<ApiResponse<CreatorChannelFanTalkReplyResponse>>
@PUT("/explorer/profile/cheers")
fun modifyCheers(
@Body request: PutModifyCheersRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
): Single<ApiResponse<CreatorChannelFanTalkReplyResponse>>
@POST("/explorer/profile/channel-donation")
fun postChannelDonation(

View File

@@ -1,18 +1,26 @@
package kr.co.vividnext.sodalive.home
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
/**
* 콘텐츠 랭킹 정렬 기준
*/
enum class ContentRankingSortType {
@Keep
enum class ContentRankingSortType(val queryValue: String) {
// 매출
REVENUE,
@SerializedName("REVENUE")
REVENUE("REVENUE"),
// 판매량
SALES_COUNT,
@SerializedName("SALES_COUNT")
SALES_COUNT("SALES_COUNT"),
// 댓글 수
COMMENT_COUNT,
@SerializedName("COMMENT_COUNT")
COMMENT_COUNT("COMMENT_COUNT"),
// 좋아요 수
LIKE_COUNT
@SerializedName("LIKE_COUNT")
LIKE_COUNT("LIKE_COUNT")
}

View File

@@ -23,7 +23,7 @@ interface HomeApi {
@GET("/api/home/day-of-week-series")
fun getDayOfWeekSeriesList(
@Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@Query("dayOfWeek") dayOfWeek: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetSeriesListResponse.SeriesListItem>>>
@@ -34,7 +34,7 @@ interface HomeApi {
@GET("/api/home/content-ranking")
fun getContentRankingBySort(
@Query("sort") sort: ContentRankingSortType,
@Query("sort") sort: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetAudioContentRankingItem>>>
}

View File

@@ -14,9 +14,10 @@ class HomeRepository(private val api: HomeApi) {
)
fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek, token: String
dayOfWeek: SeriesPublishedDaysOfWeek,
token: String
) = api.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek,
dayOfWeek = dayOfWeek.queryValue,
authHeader = token
)
@@ -28,7 +29,7 @@ class HomeRepository(private val api: HomeApi) {
sortType: ContentRankingSortType,
token: String
) = api.getContentRankingBySort(
sort = sortType,
sort = sortType.queryValue,
authHeader = token
)
}

View File

@@ -1,5 +1,31 @@
package kr.co.vividnext.sodalive.home
enum class SeriesPublishedDaysOfWeek {
SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
enum class SeriesPublishedDaysOfWeek(val queryValue: String) {
@SerializedName("SUN")
SUN("SUN"),
@SerializedName("MON")
MON("MON"),
@SerializedName("TUE")
TUE("TUE"),
@SerializedName("WED")
WED("WED"),
@SerializedName("THU")
THU("THU"),
@SerializedName("FRI")
FRI("FRI"),
@SerializedName("SAT")
SAT("SAT"),
@SerializedName("RANDOM")
RANDOM("RANDOM")
}

View File

@@ -1,3 +1,9 @@
package kr.co.vividnext.sodalive.mypage.auth
data class AuthResponse(val gender: Int)
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class AuthResponse(
@SerializedName("gender") val gender: Int
)

View File

@@ -14,6 +14,7 @@ data class PayverseChargeResponse(
@SerializedName("payloadJson") val payloadJson: String
)
@Keep
data class PayverseVerifyRequest(
@SerializedName("transactionId") val transactionId: String,
@SerializedName("orderId") val orderId: String

View File

@@ -1,9 +1,22 @@
package kr.co.vividnext.sodalive.v2.common.data
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
enum class ContentSort(val queryValue: String) {
@SerializedName("LATEST")
LATEST("LATEST"),
@SerializedName("POPULAR")
POPULAR("POPULAR"),
@SerializedName("OWNED")
OWNED("OWNED"),
@SerializedName("PRICE_HIGH")
PRICE_HIGH("PRICE_HIGH"),
@SerializedName("PRICE_LOW")
PRICE_LOW("PRICE_LOW")
}

View File

@@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.v2.components.modal
import android.app.Activity
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.drawable.toDrawable
import kr.co.vividnext.sodalive.databinding.DialogV2ModalBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class V2ModalDialog(
activity: Activity,
layoutInflater: LayoutInflater,
title: String,
desc: String,
confirmButtonTitle: String,
confirmButtonClick: () -> Unit,
cancelButtonTitle: String = "",
cancelButtonClick: (() -> Unit)? = null
) {
private val alertDialog: AlertDialog
private var isButtonClicked = false
val dialogView = DialogV2ModalBinding.inflate(layoutInflater)
init {
val dialogBuilder = AlertDialog.Builder(activity)
dialogBuilder.setView(dialogView.root)
alertDialog = dialogBuilder.create()
alertDialog.setCancelable(false)
alertDialog.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
dialogView.tvTitle.text = title
dialogView.tvDesc.text = desc
dialogView.tvCancel.text = cancelButtonTitle
dialogView.tvCancel.setOnClickListener {
dismissAndRun {
cancelButtonClick?.invoke()
}
}
dialogView.tvConfirm.text = confirmButtonTitle
dialogView.tvConfirm.setOnClickListener {
dismissAndRun(confirmButtonClick)
}
dialogView.tvCancel.visibility = if (cancelButtonTitle.isNotBlank()) {
View.VISIBLE
} else {
View.GONE
}
}
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?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
alertDialog.window?.attributes = lp
}
private fun dismissAndRun(action: () -> Unit) {
if (isButtonClicked) return
isButtonClicked = true
alertDialog.dismiss()
action()
}
}

View File

@@ -6,14 +6,12 @@ import android.animation.ValueAnimator
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.View.MeasureSpec
import android.view.animation.Interpolator
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -31,7 +29,6 @@ import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
@@ -53,18 +50,25 @@ import kr.co.vividnext.sodalive.mypage.MyPageViewModel
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.ProfileReportDialog
import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.user.login.LoginActivity
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment
import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragment
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkFragment
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail.CreatorChannelFanTalkDetailActivity
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail.toFanTalkDetailPayload
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write.CreatorChannelFanTalkWriteActivity
import kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationFragment
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel
@@ -74,8 +78,6 @@ import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTitleBarS
import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesFragment
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.user.login.LoginActivity
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.koin.android.ext.android.inject
@@ -103,6 +105,7 @@ class CreatorChannelActivity :
private var isOwnerFabExpanded: Boolean = false
private var isOwnerFabAnimating: Boolean = false
private var isDonationFloatingButtonVisible: Boolean = false
private var isFanTalkFixedPlusVisible: Boolean = false
private lateinit var loadingDialog: LoadingDialog
private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() }
private val liveCoordinator: CreatorChannelLiveCoordinator by lazy {
@@ -130,6 +133,29 @@ class CreatorChannelActivity :
refreshCreatorChannelCommunity()
}
}
private val communityDetailLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
refreshCreatorChannelCommunity()
}
}
private val fanTalkWriteLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
refreshCreatorChannelFanTalk()
homeActionDelegate?.refreshHome()
}
}
private val fanTalkDetailLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
refreshCreatorChannelFanTalk()
homeActionDelegate?.refreshHome()
}
}
private val liveRoomCreateLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
@@ -192,6 +218,7 @@ class CreatorChannelActivity :
binding.btnCreatorChannelDonationWrite.setOnClickListener {
findDonationFragment()?.onCreatorChannelDonationFloatingButtonClicked()
}
binding.btnCreatorChannelFantalkFixedPlus.setOnClickListener { openFanTalkWrite() }
binding.btnCreatorChannelOwnerCta.setOnClickListener { onOwnerCtaClicked() }
binding.tvChatButton.setOnClickListener {
currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) }
@@ -372,14 +399,18 @@ class CreatorChannelActivity :
private fun showUserBlockDialog() {
val nickname = currentHeader?.nickname.orEmpty()
AlertDialog.Builder(this)
.setTitle(getString(R.string.screen_live_room_block_title))
.setMessage(getString(R.string.screen_live_room_block_message_user, nickname))
.setPositiveButton(getString(R.string.screen_live_room_block_confirm)) { _, _ ->
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.screen_live_room_block_title),
desc = getString(R.string.screen_live_room_block_message_user, nickname),
confirmButtonTitle = getString(R.string.screen_live_room_block_confirm),
confirmButtonClick = {
homeActionDelegate?.blockUser()
}
.setNegativeButton(getString(R.string.cancel)) { _, _ -> }
.show()
},
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {}
).show(screenWidth)
}
private fun showUserReportDialog() {
@@ -389,9 +420,18 @@ class CreatorChannelActivity :
}
private fun showProfileReportDialog() {
ProfileReportDialog(this, layoutInflater) {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.dialog_profile_report_title),
desc = getString(R.string.dialog_profile_report_notice),
confirmButtonTitle = getString(R.string.report_button),
confirmButtonClick = {
homeActionDelegate?.reportProfile()
}.show(resources.displayMetrics.widthPixels)
},
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {}
).show(resources.displayMetrics.widthPixels)
}
private fun setupTabsAndPager() {
@@ -410,6 +450,7 @@ class CreatorChannelActivity :
}
updateOwnerFabVisibility()
updateDonationFloatingButtonVisibility()
updateFanTalkFixedPlusVisibility()
updateOwnerCtaVisibility()
updateCreatorChannelTabViewportHeight()
updateViewPagerHeight()
@@ -445,6 +486,7 @@ class CreatorChannelActivity :
bindTitleBar(header)
updateOwnerFabVisibility()
updateDonationFloatingButtonVisibility()
updateFanTalkFixedPlusVisibility()
updateOwnerCtaVisibility()
if (binding.viewPager.currentItem == CreatorChannelTab.Audio.ordinal) {
binding.viewPager.post {
@@ -504,6 +546,11 @@ class CreatorChannelActivity :
updateViewPagerHeight()
}
override fun onCreatorChannelHomeTabRequested(tab: CreatorChannelTab) {
selectCreatorChannelTab(tab)
scrollCreatorChannelToTop()
}
override fun onCreatorChannelLiveContentChanged() {
updateCreatorChannelTabViewportHeight()
updateViewPagerHeight {
@@ -545,12 +592,44 @@ class CreatorChannelActivity :
}
}
override fun onCreatorChannelCommunityPostClicked(postId: Long) {
if (postId <= 0L) return
if (SharedPreferenceManager.token.isBlank()) {
showLoginActivity()
return
}
communityDetailLauncher.launch(CreatorChannelCommunityDetailActivity.newIntent(this, postId))
}
override fun onCreatorChannelFanTalkContentChanged() {
updateViewPagerHeight {
postCheckCreatorChannelCurrentTabNeedsMore()
}
}
override fun onCreatorChannelFanTalkWriteClicked() {
openFanTalkWrite()
}
override fun onCreatorChannelFanTalkItemClicked(item: CreatorChannelFanTalkUiModel) {
if (currentHeader?.isOwner != true) return
if (creatorId <= 0L || item.fanTalkId <= 0L) return
if (SharedPreferenceManager.token.isBlank()) {
showLoginActivity()
return
}
fanTalkDetailLauncher.launch(
CreatorChannelFanTalkDetailActivity.newIntent(this, creatorId, item.toFanTalkDetailPayload())
)
}
override fun onCreatorChannelFanTalkFloatingButtonVisibilityChanged(isVisible: Boolean) {
isFanTalkFixedPlusVisible = isVisible
updateFanTalkFixedPlusVisibility()
}
override fun onCreatorChannelDonationContentChanged() {
updateViewPagerHeight {
postCheckCreatorChannelCurrentTabNeedsMore()
@@ -582,7 +661,7 @@ class CreatorChannelActivity :
}
override fun onCreatorChannelFanTalkDeleteClicked(fanTalkId: Long) {
SodaDialog(
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.screen_user_profile_cheer_delete_title),
@@ -618,7 +697,7 @@ class CreatorChannelActivity :
}
private fun showCreatorChannelCommunityDeleteDialog(item: CreatorChannelCommunityPostUiModel) {
SodaDialog(
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.screen_creator_community_delete_title),
@@ -822,6 +901,21 @@ class CreatorChannelActivity :
binding.btnCreatorChannelDonationWrite.isVisible = shouldShowDonationFloatingButton
}
private fun updateFanTalkFixedPlusVisibility() {
val shouldShowFanTalkFixedPlus =
isFanTalkFixedPlusVisible && binding.viewPager.currentItem == CreatorChannelTab.FanTalk.ordinal
binding.btnCreatorChannelFantalkFixedPlus.isVisible = shouldShowFanTalkFixedPlus
}
private fun openFanTalkWrite() {
if (creatorId <= 0L) return
if (SharedPreferenceManager.token.isBlank()) {
showLoginActivity()
return
}
fanTalkWriteLauncher.launch(CreatorChannelFanTalkWriteActivity.newIntent(this, creatorId))
}
private fun onOwnerFabCommunityClicked() {
collapseOwnerFab(animate = false)
communityWriteLauncher.launch(Intent(this, CreatorCommunityWriteActivity::class.java))
@@ -940,7 +1034,7 @@ class CreatorChannelActivity :
if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.auth_title),
@@ -948,8 +1042,7 @@ class CreatorChannelActivity :
confirmButtonTitle = getString(R.string.auth_go),
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {},
descGravity = Gravity.CENTER
cancelButtonClick = {}
).show(screenWidth)
return
}
@@ -1096,6 +1189,14 @@ class CreatorChannelActivity :
)
}
private fun selectCreatorChannelTab(tab: CreatorChannelTab) {
binding.viewPager.currentItem = tab.ordinal
}
private fun scrollCreatorChannelToTop() {
binding.nestedScrollView.scrollTo(0, 0)
}
override fun onResume() {
super.onResume()
binding.btnCreatorChannelOwnerCta.isEnabled = true

View File

@@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleRe
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelHomeSectionAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -25,7 +26,9 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
onScheduleClick = ::onScheduleClicked,
onAudioContentClick = ::onAudioContentClicked,
onSeriesClick = ::onSeriesClicked,
onDonationClick = ::onDonationClicked
onDonationClick = ::onDonationClicked,
onSectionChevronClick = ::onSectionChevronClicked,
onCommunityClick = ::onCommunityClicked
)
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
@@ -123,6 +126,14 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
host.onCreatorChannelDonationClicked()
}
private fun onSectionChevronClicked(tab: CreatorChannelTab) {
host.onCreatorChannelHomeTabRequested(tab)
}
private fun onCommunityClicked(postId: Long) {
host.onCreatorChannelCommunityPostClicked(postId)
}
private fun onCurrentLiveClicked(live: CreatorChannelLiveResponse) {
host.onCreatorChannelCurrentLiveClicked(live)
}
@@ -137,6 +148,8 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
fun onCreatorChannelHomeActionDelegateReady(delegate: HomeActionDelegate?)
fun onCreatorChannelHomeContentChanged()
fun onCreatorChannelDonationClicked()
fun onCreatorChannelHomeTabRequested(tab: CreatorChannelTab)
fun onCreatorChannelCommunityPostClicked(postId: Long)
fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse)
}

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel
import android.content.Intent
import android.view.LayoutInflater
import android.widget.Toast
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
@@ -27,7 +28,7 @@ import java.util.Locale
import java.util.TimeZone
class CreatorChannelLiveCoordinator(
private val activity: CreatorChannelActivity,
private val activity: FragmentActivity,
private val layoutInflater: LayoutInflater,
private val fragmentManager: FragmentManager,
private val liveViewModel: LiveViewModel,
@@ -65,11 +66,38 @@ class CreatorChannelLiveCoordinator(
}
liveViewModel.getRoomDetail(roomId) {
if (!it.channelName.isNullOrBlank()) {
if (it.manager.id == SharedPreferenceManager.userId) {
enterLiveRoom(roomId, it, onEnterRoomSuccess)
}
}
fun enterLiveRoom(roomId: Long, roomDetail: GetRoomDetailResponse) {
activity.startService(
Intent(activity, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.STOP.name
}
)
activity.startService(
Intent(activity, AudioContentPlayerService::class.java).apply {
action = "STOP_SERVICE"
}
)
val onEnterRoomSuccess = {
activity.runOnUiThread { openLiveRoom(roomId) }
}
enterLiveRoom(roomId, roomDetail, onEnterRoomSuccess)
}
private fun enterLiveRoom(
roomId: Long,
roomDetail: GetRoomDetailResponse,
onEnterRoomSuccess: () -> Unit
) {
if (!roomDetail.channelName.isNullOrBlank()) {
if (roomDetail.manager.id == SharedPreferenceManager.userId) {
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
} else if (it.price == 0 || it.isPaid) {
if (it.isPrivateRoom) {
} else if (roomDetail.price == 0 || roomDetail.isPaid) {
if (roomDetail.isPrivateRoom) {
LiveRoomPasswordDialog(
activity = activity,
layoutInflater = layoutInflater,
@@ -88,9 +116,9 @@ class CreatorChannelLiveCoordinator(
} else {
showPaidLiveEntryDialog(
roomId = roomId,
beginDateTimeUtc = it.beginDateTimeUtc,
price = it.price,
isPrivateRoom = it.isPrivateRoom,
beginDateTimeUtc = roomDetail.beginDateTimeUtc,
price = roomDetail.price,
isPrivateRoom = roomDetail.isPrivateRoom,
onEnterRoomSuccess = onEnterRoomSuccess
)
}
@@ -98,7 +126,6 @@ class CreatorChannelLiveCoordinator(
showLiveRoomDetail(roomId)
}
}
}
private fun reservationRoom(roomId: Long) {
liveViewModel.getRoomDetail(roomId) {

View File

@@ -28,11 +28,14 @@ class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommu
private val viewModel: CreatorChannelCommunityViewModel by viewModel()
private val listAdapter = CreatorChannelCommunityListAdapter(
onPostClick = { item -> host.onCreatorChannelCommunityPostClicked(item.postId) },
onPlayClick = { item -> toggleCommunityAudio(item) },
onOwnerMoreClick = { item -> host.onCreatorChannelCommunityOwnerMoreClicked(item) },
isPlayingContent = { postId -> mediaPlayerManager?.isPlayingContent(postId) == true }
)
private val gridAdapter = CreatorChannelCommunityGridAdapter()
private val gridAdapter = CreatorChannelCommunityGridAdapter(
onPostClick = { item -> host.onCreatorChannelCommunityPostClicked(item.postId) }
)
private var mediaPlayerManager: CreatorCommunityMediaPlayerManager? = null
private var currentContentState: CreatorChannelCommunityUiState.Content? = null
private var lastContentLayoutKey: CreatorChannelCommunityContentLayoutKey? = null
@@ -225,6 +228,7 @@ class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommu
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelCommunityContentChanged()
fun onCreatorChannelCommunityPostClicked(postId: Long)
fun onCreatorChannelCommunityOwnerMoreClicked(item: CreatorChannelCommunityPostUiModel)
}

View File

@@ -0,0 +1,241 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import coil.transform.CircleCropTransformation
import com.bumptech.glide.Glide
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityContentItem
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyActivity
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChannelCommunityDetailBinding>(
ActivityCreatorChannelCommunityDetailBinding::inflate
) {
private val viewModel: CreatorChannelCommunityDetailViewModel by viewModel()
private val replyLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK && postId > 0L) {
setResult(Activity.RESULT_OK)
viewModel.loadDetail(postId)
}
}
private val commentAdapter = CreatorChannelCommunityCommentAdapter(
onReplyClick = { comment -> replyLauncher.launch(CreatorChannelCommunityReplyActivity.newIntent(this, postId, comment)) },
onModifyClick = { comment -> viewModel.startCommentEdit(comment.commentId, comment.comment) },
onDeleteClick = { comment -> showDeleteCommentDialog(comment.commentId) }
)
private lateinit var mediaPlayerManager: CreatorCommunityMediaPlayerManager
private var postId: Long = 0L
private var renderedEditingCommentId: Long? = null
override fun setupView() {
mediaPlayerManager = CreatorCommunityMediaPlayerManager(this) { }
postId = intent.getLongExtra(EXTRA_POST_ID, 0L)
if (postId <= 0L) {
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
finish()
return
}
binding.btnCreatorChannelCommunityDetailBack.setOnClickListener { finish() }
binding.rvCreatorChannelCommunityDetailComments.layoutManager = LinearLayoutManager(this)
binding.rvCreatorChannelCommunityDetailComments.adapter = commentAdapter
binding.scrollCreatorChannelCommunityDetail.setOnScrollChangeListener { scrollView, _, scrollY, _, oldScrollY ->
val contentHeight = binding.scrollCreatorChannelCommunityDetail.getChildAt(0)?.measuredHeight ?: 0
val isScrollingDown = scrollY > oldScrollY
val isAtBottom = scrollY >= contentHeight - scrollView.measuredHeight
if (isScrollingDown && isAtBottom) {
viewModel.loadMoreComments()
}
}
binding.etCreatorChannelCommunityDetailComment.movementMethod = ScrollingMovementMethod()
binding.etCreatorChannelCommunityDetailComment.doAfterTextChanged { editable ->
viewModel.updateCommentInput(editable?.toString().orEmpty())
}
binding.btnCreatorChannelCommunityDetailSend.setOnClickListener { viewModel.submitComment() }
binding.btnCreatorChannelCommunityDetailEditCancel.setOnClickListener { viewModel.cancelCommentEdit() }
binding.ivCreatorChannelCommunityDetailHeart.setOnClickListener { viewModel.toggleLike() }
viewModel.detailStateLiveData.observe(this) { state -> render(state) }
viewModel.commentWrittenEventLiveData.observe(this) { written ->
if (written == true) {
setResult(Activity.RESULT_OK)
hideCommentKeyboard()
viewModel.consumeCommentWrittenEvent()
}
}
viewModel.postChangedEventLiveData.observe(this) { changed ->
if (changed == true) {
setResult(Activity.RESULT_OK)
val current = viewModel.detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
if (current?.editingCommentId != null) {
hideCommentKeyboard()
}
viewModel.consumePostChangedEvent()
}
}
viewModel.toastLiveData.observe(this) { event ->
event.consume()?.let { toast ->
val message = toast.message ?: toast.resId?.let(::getString)
message?.let(::showToast)
}
}
viewModel.loadDetail(postId)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onPause() {
mediaPlayerManager.pauseContent()
super.onPause()
}
override fun onDestroy() {
mediaPlayerManager.stopContent()
super.onDestroy()
}
private fun render(state: CreatorChannelCommunityDetailUiState) {
when (state) {
CreatorChannelCommunityDetailUiState.Loading -> Unit
is CreatorChannelCommunityDetailUiState.Error -> {
Toast.makeText(
this,
state.message ?: getString(R.string.creator_channel_community_error_message),
Toast.LENGTH_SHORT
).show()
finish()
}
is CreatorChannelCommunityDetailUiState.Content -> renderContent(state)
}
}
private fun renderContent(content: CreatorChannelCommunityDetailUiState.Content) = with(binding) {
val post = content.post
ivCreatorChannelCommunityDetailProfile.loadUrl(post.creatorProfileUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
tvCreatorChannelCommunityDetailTime.text = post.createdAtText
tvCreatorChannelCommunityDetailBody.text = post.content
tvCreatorChannelCommunityDetailLikeCount.text = post.likeCount.moneyFormat()
tvCreatorChannelCommunityDetailCommentCount.text = post.commentCount.moneyFormat()
tvCreatorChannelCommunityDetailCommentHeader.text = getString(
R.string.creator_channel_community_comment_header,
post.commentCount.moneyFormat()
)
ivCreatorChannelCommunityDetailHeart.setImageResource(
if (post.isLiked) R.drawable.ic_feed_community_heart_fill else R.drawable.ic_feed_community_heart
)
layoutCreatorChannelCommunityDetailPrice.isVisible = post.isLocked
tvCreatorChannelCommunityDetailPrice.text = post.price.moneyFormat()
tvCreatorChannelCommunityDetailPurchased.isVisible = post.existOrdered && post.price > 0
layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall
layoutCreatorChannelCommunityDetailPaywall.isVisible = post.showPaywall
tvCreatorChannelCommunityDetailPaywallPrice.text = post.price.moneyFormat()
if (post.imageUrl != null) {
Glide.with(ivCreatorChannelCommunityDetailImage)
.load(post.imageUrl)
.placeholder(R.drawable.ic_place_holder)
.into(ivCreatorChannelCommunityDetailImage)
} else {
Glide.with(ivCreatorChannelCommunityDetailImage).clear(ivCreatorChannelCommunityDetailImage)
}
tvCreatorChannelCommunityDetailAudio.isVisible = post.audioUrl != null
tvCreatorChannelCommunityDetailAudio.text = getString(R.string.creator_channel_tab_audio)
tvCreatorChannelCommunityDetailAudio.setOnClickListener {
toggleAudio(post)
}
rvCreatorChannelCommunityDetailComments.isVisible = content.isCommentAvailable
tvCreatorChannelCommunityDetailCommentHeader.isVisible = content.isCommentAvailable
layoutCreatorChannelCommunityDetailInputBar.isVisible = content.isCommentInputVisible
ivCreatorChannelCommunityDetailComment.isVisible = content.isCommentAvailable
tvCreatorChannelCommunityDetailCommentCount.isVisible = content.isCommentAvailable
commentAdapter.submitItems(content.comments)
if (etCreatorChannelCommunityDetailComment.text.toString() != content.commentInput) {
etCreatorChannelCommunityDetailComment.setText(content.commentInput)
etCreatorChannelCommunityDetailComment.setSelection(content.commentInput.length)
}
btnCreatorChannelCommunityDetailEditCancel.isVisible = content.editingCommentId != null
if (content.editingCommentId != null && content.editingCommentId != renderedEditingCommentId) {
showCommentKeyboard()
}
renderedEditingCommentId = content.editingCommentId
updateSendButton(content.commentInput.trim().isNotEmpty() && !content.isSendingComment)
}
private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelCommunityDetailSend) {
isEnabled = enabled
setBackgroundResource(
if (enabled) {
R.drawable.bg_creator_channel_community_send_enabled
} else {
R.drawable.bg_creator_channel_community_send_disabled
}
)
setImageResource(if (enabled) R.drawable.ic_new_arrow_up_white else R.drawable.ic_new_arrow_up_gray)
}
private fun toggleAudio(post: CreatorChannelCommunityPostDetailUiModel) {
val audioUrl = post.audioUrl ?: return
mediaPlayerManager.toggleContent(CreatorCommunityContentItem(post.postId, audioUrl))
}
private fun showDeleteCommentDialog(commentId: Long) {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.confirm_delete_title),
desc = getString(R.string.confirm_delete_message),
confirmButtonTitle = getString(R.string.confirm),
confirmButtonClick = { viewModel.deleteComment(commentId) },
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
}
private fun showCommentKeyboard() {
val editText = binding.etCreatorChannelCommunityDetailComment
editText.requestFocus()
editText.post {
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideCommentKeyboard() {
val editText = binding.etCreatorChannelCommunityDetailComment
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(editText.windowToken, 0)
editText.clearFocus()
}
companion object {
private const val EXTRA_POST_ID = "extra_post_id"
fun newIntent(context: Context, postId: Long): Intent {
return Intent(context, CreatorChannelCommunityDetailActivity::class.java)
.putExtra(EXTRA_POST_ID, postId)
}
}
}

View File

@@ -0,0 +1,531 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
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.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelCommunityDetailViewModel(
private val repository: CreatorChannelRepository,
private val legacyRepository: CreatorCommunityRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _detailStateLiveData = MutableLiveData<CreatorChannelCommunityDetailUiState>()
val detailStateLiveData: LiveData<CreatorChannelCommunityDetailUiState>
get() = _detailStateLiveData
private val _commentWrittenEventLiveData = MutableLiveData<Boolean>()
val commentWrittenEventLiveData: LiveData<Boolean>
get() = _commentWrittenEventLiveData
private val _postChangedEventLiveData = MutableLiveData<Boolean>()
val postChangedEventLiveData: LiveData<Boolean>
get() = _postChangedEventLiveData
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
get() = _toastLiveData
private var postId: Long = 0L
private var detailRequestGeneration = 0
private var commentRequestGeneration = 0
private var isLoadingComments = false
private var isTogglingLike = false
private var isSendingComment = false
private var isModifyingComment = false
fun loadDetail(postId: Long) {
if (postId <= 0) return
this.postId = postId
val generation = ++detailRequestGeneration
commentRequestGeneration++
isLoadingComments = false
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Loading
compositeDisposable.add(
repository.getCommunityPostDetail(postId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
if (generation == detailRequestGeneration) {
handleDetailResponse(response)
}
},
{
if (generation == detailRequestGeneration) {
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(it.message)
}
}
)
)
}
fun loadMoreComments() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.isCommentAvailable || !content.hasNextComment || isLoadingComments) return
loadComments(page = content.commentPage + 1, append = true)
}
fun toggleLike() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (isTogglingLike) return
val previousPost = content.post
val nextPost = previousPost.copy(
isLiked = !previousPost.isLiked,
likeCount = (previousPost.likeCount + if (previousPost.isLiked) -1 else 1).coerceAtLeast(0)
)
isTogglingLike = true
_detailStateLiveData.value = content.copy(post = nextPost)
compositeDisposable.add(
legacyRepository.communityPostLike(postId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isTogglingLike = false
if (response.success) {
_postChangedEventLiveData.value = true
} else {
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(post = previousPost)
}
},
{
isTogglingLike = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(post = previousPost)
}
)
)
}
fun updateCommentInput(input: String) {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.isCommentAvailable) return
_detailStateLiveData.value = content.copy(commentInput = input)
}
fun submitComment() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
val comment = content.commentInput.trim()
val editingCommentId = content.editingCommentId
if (editingCommentId != null) {
submitCommentEdit(content, editingCommentId, comment)
return
}
if (!content.isCommentAvailable || comment.isBlank() || isSendingComment) return
isSendingComment = true
_detailStateLiveData.value = content.copy(isSendingComment = true)
compositeDisposable.add(
legacyRepository.registerComment(
postId = postId,
comment = comment,
parentId = null,
isSecret = false,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isSendingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
if (response.success) {
_commentWrittenEventLiveData.value = true
_detailStateLiveData.value = current.copy(commentInput = "", isSendingComment = false)
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
},
{
isSendingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
showMutationFailureToast()
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
)
)
}
fun consumeCommentWrittenEvent() {
_commentWrittenEventLiveData.value = false
}
fun consumePostChangedEvent() {
_postChangedEventLiveData.value = false
}
fun startCommentEdit(commentId: Long, comment: String) {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (commentId <= 0) return
_detailStateLiveData.value = content.copy(
commentInput = comment,
editingCommentId = commentId,
editingOriginalComment = comment
)
}
fun cancelCommentEdit() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
_detailStateLiveData.value = content.copy(
commentInput = "",
editingCommentId = null,
editingOriginalComment = ""
)
}
fun modifyComment(commentId: Long, comment: String) {
val trimmedComment = comment.trim()
if (commentId <= 0 || isModifyingComment) return
if (trimmedComment.isBlank()) {
showBlankCommentToast()
return
}
modifyComment(ModifyCommentRequest(commentId = commentId, comment = trimmedComment))
}
fun deleteComment(commentId: Long) {
if (commentId <= 0 || isModifyingComment) return
modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false))
}
private fun submitCommentEdit(
content: CreatorChannelCommunityDetailUiState.Content,
commentId: Long,
comment: String
) {
if (commentId <= 0 || isModifyingComment) return
if (comment.isBlank()) {
showBlankCommentToast()
return
}
if (comment == content.editingOriginalComment.trim()) {
showNoChangeToast()
return
}
isModifyingComment = true
_detailStateLiveData.value = content.copy(isSendingComment = true)
compositeDisposable.add(
legacyRepository.modifyComment(ModifyCommentRequest(commentId = commentId, comment = comment), authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
if (response.success) {
_postChangedEventLiveData.value = true
_detailStateLiveData.value = current.copy(
commentInput = "",
isSendingComment = false,
editingCommentId = null,
editingOriginalComment = ""
)
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
},
{
isModifyingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
showMutationFailureToast()
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
)
)
}
private fun handleDetailResponse(response: ApiResponse<CreatorChannelCommunityPostDetailResponse>) {
val data = response.data
if (!response.success || data == null) {
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(response.message)
return
}
val isCreatorOwner = data.creatorId == SharedPreferenceManager.userId
val comments = if (data.isCommentAvailable) {
data.comments.comments.map { it.toUiModel(isCreatorOwner) }
} else {
emptyList()
}
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Content(
post = data.toUiModel(),
isCommentAvailable = data.isCommentAvailable,
isCommentInputVisible = data.isCommentAvailable,
comments = comments,
commentPage = data.comments.page,
hasNextComment = data.isCommentAvailable && data.comments.hasNext
)
}
private fun loadComments(page: Int, append: Boolean) {
if (postId <= 0 || isLoadingComments && append) return
val generation = if (append) {
commentRequestGeneration
} else {
++commentRequestGeneration
}
isLoadingComments = true
val beforeRequest = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
if (beforeRequest != null) {
_detailStateLiveData.value = beforeRequest.copy(isLoadingComments = true)
}
compositeDisposable.add(
repository.getCommunityPostComments(postId, page, DEFAULT_PAGE_SIZE, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response -> handleCommentsResponse(response, append, generation) },
{
if (generation != commentRequestGeneration) return@subscribe
isLoadingComments = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(isLoadingComments = false)
}
)
)
}
private fun handleCommentsResponse(
response: ApiResponse<CreatorChannelCommunityCommentsResponse>,
append: Boolean,
generation: Int
) {
if (generation != commentRequestGeneration) return
isLoadingComments = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
val data = response.data
if (!response.success || data == null) {
_detailStateLiveData.value = current.copy(isLoadingComments = false)
return
}
val isCreatorOwner = current.post.creatorId == SharedPreferenceManager.userId
val nextComments = data.comments.map { it.toUiModel(isCreatorOwner) }
_detailStateLiveData.value = current.copy(
post = current.post.copy(commentCount = data.commentCount),
comments = if (append) current.comments + nextComments else nextComments,
commentPage = data.page,
hasNextComment = data.hasNext,
isLoadingComments = false
)
}
private fun modifyComment(request: ModifyCommentRequest) {
if (postId <= 0) return
isModifyingComment = true
compositeDisposable.add(
legacyRepository.modifyComment(request, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingComment = false
if (response.success) {
_postChangedEventLiveData.value = true
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
}
},
{
isModifyingComment = false
showMutationFailureToast()
}
)
)
}
private fun showMutationFailureToast(message: String? = null) {
_toastLiveData.value = CreatorChannelEvent(
if (message.isNullOrBlank()) {
ToastMessage(resId = R.string.common_error_unknown)
} else {
ToastMessage(message = message)
}
)
}
private fun showBlankCommentToast() {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.screen_creator_community_write_content_hint)
)
}
private fun showNoChangeToast() {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.audio_content_comment_no_change)
)
}
private fun CreatorChannelCommunityPostDetailResponse.toUiModel() = CreatorChannelCommunityPostDetailUiModel(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfileUrl,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
content = content,
imageUrl = imageUrl.takeUnless { isLocked() },
audioUrl = audioUrl.takeUnless { isLocked() },
price = price,
existOrdered = existOrdered,
likeCount = likeCount,
commentCount = commentCount,
isLiked = isLiked,
isPinned = isPinned,
isLocked = isLocked(),
showPaywall = isLocked()
)
private fun CreatorChannelCommunityPostDetailResponse.isLocked(): Boolean {
return price > 0 && !existOrdered && creatorId != SharedPreferenceManager.userId
}
private fun CreatorChannelCommunityCommentResponse.toUiModel(
isCreatorOwner: Boolean
) = CreatorChannelCommunityCommentUiModel(
commentId = commentId,
memberId = writerId,
memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content,
likeCount = 0,
replyCount = if (latestReply != null) 1 else 0,
isLiked = false,
isMine = writerId == SharedPreferenceManager.userId,
isCreatorOwner = isCreatorOwner,
latestReply = latestReply?.toUiModel(parentCommentId = commentId, isCreatorOwner = isCreatorOwner)
)
private fun CreatorChannelCommunityReplyResponse.toUiModel(
parentCommentId: Long,
isCreatorOwner: Boolean
) = CreatorChannelCommunityReplyUiModel(
replyId = commentId,
commentId = parentCommentId,
memberId = writerId,
memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content,
likeCount = 0,
isLiked = false,
isMine = writerId == SharedPreferenceManager.userId,
isCreatorOwner = isCreatorOwner
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelCommunityDetailUiState {
data object Loading : CreatorChannelCommunityDetailUiState
data class Error(val message: String?) : CreatorChannelCommunityDetailUiState
data class Content(
val post: CreatorChannelCommunityPostDetailUiModel,
val isCommentAvailable: Boolean,
val isCommentInputVisible: Boolean,
val comments: List<CreatorChannelCommunityCommentUiModel>,
val commentPage: Int,
val hasNextComment: Boolean,
val isLoadingComments: Boolean = false,
val commentInput: String = "",
val isSendingComment: Boolean = false,
val editingCommentId: Long? = null,
val editingOriginalComment: String = ""
) : CreatorChannelCommunityDetailUiState
}
data class CreatorChannelCommunityPostDetailUiModel(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String?,
val createdAtText: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
val isLiked: Boolean,
val isPinned: Boolean,
val isLocked: Boolean,
val showPaywall: Boolean
)
data class CreatorChannelCommunityCommentUiModel(
val commentId: Long,
val memberId: Long,
val memberNickname: String,
val memberProfileUrl: String?,
val createdAtUtc: String,
val createdAtText: String,
val comment: String,
val likeCount: Int,
val replyCount: Int,
val isLiked: Boolean,
val isMine: Boolean,
val isCreatorOwner: Boolean,
val latestReply: CreatorChannelCommunityReplyUiModel?
)
data class CreatorChannelCommunityReplyUiModel(
val replyId: Long,
val commentId: Long,
val memberId: Long,
val memberNickname: String,
val memberProfileUrl: String?,
val createdAtUtc: String,
val createdAtText: String,
val comment: String,
val likeCount: Int,
val isLiked: Boolean,
val isMine: Boolean,
val isCreatorOwner: Boolean
)

View File

@@ -0,0 +1,63 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CreatorChannelCommunityPostDetailResponse(
@SerializedName("postId") val postId: Long,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String?,
@SerializedName("createdAtUtc") val createdAtUtc: String,
@SerializedName("content") val content: String,
@SerializedName("imageUrl") val imageUrl: String?,
@SerializedName("audioUrl") val audioUrl: String?,
@SerializedName("price") val price: Int,
@SerializedName("existOrdered") val existOrdered: Boolean,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("commentCount") val commentCount: Int,
@SerializedName("isLiked") val isLiked: Boolean,
@SerializedName("isPinned") val isPinned: Boolean,
@SerializedName("comments") val comments: CreatorChannelCommunityCommentsResponse
)
@Keep
data class CreatorChannelCommunityCommentsResponse(
@SerializedName("commentCount") val commentCount: Int,
@SerializedName("comments") val comments: List<CreatorChannelCommunityCommentResponse>,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@Keep
data class CreatorChannelCommunityCommentResponse(
@SerializedName("commentId") val commentId: Long,
@SerializedName("writerId") val writerId: Long,
@SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String,
@SerializedName("writerNickname") val writerNickname: String,
@SerializedName("content") val content: String,
@SerializedName("isSecret") val isSecret: Boolean,
@SerializedName("createdAtUtc") val createdAtUtc: String,
@SerializedName("latestReply") val latestReply: CreatorChannelCommunityReplyResponse?
)
@Keep
data class CreatorChannelCommunityReplyResponse(
@SerializedName("commentId") val commentId: Long,
@SerializedName("writerId") val writerId: Long,
@SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String,
@SerializedName("writerNickname") val writerNickname: String,
@SerializedName("content") val content: String,
@SerializedName("createdAtUtc") val createdAtUtc: String
)
@Keep
data class CreatorChannelCommunityRepliesResponse(
@SerializedName("replies") val replies: List<CreatorChannelCommunityReplyResponse>,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)

View File

@@ -0,0 +1,229 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.text.method.ScrollingMovementMethod
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityReplyBinding
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.ui.CreatorChannelCommunityReplyAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannelCommunityReplyBinding>(
ActivityCreatorChannelCommunityReplyBinding::inflate
) {
private val viewModel: CreatorChannelCommunityReplyViewModel by viewModel()
private val replyAdapter = CreatorChannelCommunityReplyAdapter(
onModifyClick = { reply -> viewModel.startReplyEdit(reply.replyId, reply.comment) },
onDeleteClick = { reply -> showDeleteReplyDialog(reply.replyId) }
)
private var postId: Long = 0L
private var renderedEditingTarget: CreatorChannelCommunityReplyEditTarget? = null
override fun setupView() {
postId = intent.getLongExtra(EXTRA_POST_ID, 0L)
val parentComment = parentCommentFromIntent()
if (postId <= 0L || parentComment.commentId <= 0L) {
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
finish()
return
}
binding.btnCreatorChannelCommunityReplyBack.setOnClickListener { finish() }
binding.rvCreatorChannelCommunityReply.layoutManager = LinearLayoutManager(this)
binding.rvCreatorChannelCommunityReply.adapter = replyAdapter
binding.rvCreatorChannelCommunityReply.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy > 0 && !recyclerView.canScrollVertically(1)) {
viewModel.loadMoreReplies()
}
}
}
)
binding.etCreatorChannelCommunityReply.movementMethod = ScrollingMovementMethod()
binding.etCreatorChannelCommunityReply.doAfterTextChanged { editable ->
viewModel.updateReplyInput(editable?.toString().orEmpty())
}
binding.btnCreatorChannelCommunityReplySend.setOnClickListener { viewModel.submitReply() }
binding.btnCreatorChannelCommunityReplyEditCancel.setOnClickListener { viewModel.cancelReplyEdit() }
viewModel.replyStateLiveData.observe(this) { state -> render(state) }
viewModel.replyWrittenEventLiveData.observe(this) { written ->
if (written == true) {
hideReplyKeyboard()
viewModel.consumeReplyWrittenEvent()
}
}
viewModel.replyChangedEventLiveData.observe(this) { changed ->
if (changed == true) {
setResult(Activity.RESULT_OK)
hideReplyKeyboard()
viewModel.consumeReplyChangedEvent()
}
}
viewModel.parentCommentChangedEventLiveData.observe(this) { changed ->
if (changed == true) {
setResult(Activity.RESULT_OK)
hideReplyKeyboard()
viewModel.consumeParentCommentChangedEvent()
}
}
viewModel.parentCommentDeletedEventLiveData.observe(this) { deleted ->
if (deleted == true) {
setResult(Activity.RESULT_OK)
viewModel.consumeParentCommentDeletedEvent()
finish()
}
}
viewModel.toastLiveData.observe(this) { event ->
event.consume()?.let { toast ->
val message = toast.message ?: toast.resId?.let(::getString)
message?.let(::showToast)
}
}
viewModel.loadReplies(postId, parentComment)
}
private fun render(state: CreatorChannelCommunityReplyUiState) {
when (state) {
is CreatorChannelCommunityReplyUiState.Content -> renderContent(state)
}
}
private fun renderContent(content: CreatorChannelCommunityReplyUiState.Content) = with(binding) {
CreatorChannelCommunityCommentAdapter.bindComment(
layoutCreatorChannelCommunityReplyParent,
content.parentComment,
onReplyClick = {},
onModifyClick = { comment -> viewModel.startParentCommentEdit(comment.comment) },
onDeleteClick = { showDeleteParentCommentDialog() }
)
layoutCreatorChannelCommunityReplyParent.layoutCreatorChannelCommunityCommentLatestReply.isVisible = false
replyAdapter.submitItems(content.replies)
if (etCreatorChannelCommunityReply.text.toString() != content.replyInput) {
etCreatorChannelCommunityReply.setText(content.replyInput)
etCreatorChannelCommunityReply.setSelection(content.replyInput.length)
}
btnCreatorChannelCommunityReplyEditCancel.isVisible = content.editingTarget != null
if (content.editingTarget != null && content.editingTarget != renderedEditingTarget) {
showReplyKeyboard()
}
renderedEditingTarget = content.editingTarget
updateSendButton(content.replyInput.trim().isNotEmpty() && !content.isSendingReply)
}
private fun showDeleteParentCommentDialog() {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.confirm_delete_title),
desc = getString(R.string.confirm_delete_message),
confirmButtonTitle = getString(R.string.confirm),
confirmButtonClick = { viewModel.deleteParentComment() },
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
}
private fun showDeleteReplyDialog(replyId: Long) {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.confirm_delete_title),
desc = getString(R.string.confirm_delete_message),
confirmButtonTitle = getString(R.string.confirm),
confirmButtonClick = { viewModel.deleteReply(replyId) },
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
}
private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelCommunityReplySend) {
isEnabled = enabled
setBackgroundResource(
if (enabled) {
R.drawable.bg_creator_channel_community_send_enabled
} else {
R.drawable.bg_creator_channel_community_send_disabled
}
)
setImageResource(if (enabled) R.drawable.ic_new_arrow_up_white else R.drawable.ic_new_arrow_up_gray)
}
private fun showReplyKeyboard() {
val editText = binding.etCreatorChannelCommunityReply
editText.requestFocus()
editText.post {
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideReplyKeyboard() {
val editText = binding.etCreatorChannelCommunityReply
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(editText.windowToken, 0)
editText.clearFocus()
}
private fun parentCommentFromIntent(): CreatorChannelCommunityCommentUiModel {
return CreatorChannelCommunityCommentUiModel(
commentId = intent.getLongExtra(EXTRA_COMMENT_ID, 0L),
memberId = intent.getLongExtra(EXTRA_MEMBER_ID, 0L),
memberNickname = intent.getStringExtra(EXTRA_MEMBER_NICKNAME).orEmpty(),
memberProfileUrl = intent.getStringExtra(EXTRA_MEMBER_PROFILE_URL),
createdAtUtc = intent.getStringExtra(EXTRA_CREATED_AT_UTC).orEmpty(),
createdAtText = intent.getStringExtra(EXTRA_CREATED_AT_TEXT).orEmpty(),
comment = intent.getStringExtra(EXTRA_COMMENT).orEmpty(),
likeCount = intent.getIntExtra(EXTRA_LIKE_COUNT, 0),
replyCount = intent.getIntExtra(EXTRA_REPLY_COUNT, 0),
isLiked = intent.getBooleanExtra(EXTRA_IS_LIKED, false),
isMine = intent.getBooleanExtra(EXTRA_IS_MINE, false),
isCreatorOwner = intent.getBooleanExtra(EXTRA_IS_CREATOR_OWNER, false),
latestReply = null
)
}
companion object {
private const val EXTRA_POST_ID = "extra_post_id"
private const val EXTRA_COMMENT_ID = "extra_comment_id"
private const val EXTRA_MEMBER_ID = "extra_member_id"
private const val EXTRA_MEMBER_NICKNAME = "extra_member_nickname"
private const val EXTRA_MEMBER_PROFILE_URL = "extra_member_profile_url"
private const val EXTRA_CREATED_AT_UTC = "extra_created_at_utc"
private const val EXTRA_CREATED_AT_TEXT = "extra_created_at_text"
private const val EXTRA_COMMENT = "extra_comment"
private const val EXTRA_LIKE_COUNT = "extra_like_count"
private const val EXTRA_REPLY_COUNT = "extra_reply_count"
private const val EXTRA_IS_LIKED = "extra_is_liked"
private const val EXTRA_IS_MINE = "extra_is_mine"
private const val EXTRA_IS_CREATOR_OWNER = "extra_is_creator_owner"
fun newIntent(context: Context, postId: Long, parentComment: CreatorChannelCommunityCommentUiModel): Intent {
return Intent(context, CreatorChannelCommunityReplyActivity::class.java)
.putExtra(EXTRA_POST_ID, postId)
.putExtra(EXTRA_COMMENT_ID, parentComment.commentId)
.putExtra(EXTRA_MEMBER_ID, parentComment.memberId)
.putExtra(EXTRA_MEMBER_NICKNAME, parentComment.memberNickname)
.putExtra(EXTRA_MEMBER_PROFILE_URL, parentComment.memberProfileUrl)
.putExtra(EXTRA_CREATED_AT_UTC, parentComment.createdAtUtc)
.putExtra(EXTRA_CREATED_AT_TEXT, parentComment.createdAtText)
.putExtra(EXTRA_COMMENT, parentComment.comment)
.putExtra(EXTRA_LIKE_COUNT, parentComment.likeCount)
.putExtra(EXTRA_REPLY_COUNT, parentComment.replyCount)
.putExtra(EXTRA_IS_LIKED, parentComment.isLiked)
.putExtra(EXTRA_IS_MINE, parentComment.isMine)
.putExtra(EXTRA_IS_CREATOR_OWNER, parentComment.isCreatorOwner)
}
}
}

View File

@@ -0,0 +1,529 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
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.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityRepliesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelCommunityReplyViewModel(
private val repository: CreatorChannelRepository,
private val legacyRepository: CreatorCommunityRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _replyStateLiveData = MutableLiveData<CreatorChannelCommunityReplyUiState>()
val replyStateLiveData: LiveData<CreatorChannelCommunityReplyUiState>
get() = _replyStateLiveData
private val _replyWrittenEventLiveData = MutableLiveData<Boolean>()
val replyWrittenEventLiveData: LiveData<Boolean>
get() = _replyWrittenEventLiveData
private val _replyChangedEventLiveData = MutableLiveData<Boolean>()
val replyChangedEventLiveData: LiveData<Boolean>
get() = _replyChangedEventLiveData
private val _parentCommentChangedEventLiveData = MutableLiveData<Boolean>()
val parentCommentChangedEventLiveData: LiveData<Boolean>
get() = _parentCommentChangedEventLiveData
private val _parentCommentDeletedEventLiveData = MutableLiveData<Boolean>()
val parentCommentDeletedEventLiveData: LiveData<Boolean>
get() = _parentCommentDeletedEventLiveData
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
get() = _toastLiveData
private var postId: Long = 0L
private var parentComment: CreatorChannelCommunityCommentUiModel? = null
private var replyRequestGeneration = 0
private var isLoadingReplies = false
private var isSendingReply = false
private var isModifyingReply = false
fun loadReplies(postId: Long, parentComment: CreatorChannelCommunityCommentUiModel) {
val formattedParentComment = parentComment.copy(
createdAtText = relativeTimeTextFormatter.format(parentComment.createdAtUtc)
)
this.postId = postId
this.parentComment = formattedParentComment
replyRequestGeneration++
isLoadingReplies = false
_replyStateLiveData.value = CreatorChannelCommunityReplyUiState.Content(
parentComment = formattedParentComment,
replies = emptyList(),
replyPage = FIRST_PAGE,
hasNextReply = false
)
if (formattedParentComment.commentId > 0) {
loadReplyPage(page = FIRST_PAGE, append = false)
}
}
fun loadMoreReplies() {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
if (!content.hasNextReply || isLoadingReplies) return
loadReplyPage(page = content.replyPage + 1, append = true)
}
fun updateReplyInput(input: String) {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
_replyStateLiveData.value = content.copy(replyInput = input)
}
fun submitReply() {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
val comment = content.replyInput.trim()
val editingTarget = content.editingTarget
if (editingTarget != null) {
submitReplyEdit(content, editingTarget, comment)
return
}
val commentId = content.parentComment.commentId
if (postId <= 0 || commentId <= 0 || comment.isBlank() || isSendingReply) return
isSendingReply = true
_replyStateLiveData.value = content.copy(isSendingReply = true)
compositeDisposable.add(
legacyRepository.registerComment(
postId = postId,
comment = comment,
parentId = commentId,
isSecret = false,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response -> handleWriteResponse(response) },
{
isSendingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
showMutationFailureToast()
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
)
)
}
fun consumeReplyWrittenEvent() {
_replyWrittenEventLiveData.value = false
}
fun consumeReplyChangedEvent() {
_replyChangedEventLiveData.value = false
}
fun consumeParentCommentChangedEvent() {
_parentCommentChangedEventLiveData.value = false
}
fun consumeParentCommentDeletedEvent() {
_parentCommentDeletedEventLiveData.value = false
}
fun startReplyEdit(replyId: Long, comment: String) {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
if (replyId <= 0) return
_replyStateLiveData.value = content.copy(
replyInput = comment,
editingTarget = CreatorChannelCommunityReplyEditTarget.Reply(replyId, comment)
)
}
fun startParentCommentEdit(comment: String) {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
if (content.parentComment.commentId <= 0) return
_replyStateLiveData.value = content.copy(
replyInput = comment,
editingTarget = CreatorChannelCommunityReplyEditTarget.ParentComment(comment)
)
}
fun cancelReplyEdit() {
val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
_replyStateLiveData.value = content.copy(replyInput = "", editingTarget = null)
}
fun modifyReply(replyId: Long, comment: String) {
val trimmedComment = comment.trim()
if (replyId <= 0 || isModifyingReply) return
if (trimmedComment.isBlank()) {
showBlankCommentToast()
return
}
modifyComment(ModifyCommentRequest(commentId = replyId, comment = trimmedComment))
}
fun deleteReply(replyId: Long) {
if (replyId <= 0 || isModifyingReply) return
modifyComment(ModifyCommentRequest(commentId = replyId, isActive = false))
}
fun modifyParentComment(comment: String) {
val parentComment = parentComment ?: return
val trimmedComment = comment.trim()
if (parentComment.commentId <= 0 || isModifyingReply) return
if (trimmedComment.isBlank()) {
showBlankCommentToast()
return
}
modifyParentComment(parentComment, trimmedComment)
}
fun deleteParentComment() {
val parentComment = parentComment ?: return
if (parentComment.commentId <= 0 || isModifyingReply) return
deleteParentComment(parentComment.commentId)
}
private fun submitReplyEdit(
content: CreatorChannelCommunityReplyUiState.Content,
target: CreatorChannelCommunityReplyEditTarget,
comment: String
) {
if (isModifyingReply) return
if (comment.isBlank()) {
showBlankCommentToast()
return
}
if (comment == target.originalComment.trim()) {
showNoChangeToast()
return
}
when (target) {
is CreatorChannelCommunityReplyEditTarget.ParentComment -> modifyParentCommentFromInput(content, comment)
is CreatorChannelCommunityReplyEditTarget.Reply -> modifyReplyFromInput(content, target.replyId, comment)
}
}
private fun modifyReplyFromInput(
content: CreatorChannelCommunityReplyUiState.Content,
replyId: Long,
comment: String
) {
isModifyingReply = true
_replyStateLiveData.value = content.copy(isSendingReply = true)
compositeDisposable.add(
legacyRepository.modifyComment(ModifyCommentRequest(commentId = replyId, comment = comment), authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
if (response.success) {
_replyChangedEventLiveData.value = true
_replyStateLiveData.value = current.copy(
replyInput = "",
isSendingReply = false,
editingTarget = null
)
loadReplyPage(page = FIRST_PAGE, append = false)
} else {
showMutationFailureToast(response.message)
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
},
{
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
showMutationFailureToast()
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
)
)
}
private fun modifyParentCommentFromInput(
content: CreatorChannelCommunityReplyUiState.Content,
comment: String
) {
val parentComment = parentComment ?: return
isModifyingReply = true
_replyStateLiveData.value = content.copy(isSendingReply = true)
compositeDisposable.add(
legacyRepository.modifyComment(
ModifyCommentRequest(commentId = parentComment.commentId, comment = comment),
authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
if (response.success) {
val updatedParent = parentComment.copy(comment = comment)
this.parentComment = updatedParent
_replyStateLiveData.value = current.copy(
parentComment = updatedParent,
replyInput = "",
isSendingReply = false,
editingTarget = null
)
_parentCommentChangedEventLiveData.value = true
} else {
showMutationFailureToast(response.message)
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
},
{
isModifyingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
showMutationFailureToast()
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
)
)
}
private fun modifyParentComment(parentComment: CreatorChannelCommunityCommentUiModel, comment: String) {
isModifyingReply = true
compositeDisposable.add(
legacyRepository.modifyComment(
ModifyCommentRequest(commentId = parentComment.commentId, comment = comment),
authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingReply = false
if (response.success) {
val updatedParent = parentComment.copy(comment = comment)
this.parentComment = updatedParent
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
_replyStateLiveData.value = current.copy(parentComment = updatedParent)
_parentCommentChangedEventLiveData.value = true
} else {
showMutationFailureToast(response.message)
}
},
{
isModifyingReply = false
showMutationFailureToast()
}
)
)
}
private fun deleteParentComment(commentId: Long) {
isModifyingReply = true
compositeDisposable.add(
legacyRepository.modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false), authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingReply = false
if (response.success) {
_parentCommentDeletedEventLiveData.value = true
} else {
showMutationFailureToast(response.message)
}
},
{
isModifyingReply = false
showMutationFailureToast()
}
)
)
}
private fun loadReplyPage(page: Int, append: Boolean) {
val commentId = parentComment?.commentId ?: return
if (commentId <= 0 || isLoadingReplies && append) return
val generation = if (append) {
replyRequestGeneration
} else {
++replyRequestGeneration
}
isLoadingReplies = true
val beforeRequest = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
if (beforeRequest != null) {
_replyStateLiveData.value = beforeRequest.copy(isLoadingReplies = true)
}
compositeDisposable.add(
repository.getCommunityCommentReplies(commentId, page, DEFAULT_PAGE_SIZE, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response -> handleRepliesResponse(response, append, generation) },
{
if (generation != replyRequestGeneration) return@subscribe
isLoadingReplies = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content
?: return@subscribe
_replyStateLiveData.value = current.copy(isLoadingReplies = false)
}
)
)
}
private fun handleRepliesResponse(
response: ApiResponse<CreatorChannelCommunityRepliesResponse>,
append: Boolean,
generation: Int
) {
if (generation != replyRequestGeneration) return
isLoadingReplies = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
val data = response.data
if (!response.success || data == null) {
_replyStateLiveData.value = current.copy(isLoadingReplies = false)
return
}
val nextReplies = data.replies.map { it.toUiModel() }
_replyStateLiveData.value = current.copy(
replies = if (append) current.replies + nextReplies else nextReplies,
replyPage = data.page,
hasNextReply = data.hasNext,
isLoadingReplies = false
)
}
private fun handleWriteResponse(response: ApiResponse<Any>) {
isSendingReply = false
val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return
if (response.success) {
_replyWrittenEventLiveData.value = true
_replyChangedEventLiveData.value = true
_replyStateLiveData.value = current.copy(replyInput = "", isSendingReply = false)
loadReplyPage(page = FIRST_PAGE, append = false)
} else {
showMutationFailureToast(response.message)
_replyStateLiveData.value = current.copy(isSendingReply = false)
}
}
private fun modifyComment(request: ModifyCommentRequest) {
val commentId = parentComment?.commentId ?: return
if (commentId <= 0) return
isModifyingReply = true
compositeDisposable.add(
legacyRepository.modifyComment(request, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingReply = false
if (response.success) {
_replyChangedEventLiveData.value = true
loadReplyPage(page = FIRST_PAGE, append = false)
} else {
showMutationFailureToast(response.message)
}
},
{
isModifyingReply = false
showMutationFailureToast()
}
)
)
}
private fun showMutationFailureToast(message: String? = null) {
_toastLiveData.value = CreatorChannelEvent(
if (message.isNullOrBlank()) {
ToastMessage(resId = R.string.common_error_unknown)
} else {
ToastMessage(message = message)
}
)
}
private fun showBlankCommentToast() {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.screen_creator_community_write_content_hint)
)
}
private fun showNoChangeToast() {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.audio_content_comment_no_change)
)
}
private fun CreatorChannelCommunityReplyResponse.toUiModel() = CreatorChannelCommunityReplyUiModel(
replyId = commentId,
commentId = parentComment?.commentId ?: 0L,
memberId = writerId,
memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content,
likeCount = 0,
isLiked = false,
isMine = writerId == SharedPreferenceManager.userId,
isCreatorOwner = parentComment?.isCreatorOwner == true
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelCommunityReplyEditTarget {
val originalComment: String
data class ParentComment(
override val originalComment: String
) : CreatorChannelCommunityReplyEditTarget
data class Reply(
val replyId: Long,
override val originalComment: String
) : CreatorChannelCommunityReplyEditTarget
}
sealed interface CreatorChannelCommunityReplyUiState {
data class Content(
val parentComment: CreatorChannelCommunityCommentUiModel,
val replies: List<CreatorChannelCommunityReplyUiModel>,
val replyPage: Int,
val hasNextReply: Boolean,
val isLoadingReplies: Boolean = false,
val replyInput: String = "",
val isSendingReply: Boolean = false,
val editingTarget: CreatorChannelCommunityReplyEditTarget? = null
) : CreatorChannelCommunityReplyUiState
}

View File

@@ -0,0 +1,81 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityReplyBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityMorePopup
class CreatorChannelCommunityReplyAdapter(
private val onModifyClick: (CreatorChannelCommunityReplyUiModel) -> Unit,
private val onDeleteClick: (CreatorChannelCommunityReplyUiModel) -> Unit
) : RecyclerView.Adapter<CreatorChannelCommunityReplyAdapter.ViewHolder>() {
private var items: List<CreatorChannelCommunityReplyUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelCommunityReplyUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelCommunityReplyBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onModifyClick,
onDeleteClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelCommunityReplyBinding,
private val onModifyClick: (CreatorChannelCommunityReplyUiModel) -> Unit,
private val onDeleteClick: (CreatorChannelCommunityReplyUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelCommunityReplyUiModel) = with(binding) {
ivCreatorChannelCommunityReplyItemProfile.loadUrl(item.memberProfileUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
tvCreatorChannelCommunityReplyItemNickname.text = item.memberNickname
tvCreatorChannelCommunityReplyItemTime.text = item.createdAtText
tvCreatorChannelCommunityReplyItemBody.text = item.comment
ivCreatorChannelCommunityReplyItemMore.isVisible = item.canShowMore
ivCreatorChannelCommunityReplyItemMore.setOnClickListener { anchor ->
showOptionMenu(anchor, item, onModifyClick, onDeleteClick)
}
}
private fun showOptionMenu(
anchor: View,
item: CreatorChannelCommunityReplyUiModel,
onModifyClick: (CreatorChannelCommunityReplyUiModel) -> Unit,
onDeleteClick: (CreatorChannelCommunityReplyUiModel) -> Unit
) {
if (!item.canShowMore) return
CreatorChannelCommunityMorePopup(
anchor = anchor,
showEdit = item.isMine,
showDelete = true,
onEditClick = { onModifyClick(item) },
onDeleteClick = { onDeleteClick(item) }
).show(anchor)
}
private val CreatorChannelCommunityReplyUiModel.canShowMore: Boolean
get() = isMine || isCreatorOwner
}
}

View File

@@ -0,0 +1,111 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityCommentBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
class CreatorChannelCommunityCommentAdapter(
private val onReplyClick: (CreatorChannelCommunityCommentUiModel) -> Unit,
private val onModifyClick: (CreatorChannelCommunityCommentUiModel) -> Unit = {},
private val onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelCommunityCommentAdapter.ViewHolder>() {
private var items: List<CreatorChannelCommunityCommentUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelCommunityCommentUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelCommunityCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onReplyClick,
onModifyClick,
onDeleteClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelCommunityCommentBinding,
private val onReplyClick: (CreatorChannelCommunityCommentUiModel) -> Unit,
private val onModifyClick: (CreatorChannelCommunityCommentUiModel) -> Unit,
private val onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelCommunityCommentUiModel) {
bindComment(binding, item, onReplyClick)
binding.ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore
binding.ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor ->
showOptionMenu(anchor, item, onModifyClick, onDeleteClick)
}
}
}
companion object {
fun bindComment(
binding: ItemCreatorChannelCommunityCommentBinding,
item: CreatorChannelCommunityCommentUiModel,
onReplyClick: (CreatorChannelCommunityCommentUiModel) -> Unit,
onModifyClick: ((CreatorChannelCommunityCommentUiModel) -> Unit)? = null,
onDeleteClick: ((CreatorChannelCommunityCommentUiModel) -> Unit)? = null
) = with(binding) {
ivCreatorChannelCommunityCommentProfile.loadUrl(item.memberProfileUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
tvCreatorChannelCommunityCommentNickname.text = item.memberNickname
tvCreatorChannelCommunityCommentTime.text = item.createdAtText
tvCreatorChannelCommunityCommentBody.text = item.comment
ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore && onDeleteClick != null
ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor ->
showOptionMenu(
anchor,
item,
onModifyClick ?: {},
onDeleteClick ?: {}
)
}
layoutCreatorChannelCommunityCommentLatestReply.isVisible = item.latestReply != null
item.latestReply?.let { latestReply ->
tvCreatorChannelCommunityCommentLatestReplyNickname.text = latestReply.memberNickname
tvCreatorChannelCommunityCommentLatestReplyBody.text = latestReply.comment
}
root.setOnClickListener { onReplyClick(item) }
layoutCreatorChannelCommunityCommentLatestReply.setOnClickListener { onReplyClick(item) }
}
private fun showOptionMenu(
anchor: View,
item: CreatorChannelCommunityCommentUiModel,
onModifyClick: (CreatorChannelCommunityCommentUiModel) -> Unit,
onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit
) {
if (!item.canShowMore) return
CreatorChannelCommunityMorePopup(
anchor = anchor,
showEdit = item.isMine,
showDelete = true,
onEditClick = { onModifyClick(item) },
onDeleteClick = { onDeleteClick(item) }
).show(anchor)
}
private val CreatorChannelCommunityCommentUiModel.canShowMore: Boolean
get() = isMine || isCreatorOwner
}
}

View File

@@ -0,0 +1,50 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.isVisible
import kr.co.vividnext.sodalive.databinding.ViewCreatorChannelCommunityMorePopupBinding
class CreatorChannelCommunityMorePopup(
anchor: View,
showEdit: Boolean,
showDelete: Boolean,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit
) {
private val binding = ViewCreatorChannelCommunityMorePopupBinding.inflate(LayoutInflater.from(anchor.context))
private val popupWindow: PopupWindow = PopupWindow(anchor.context).apply {
contentView = binding.root
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
isOutsideTouchable = true
isFocusable = true
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
}
init {
binding.tvCreatorChannelCommunityMoreEdit.isVisible = showEdit
binding.tvCreatorChannelCommunityMoreDelete.isVisible = showDelete
binding.tvCreatorChannelCommunityMoreEdit.setOnClickListener {
dismiss()
onEditClick()
}
binding.tvCreatorChannelCommunityMoreDelete.setOnClickListener {
dismiss()
onDeleteClick()
}
}
fun show(anchor: View) {
popupWindow.showAsDropDown(anchor)
}
fun dismiss() {
popupWindow.dismiss()
}
}

View File

@@ -13,7 +13,9 @@ import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityImageMode
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
class CreatorChannelCommunityGridAdapter : RecyclerView.Adapter<CreatorChannelCommunityGridAdapter.ViewHolder>() {
class CreatorChannelCommunityGridAdapter(
private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelCommunityGridAdapter.ViewHolder>() {
private var items: List<CreatorChannelCommunityPostUiModel> = emptyList()
private var itemSizePx: Int = 0
@@ -31,7 +33,10 @@ class CreatorChannelCommunityGridAdapter : RecyclerView.Adapter<CreatorChannelCo
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ItemCreatorChannelCommunityGridBinding.inflate(LayoutInflater.from(parent.context), parent, false))
return ViewHolder(
ItemCreatorChannelCommunityGridBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onPostClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
@@ -41,10 +46,12 @@ class CreatorChannelCommunityGridAdapter : RecyclerView.Adapter<CreatorChannelCo
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelCommunityGridBinding
private val binding: ItemCreatorChannelCommunityGridBinding,
private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelCommunityPostUiModel, itemSizePx: Int) = with(binding) {
root.setOnClickListener { onPostClick(item) }
if (itemSizePx > 0) {
root.layoutParams = root.layoutParams.apply {
width = itemSizePx

View File

@@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
class CreatorChannelCommunityListAdapter(
private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val isPlayingContent: (Long) -> Boolean = { false }
@@ -32,6 +33,7 @@ class CreatorChannelCommunityListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelCommunityListBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onPostClick,
onPlayClick,
onOwnerMoreClick,
isPlayingContent
@@ -46,12 +48,14 @@ class CreatorChannelCommunityListAdapter(
class ViewHolder(
private val binding: ItemCreatorChannelCommunityListBinding,
private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val isPlayingContent: (Long) -> Boolean
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelCommunityPostUiModel) = with(binding) {
root.setOnClickListener { onPostClick(item) }
ivCreatorChannelCommunityListProfile.loadUrl(item.creatorProfileUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)

View File

@@ -2,9 +2,11 @@ package kr.co.vividnext.sodalive.v2.creator.channel.data
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityRepliesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse
@@ -26,7 +28,7 @@ interface CreatorChannelApi {
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("sort") sort: ContentSort,
@Query("sort") sort: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelLiveTabResponse>>
@@ -35,7 +37,7 @@ interface CreatorChannelApi {
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("sort") sort: ContentSort,
@Query("sort") sort: String,
@Query("themeId") themeId: Long?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelAudioTabResponse>>
@@ -45,7 +47,7 @@ interface CreatorChannelApi {
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("sort") sort: ContentSort,
@Query("sort") sort: String,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelSeriesTabResponse>>
@@ -57,6 +59,28 @@ interface CreatorChannelApi {
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelCommunityTabResponse>>
@GET("/api/v2/creator-channels/community-posts/{postId}")
fun getCommunityPostDetail(
@Path("postId") postId: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelCommunityPostDetailResponse>>
@GET("/api/v2/creator-channels/community-posts/{postId}/comments")
fun getCommunityPostComments(
@Path("postId") postId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelCommunityCommentsResponse>>
@GET("/api/v2/creator-channels/community-comments/{commentId}/replies")
fun getCommunityCommentReplies(
@Path("commentId") commentId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelCommunityRepliesResponse>>
@GET("/api/v2/creator-channels/{creatorId}/fan-talks")
fun getFanTalks(
@Path("creatorId") creatorId: Long,

View File

@@ -33,7 +33,7 @@ class CreatorChannelRepository(
creatorId = creatorId,
page = page,
size = size,
sort = sort,
sort = sort.queryValue,
authHeader = token
)
@@ -48,7 +48,7 @@ class CreatorChannelRepository(
creatorId = creatorId,
page = page,
size = size,
sort = sort,
sort = sort.queryValue,
themeId = themeId,
authHeader = token
)
@@ -63,7 +63,7 @@ class CreatorChannelRepository(
creatorId = creatorId,
page = page,
size = size,
sort = sort,
sort = sort.queryValue,
authHeader = token
)
@@ -79,6 +79,35 @@ class CreatorChannelRepository(
authHeader = token
)
fun getCommunityPostDetail(postId: Long, token: String) = api.getCommunityPostDetail(
postId = postId,
authHeader = token
)
fun getCommunityPostComments(
postId: Long,
page: Int = 0,
size: Int = 20,
token: String
) = api.getCommunityPostComments(
postId = postId,
page = page,
size = size,
authHeader = token
)
fun getCommunityCommentReplies(
commentId: Long,
page: Int = 0,
size: Int = 20,
token: String
) = api.getCommunityCommentReplies(
commentId = commentId,
page = page,
size = size,
authHeader = token
)
fun getFanTalks(
creatorId: Long,
page: Int,
@@ -160,4 +189,28 @@ class CreatorChannelRepository(
request = PutModifyCheersRequest(cheersId = fanTalkId, isActive = false),
token = token
)
fun writeFanTalk(creatorId: Long, content: String, token: String) = explorerRepository.writeCheers(
parentCheersId = null,
creatorId = creatorId,
content = content,
token = token
)
fun writeFanTalkReply(fanTalkId: Long, creatorId: Long, content: String, token: String) = explorerRepository.writeCheers(
parentCheersId = fanTalkId,
creatorId = creatorId,
content = content,
token = token
)
fun modifyFanTalkReply(replyFanTalkId: Long, content: String, token: String) = explorerRepository.modifyCheers(
request = PutModifyCheersRequest(cheersId = replyFanTalkId, content = content),
token = token
)
fun deleteFanTalkReply(replyFanTalkId: Long, token: String) = explorerRepository.modifyCheers(
request = PutModifyCheersRequest(cheersId = replyFanTalkId, isActive = false),
token = token
)
}

View File

@@ -37,7 +37,7 @@ private fun CreatorChannelDonationResponse.toDonationUiModel(
@ColorRes
internal fun calculateDonationHeaderColorRes(can: Int): Int = when {
can >= 500 -> R.color.red_400
can >= 501 -> R.color.red_400
can >= 101 -> R.color.creator_channel_donation_cyan
can >= 51 -> R.color.green_400
else -> R.color.gray_200

View File

@@ -8,15 +8,12 @@ import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationEmptyBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.ui.bindCreatorChannelDonationCard
class CreatorChannelDonationAdapter(
private val onRankingAllClick: () -> Unit = { },
@@ -119,20 +116,20 @@ class CreatorChannelDonationAdapter(
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelDonationUiModel) = with(binding) {
layoutCreatorChannelDonationHeader.setBackgroundColor(root.context.getColor(item.headerColorResId))
ivCreatorChannelDonationProfile.loadUrl(item.profileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
tvCreatorChannelDonationNickname.text = item.nickname
tvCreatorChannelDonationCreatedAt.text = item.createdAtText
tvCreatorChannelDonationCan.text = root.context.getString(
R.string.creator_channel_donation_can_format,
item.can.moneyFormat()
bindCreatorChannelDonationCard(
headerView = layoutCreatorChannelDonationHeader,
profileView = ivCreatorChannelDonationProfile,
nicknameView = tvCreatorChannelDonationNickname,
createdAtView = tvCreatorChannelDonationCreatedAt,
canView = tvCreatorChannelDonationCan,
messageView = tvCreatorChannelDonationMessage,
headerColorResId = item.headerColorResId,
profileImageUrl = item.profileImageUrl,
nickname = item.nickname,
createdAtText = item.createdAtText,
can = item.can,
message = item.message
)
tvCreatorChannelDonationMessage.text = item.message
}
}

View File

@@ -23,7 +23,8 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
private val viewModel: CreatorChannelFanTalkViewModel by viewModel()
private val fanTalkAdapter = CreatorChannelFanTalkAdapter(
onOwnerMoreClick = ::showOwnerMorePopup,
onReportClick = ::showReportDialog
onReportClick = ::showReportDialog,
onItemClick = { host.onCreatorChannelFanTalkItemClicked(it) }
)
private var morePopup: CreatorChannelFanTalkMorePopup? = null
private var lastContentLayoutKey: CreatorChannelFanTalkContentLayoutKey? = null
@@ -43,6 +44,7 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
morePopup?.dismiss()
morePopup = null
lastContentLayoutKey = null
host.onCreatorChannelFanTalkFloatingButtonVisibilityChanged(false)
binding.rvCreatorChannelFantalk.adapter = null
super.onDestroyView()
}
@@ -77,8 +79,8 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
btnCreatorChannelFantalkRetry.setOnClickListener {
viewModel.retryFanTalks()
}
btnCreatorChannelFantalkWrite.setOnClickListener { }
layoutCreatorChannelFantalkEmptyWriteButton.setOnClickListener { }
btnCreatorChannelFantalkWrite.setOnClickListener { host.onCreatorChannelFanTalkWriteClicked() }
layoutCreatorChannelFantalkEmptyWriteButton.setOnClickListener { host.onCreatorChannelFanTalkWriteClicked() }
}
private fun observeViewModel() {
@@ -100,6 +102,7 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
tvCreatorChannelFantalkErrorMessage.isVisible = false
btnCreatorChannelFantalkRetry.isVisible = false
btnCreatorChannelFantalkWrite.isVisible = false
host.onCreatorChannelFanTalkFloatingButtonVisibilityChanged(false)
}
private fun bindEmpty() = with(binding) {
@@ -110,6 +113,7 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
tvCreatorChannelFantalkErrorMessage.isVisible = false
btnCreatorChannelFantalkRetry.isVisible = false
btnCreatorChannelFantalkWrite.isVisible = false
host.onCreatorChannelFanTalkFloatingButtonVisibilityChanged(false)
host.onCreatorChannelFanTalkContentChanged()
}
@@ -122,6 +126,7 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
tvCreatorChannelFantalkErrorMessage.text = state.message ?: getString(R.string.creator_channel_fantalk_error_message)
btnCreatorChannelFantalkRetry.isVisible = true
btnCreatorChannelFantalkWrite.isVisible = false
host.onCreatorChannelFanTalkFloatingButtonVisibilityChanged(false)
host.onCreatorChannelFanTalkContentChanged()
}
@@ -133,7 +138,8 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
layoutCreatorChannelFantalkEmpty.isVisible = false
tvCreatorChannelFantalkErrorMessage.isVisible = false
btnCreatorChannelFantalkRetry.isVisible = false
btnCreatorChannelFantalkWrite.isVisible = true
btnCreatorChannelFantalkWrite.isVisible = false
host.onCreatorChannelFanTalkFloatingButtonVisibilityChanged(true)
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
@@ -178,7 +184,10 @@ class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalk
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelFanTalkContentChanged()
fun onCreatorChannelFanTalkWriteClicked()
fun onCreatorChannelFanTalkFloatingButtonVisibilityChanged(isVisible: Boolean)
fun onCreatorChannelFanTalkDeleteClicked(fanTalkId: Long)
fun onCreatorChannelFanTalkItemClicked(item: CreatorChannelFanTalkUiModel) = Unit
}
companion object {

View File

@@ -0,0 +1,238 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.Toast
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelFantalkDetailBinding
import kr.co.vividnext.sodalive.databinding.ViewCreatorChannelFantalkMorePopupBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelFanTalkDetailActivity : BaseActivity<ActivityCreatorChannelFantalkDetailBinding>(
ActivityCreatorChannelFantalkDetailBinding::inflate
) {
private val viewModel: CreatorChannelFanTalkDetailViewModel by viewModel()
private var creatorId: Long = 0L
private var replyMorePopup: PopupWindow? = null
private var renderedEditingTarget: CreatorChannelFanTalkDetailEditTarget? = null
override fun setupView() {
creatorId = intent.getLongExtra(EXTRA_CREATOR_ID, 0L)
val payload = payloadFromIntent() ?: run {
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
finish()
return
}
if (creatorId <= 0L || payload.fanTalkId <= 0L) {
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
finish()
return
}
binding.btnCreatorChannelFantalkDetailBack.setOnClickListener { finish() }
binding.etCreatorChannelFantalkDetailReply.movementMethod = ScrollingMovementMethod()
binding.etCreatorChannelFantalkDetailReply.doAfterTextChanged { editable ->
viewModel.updateReplyInput(editable?.toString().orEmpty())
}
binding.btnCreatorChannelFantalkDetailSend.setOnClickListener { viewModel.submitReply() }
binding.btnCreatorChannelFantalkDetailEditCancel.setOnClickListener { viewModel.cancelReplyEdit() }
viewModel.detailStateLiveData.observe(this) { state -> render(state) }
viewModel.replacementModalEventLiveData.observe(this) { event ->
event.consume()?.let(::showReplacementDialog)
}
viewModel.replyChangedEventLiveData.observe(this) { changed ->
if (changed == true) {
setResult(Activity.RESULT_OK)
hideReplyKeyboard()
viewModel.consumeReplyChangedEvent()
}
}
viewModel.toastLiveData.observe(this) { event ->
event.consume()?.let { toast ->
val message = toast.message ?: toast.resId?.let(::getString)
message?.let(::showToast)
}
}
viewModel.load(creatorId, payload)
}
override fun onDestroy() {
replyMorePopup?.dismiss()
replyMorePopup = null
super.onDestroy()
}
private fun render(state: CreatorChannelFanTalkDetailUiState) {
when (state) {
is CreatorChannelFanTalkDetailUiState.Content -> renderContent(state)
}
}
private fun renderContent(content: CreatorChannelFanTalkDetailUiState.Content) = with(binding) {
bindParentFanTalk(content.parentFanTalk)
bindReply(content.reply)
if (etCreatorChannelFantalkDetailReply.text.toString() != content.replyInput) {
etCreatorChannelFantalkDetailReply.setText(content.replyInput)
etCreatorChannelFantalkDetailReply.setSelection(content.replyInput.length)
}
btnCreatorChannelFantalkDetailEditCancel.isVisible = content.editingTarget != null
if (content.editingTarget != null && content.editingTarget != renderedEditingTarget) {
showReplyKeyboard()
}
renderedEditingTarget = content.editingTarget
updateSendButton(content.isSendEnabled)
}
private fun bindParentFanTalk(item: CreatorChannelFanTalkUiModel) = with(binding) {
ivCreatorChannelFantalkDetailProfile.loadProfile(item.writerProfileImageUrl)
tvCreatorChannelFantalkDetailNickname.text = item.writerNickname
tvCreatorChannelFantalkDetailTime.text = item.createdAtText
tvCreatorChannelFantalkDetailContent.text = item.content
ivCreatorChannelFantalkDetailMore.isVisible = false
ivCreatorChannelFantalkDetailMore.setOnClickListener(null)
}
private fun bindReply(reply: CreatorChannelFanTalkReplyUiModel?) = with(binding) {
viewCreatorChannelFantalkDetailReplyConnector.isVisible = reply != null
layoutCreatorChannelFantalkDetailReply.isVisible = reply != null
if (reply == null) {
replyMorePopup?.dismiss()
return@with
}
ivCreatorChannelFantalkDetailReplyProfile.loadProfile(reply.writerProfileImageUrl)
tvCreatorChannelFantalkDetailReplyNickname.text = reply.writerNickname
tvCreatorChannelFantalkDetailReplyTime.text = reply.createdAtText
tvCreatorChannelFantalkDetailReplyContent.text = reply.content
ivCreatorChannelFantalkDetailReplyMore.isVisible = reply.fanTalkId > 0L
ivCreatorChannelFantalkDetailReplyMore.setOnClickListener(
if (reply.fanTalkId > 0L) {
View.OnClickListener { anchor -> showReplyMorePopup(anchor) }
} else {
null
}
)
}
private fun showReplyMorePopup(anchor: View) {
replyMorePopup?.dismiss()
val popupBinding = ViewCreatorChannelFantalkMorePopupBinding.inflate(LayoutInflater.from(anchor.context))
replyMorePopup = PopupWindow(anchor.context).apply {
contentView = popupBinding.root
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
isOutsideTouchable = true
isFocusable = true
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
}
popupBinding.tvCreatorChannelFantalkMoreEdit.isVisible = true
popupBinding.tvCreatorChannelFantalkMoreDelete.isVisible = true
popupBinding.tvCreatorChannelFantalkMoreEdit.setOnClickListener {
replyMorePopup?.dismiss()
viewModel.startReplyEdit()
}
popupBinding.tvCreatorChannelFantalkMoreDelete.setOnClickListener {
replyMorePopup?.dismiss()
showDeleteReplyDialog()
}
replyMorePopup?.showAsDropDown(anchor)
}
private fun showReplacementDialog(modal: CreatorChannelFanTalkReplacementModalUiModel) {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(modal.titleResId),
desc = getString(modal.descriptionResId),
confirmButtonTitle = getString(modal.confirmResId),
confirmButtonClick = { viewModel.confirmReplacement() },
cancelButtonTitle = getString(modal.cancelResId),
cancelButtonClick = { viewModel.cancelReplacement() }
).show(screenWidth)
}
private fun showDeleteReplyDialog() {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.confirm_delete_title),
desc = getString(R.string.confirm_delete_message),
confirmButtonTitle = getString(R.string.confirm),
confirmButtonClick = { viewModel.deleteReply() },
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
}
private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelFantalkDetailSend) {
isEnabled = enabled
setBackgroundResource(
if (enabled) {
R.drawable.bg_creator_channel_community_send_enabled
} else {
R.drawable.bg_creator_channel_community_send_disabled
}
)
setImageResource(if (enabled) R.drawable.ic_new_arrow_up_white else R.drawable.ic_new_arrow_up_gray)
}
private fun showReplyKeyboard() {
val editText = binding.etCreatorChannelFantalkDetailReply
editText.requestFocus()
editText.post {
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideReplyKeyboard() {
val editText = binding.etCreatorChannelFantalkDetailReply
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(editText.windowToken, 0)
editText.clearFocus()
}
private fun payloadFromIntent(): CreatorChannelFanTalkDetailPayload? {
return IntentCompat.getParcelableExtra(intent, EXTRA_PAYLOAD, CreatorChannelFanTalkDetailPayload::class.java)
}
private fun ImageView.loadProfile(url: String) {
loadUrl(url) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
companion object {
private const val EXTRA_CREATOR_ID = "extra_creator_id"
private const val EXTRA_PAYLOAD = "extra_payload"
fun newIntent(context: Context, creatorId: Long, payload: CreatorChannelFanTalkDetailPayload): Intent {
return Intent(context, CreatorChannelFanTalkDetailActivity::class.java)
.putExtra(EXTRA_CREATOR_ID, creatorId)
.putExtra(EXTRA_PAYLOAD, payload)
}
}
}

View File

@@ -0,0 +1,101 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
import android.os.Parcelable
import androidx.annotation.StringRes
import kotlinx.parcelize.Parcelize
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
@Parcelize
data class CreatorChannelFanTalkDetailPayload(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtText: String,
val reply: CreatorChannelFanTalkDetailReplyPayload?,
val showEdit: Boolean,
val showDelete: Boolean
) : Parcelable {
fun toUiModel(): CreatorChannelFanTalkUiModel = CreatorChannelFanTalkUiModel(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = createdAtText,
reply = reply?.toUiModel(),
rightAction = CreatorChannelFanTalkRightAction.OwnerMore(showEdit = showEdit, showDelete = showDelete)
)
}
@Parcelize
data class CreatorChannelFanTalkDetailReplyPayload(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtText: String
) : Parcelable {
fun toUiModel(): CreatorChannelFanTalkReplyUiModel = CreatorChannelFanTalkReplyUiModel(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = createdAtText
)
}
fun CreatorChannelFanTalkUiModel.toFanTalkDetailPayload(): CreatorChannelFanTalkDetailPayload {
val ownerMore = rightAction as? CreatorChannelFanTalkRightAction.OwnerMore
return CreatorChannelFanTalkDetailPayload(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = createdAtText,
reply = reply?.toFanTalkDetailReplyPayload(),
showEdit = ownerMore?.showEdit == true,
showDelete = ownerMore?.showDelete == true
)
}
fun CreatorChannelFanTalkReplyUiModel.toFanTalkDetailReplyPayload(): CreatorChannelFanTalkDetailReplyPayload {
return CreatorChannelFanTalkDetailReplyPayload(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = createdAtText
)
}
data class CreatorChannelFanTalkReplacementModalUiModel(
@StringRes val titleResId: Int = R.string.screen_user_profile_reply_edit,
@StringRes val descriptionResId: Int = R.string.creator_channel_fantalk_reply_replace_description,
@StringRes val cancelResId: Int = R.string.cancel,
@StringRes val confirmResId: Int = R.string.confirm
)
sealed interface CreatorChannelFanTalkDetailEditTarget {
data class Reply(val replyFanTalkId: Long) : CreatorChannelFanTalkDetailEditTarget
}
sealed interface CreatorChannelFanTalkDetailUiState {
data class Content(
val parentFanTalk: CreatorChannelFanTalkUiModel,
val reply: CreatorChannelFanTalkReplyUiModel?,
val replyInput: String = "",
val isSubmitting: Boolean = false,
val editingTarget: CreatorChannelFanTalkDetailEditTarget? = null
) : CreatorChannelFanTalkDetailUiState {
val isSendEnabled: Boolean = replyInput.trim().isNotEmpty() && !isSubmitting
}
}

View File

@@ -0,0 +1,244 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkReplyUiModel
class CreatorChannelFanTalkDetailViewModel(
private val repository: CreatorChannelRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _detailStateLiveData = MutableLiveData<CreatorChannelFanTalkDetailUiState>()
val detailStateLiveData: LiveData<CreatorChannelFanTalkDetailUiState>
get() = _detailStateLiveData
private val _replacementModalEventLiveData =
MutableLiveData<CreatorChannelEvent<CreatorChannelFanTalkReplacementModalUiModel>>()
val replacementModalEventLiveData: LiveData<CreatorChannelEvent<CreatorChannelFanTalkReplacementModalUiModel>>
get() = _replacementModalEventLiveData
private val _replyChangedEventLiveData = MutableLiveData(false)
val replyChangedEventLiveData: LiveData<Boolean>
get() = _replyChangedEventLiveData
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
get() = _toastLiveData
private var creatorId: Long = 0L
private var isSubmitting: Boolean = false
private var pendingReplacementContent: String? = null
fun load(creatorId: Long, payload: CreatorChannelFanTalkDetailPayload) {
this.creatorId = creatorId
val parentFanTalk = payload.toUiModel()
_detailStateLiveData.value = CreatorChannelFanTalkDetailUiState.Content(
parentFanTalk = parentFanTalk,
reply = parentFanTalk.reply
)
}
fun updateReplyInput(input: String) {
val content = currentContent() ?: return
_detailStateLiveData.value = content.copy(replyInput = input)
}
fun submitReply() {
val content = currentContent() ?: return
val trimmedContent = content.replyInput.trim()
if (trimmedContent.isEmpty() || isSubmitting) return
val editingTarget = content.editingTarget
if (editingTarget is CreatorChannelFanTalkDetailEditTarget.Reply) {
modifyReply(content, editingTarget.replyFanTalkId, trimmedContent)
return
}
val reply = content.reply
if (reply == null) {
writeReply(content, trimmedContent)
} else {
if (reply.fanTalkId <= 0L) {
showFailureToast()
return
}
pendingReplacementContent = trimmedContent
_replacementModalEventLiveData.value = CreatorChannelEvent(CreatorChannelFanTalkReplacementModalUiModel())
}
}
fun confirmReplacement() {
val content = currentContent() ?: return
val reply = content.reply ?: return
val replacementContent = pendingReplacementContent ?: content.replyInput.trim()
if (replacementContent.isBlank() || isSubmitting) return
if (reply.fanTalkId <= 0L) {
pendingReplacementContent = null
showFailureToast()
return
}
modifyReply(content, reply.fanTalkId, replacementContent)
pendingReplacementContent = null
}
fun cancelReplacement() {
pendingReplacementContent = null
}
fun consumeReplyChangedEvent() {
_replyChangedEventLiveData.value = false
}
fun cancelReplyEdit() {
val content = currentContent() ?: return
_detailStateLiveData.value = content.copy(replyInput = "", editingTarget = null)
}
fun startReplyEdit() {
val content = currentContent() ?: return
val reply = content.reply ?: return
_detailStateLiveData.value = content.copy(
replyInput = reply.content,
editingTarget = CreatorChannelFanTalkDetailEditTarget.Reply(reply.fanTalkId)
)
}
fun deleteReply() {
val content = currentContent() ?: return
val reply = content.reply ?: return
if (isSubmitting) return
submitMutation(content, repository.deleteFanTalkReply(reply.fanTalkId, authToken())) { current ->
current.copy(reply = null, replyInput = "", editingTarget = null)
}
}
private fun writeReply(content: CreatorChannelFanTalkDetailUiState.Content, replyContent: String) {
if (creatorId <= 0L || content.parentFanTalk.fanTalkId <= 0L) return
isSubmitting = true
_detailStateLiveData.value = content.copy(isSubmitting = true)
compositeDisposable.add(
repository.writeFanTalkReply(
fanTalkId = content.parentFanTalk.fanTalkId,
creatorId = creatorId,
content = replyContent,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response -> handleWriteReplyResponse(response) },
{ error -> handleMutationError(error) }
)
)
}
private fun modifyReply(
content: CreatorChannelFanTalkDetailUiState.Content,
replyFanTalkId: Long,
replyContent: String
) {
if (replyFanTalkId <= 0L) return
submitMutation(
content = content,
request = repository.modifyFanTalkReply(replyFanTalkId, replyContent, authToken())
) { current ->
current.copy(
reply = current.reply?.copy(content = replyContent),
replyInput = "",
editingTarget = null
)
}
}
private fun submitMutation(
content: CreatorChannelFanTalkDetailUiState.Content,
request: Single<ApiResponse<CreatorChannelFanTalkReplyResponse>>,
onSuccess: (CreatorChannelFanTalkDetailUiState.Content) -> CreatorChannelFanTalkDetailUiState.Content
) {
isSubmitting = true
_detailStateLiveData.value = content.copy(isSubmitting = true)
compositeDisposable.add(
request
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response -> handleMutationResponse(response, onSuccess) },
{ error -> handleMutationError(error) }
)
)
}
private fun handleWriteReplyResponse(response: ApiResponse<CreatorChannelFanTalkReplyResponse>) {
isSubmitting = false
val current = currentContent() ?: return
if (response.success) {
_detailStateLiveData.value = current.copy(
reply = response.data?.toFanTalkReplyUiModel(relativeTimeTextFormatter) ?: current.reply,
replyInput = "",
isSubmitting = false,
editingTarget = null
)
_replyChangedEventLiveData.value = true
} else {
showFailureToast(response.message)
_detailStateLiveData.value = current.copy(isSubmitting = false)
}
}
private fun handleMutationError(error: Throwable) {
isSubmitting = false
error.message?.let { Logger.e(it) }
val current = currentContent() ?: return
showFailureToast()
_detailStateLiveData.value = current.copy(isSubmitting = false)
}
private fun handleMutationResponse(
response: ApiResponse<CreatorChannelFanTalkReplyResponse>,
onSuccess: (CreatorChannelFanTalkDetailUiState.Content) -> CreatorChannelFanTalkDetailUiState.Content
) {
isSubmitting = false
val current = currentContent() ?: return
if (response.success) {
_detailStateLiveData.value = onSuccess(current).copy(isSubmitting = false)
_replyChangedEventLiveData.value = true
} else {
showFailureToast(response.message)
_detailStateLiveData.value = current.copy(isSubmitting = false)
}
}
private fun showFailureToast(message: String? = null) {
_toastLiveData.value = CreatorChannelEvent(
if (message.isNullOrBlank()) {
ToastMessage(resId = R.string.common_error_unknown)
} else {
ToastMessage(message = message)
}
)
}
private fun currentContent(): CreatorChannelFanTalkDetailUiState.Content? {
return _detailStateLiveData.value as? CreatorChannelFanTalkDetailUiState.Content
}
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
}

View File

@@ -23,11 +23,22 @@ private fun CreatorChannelFanTalkResponse.toFanTalkUiModel(
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
reply = creatorReplies.firstOrNull()?.toReplyUiModel(relativeTimeTextFormatter),
reply = creatorReplies.orEmpty().firstOrNull()?.toFanTalkReplyUiModel(relativeTimeTextFormatter),
rightAction = toRightAction(isOwner = isOwner, currentUserId = currentUserId)
)
private fun CreatorChannelFanTalkReplyResponse.toReplyUiModel(
fun CreatorChannelFanTalkReplyResponse.toFanTalkReplyUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) = CreatorChannelFanTalkReplyUiModel(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc)
)
fun CreatorChannelFanTalkResponse.toFanTalkReplyUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) = CreatorChannelFanTalkReplyUiModel(
fanTalkId = fanTalkId,

View File

@@ -15,7 +15,8 @@ import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelF
class CreatorChannelFanTalkAdapter(
private val onOwnerMoreClick: (View, CreatorChannelFanTalkUiModel) -> Unit = { _, _ -> },
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit = { }
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit = { },
private val onItemClick: (CreatorChannelFanTalkUiModel) -> Unit = { }
) : RecyclerView.Adapter<CreatorChannelFanTalkAdapter.ViewHolder>() {
private var items: List<CreatorChannelFanTalkUiModel> = emptyList()
@@ -29,7 +30,8 @@ class CreatorChannelFanTalkAdapter(
return ViewHolder(
ItemCreatorChannelFantalkBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onOwnerMoreClick,
onReportClick
onReportClick,
onItemClick
)
}
@@ -42,7 +44,8 @@ class CreatorChannelFanTalkAdapter(
class ViewHolder(
private val binding: ItemCreatorChannelFantalkBinding,
private val onOwnerMoreClick: (View, CreatorChannelFanTalkUiModel) -> Unit,
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit,
private val onItemClick: (CreatorChannelFanTalkUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelFanTalkUiModel) = with(binding) {
@@ -50,6 +53,8 @@ class CreatorChannelFanTalkAdapter(
tvCreatorChannelFantalkNickname.text = item.writerNickname
tvCreatorChannelFantalkTime.text = item.createdAtText
tvCreatorChannelFantalkContent.text = item.content
root.setOnClickListener { onItemClick(item) }
tvCreatorChannelFantalkContent.setOnClickListener { onItemClick(item) }
bindRightAction(item)
bindReply(item.reply)
}

View File

@@ -0,0 +1,144 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write
import android.content.Context
import android.content.Intent
import android.text.InputFilter
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.addCallback
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelFantalkWriteBinding
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelFanTalkWriteActivity : BaseActivity<ActivityCreatorChannelFantalkWriteBinding>(
ActivityCreatorChannelFantalkWriteBinding::inflate
) {
private val viewModel: CreatorChannelFanTalkWriteViewModel by viewModel()
private var creatorId: Long = 0L
override fun setupView() {
creatorId = intent.getLongExtra(EXTRA_CREATOR_ID, 0L)
if (creatorId <= 0L) {
finish()
return
}
setupContentInput()
setupClickListeners()
setupBackHandler()
observeViewModel()
focusContentInput()
}
private fun setupContentInput() = with(binding.etCreatorChannelFantalkWriteContent) {
filters = arrayOf(InputFilter.LengthFilter(CreatorChannelFanTalkWriteViewModel.MAX_CONTENT_LENGTH))
}
private fun setupClickListeners() = with(binding) {
tvCreatorChannelFantalkWriteCancel.setOnClickListener { handleCancel() }
tvCreatorChannelFantalkWriteSend.setOnClickListener { viewModel.submit(creatorId) }
etCreatorChannelFantalkWriteContent.addTextChangedListener { text ->
viewModel.onContentChanged(text?.toString().orEmpty())
}
}
private fun setupBackHandler() {
onBackPressedDispatcher.addCallback(this) {
handleCancel()
}
}
private fun observeViewModel() {
viewModel.isSendEnabledLiveData.observe(this) { isEnabled ->
bindSendEnabled(isEnabled && viewModel.isLoadingLiveData.value != true)
}
viewModel.contentLengthLiveData.observe(this) { length ->
binding.tvCreatorChannelFantalkWriteCount.text = length.toString()
}
viewModel.isLoadingLiveData.observe(this) { isLoading ->
bindLoading(isLoading)
}
viewModel.finishEventLiveData.observe(this) { shouldFinish ->
if (shouldFinish == true) {
setResult(RESULT_OK)
finish()
}
}
viewModel.toastEventLiveData.observe(this) { message ->
message?.let { Toast.makeText(this, it, Toast.LENGTH_SHORT).show() }
}
}
private fun bindSendEnabled(isEnabled: Boolean) = with(binding.tvCreatorChannelFantalkWriteSend) {
this.isEnabled = isEnabled
setTextColor(
ContextCompat.getColor(
context,
if (isEnabled) R.color.white else R.color.gray_500
)
)
setBackgroundResource(
if (isEnabled) {
R.drawable.bg_creator_channel_fantalk_write_send_enabled
} else {
R.drawable.bg_creator_channel_fantalk_write_send_disabled
}
)
}
private fun bindLoading(isLoading: Boolean) = with(binding) {
etCreatorChannelFantalkWriteContent.isEnabled = !isLoading
tvCreatorChannelFantalkWriteSend.text = getString(
if (isLoading) R.string.creator_channel_fantalk_write_sending else R.string.creator_channel_fantalk_write_send
)
bindSendEnabled(!isLoading && viewModel.isSendEnabledLiveData.value == true)
}
private fun focusContentInput() {
binding.etCreatorChannelFantalkWriteContent.post {
binding.etCreatorChannelFantalkWriteContent.requestFocus()
WindowCompat.getInsetsController(window, binding.etCreatorChannelFantalkWriteContent)
.show(WindowInsetsCompat.Type.ime())
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(binding.etCreatorChannelFantalkWriteContent, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun handleCancel() {
if (binding.etCreatorChannelFantalkWriteContent.text?.toString().orEmpty().trim().isEmpty()) {
finish()
} else {
showCancelConfirmDialog()
}
}
private fun showCancelConfirmDialog() {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.creator_channel_fantalk_write_exit_title),
desc = getString(R.string.creator_channel_fantalk_write_exit_desc),
confirmButtonTitle = getString(R.string.creator_channel_fantalk_write_exit_confirm),
confirmButtonClick = { finish() },
cancelButtonTitle = getString(R.string.creator_channel_fantalk_write_exit_cancel),
cancelButtonClick = {}
).show(screenWidth)
}
companion object {
const val EXTRA_CREATOR_ID: String = "extra_creator_id"
fun newIntent(context: Context, creatorId: Long): Intent {
return Intent(context, CreatorChannelFanTalkWriteActivity::class.java).apply {
putExtra(EXTRA_CREATOR_ID, creatorId)
}
}
}
}

View File

@@ -0,0 +1,89 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write
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.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelFanTalkWriteViewModel(
private val repository: CreatorChannelRepository
) : BaseViewModel() {
private val _contentLiveData = MutableLiveData("")
val contentLiveData: LiveData<String>
get() = _contentLiveData
private val _isSendEnabledLiveData = MutableLiveData(false)
val isSendEnabledLiveData: LiveData<Boolean>
get() = _isSendEnabledLiveData
private val _contentLengthLiveData = MutableLiveData(0)
val contentLengthLiveData: LiveData<Int>
get() = _contentLengthLiveData
private val _isLoadingLiveData = MutableLiveData(false)
val isLoadingLiveData: LiveData<Boolean>
get() = _isLoadingLiveData
private val _finishEventLiveData = MutableLiveData(false)
val finishEventLiveData: LiveData<Boolean>
get() = _finishEventLiveData
private val _toastEventLiveData = MutableLiveData<String?>()
val toastEventLiveData: LiveData<String?>
get() = _toastEventLiveData
fun onContentChanged(content: String) {
val limitedContent = content.take(MAX_CONTENT_LENGTH)
_contentLiveData.value = limitedContent
_contentLengthLiveData.value = limitedContent.length
_isSendEnabledLiveData.value = limitedContent.trim().isNotEmpty()
}
fun submit(creatorId: Long) {
val trimmedContent = _contentLiveData.value.orEmpty().trim()
if (creatorId <= 0L) return
if (_isLoadingLiveData.value == true) return
if (trimmedContent.isEmpty()) return
_isLoadingLiveData.value = true
compositeDisposable.add(
repository.writeFanTalk(
creatorId = creatorId,
content = trimmedContent,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
_isLoadingLiveData.value = false
if (response.success) {
_finishEventLiveData.value = true
} else {
_toastEventLiveData.value = response.message ?: unknownErrorMessage()
}
},
{ error ->
_isLoadingLiveData.value = false
error.message?.let { Logger.e(it) }
_toastEventLiveData.value = unknownErrorMessage()
}
)
)
}
private fun unknownErrorMessage(): String {
return SodaLiveApplicationHolder.get().getString(R.string.common_error_unknown)
}
companion object {
const val MAX_CONTENT_LENGTH: Int = 500
}
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorRes
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
internal fun bindCreatorChannelDonationCard(
headerView: View,
profileView: ImageView,
nicknameView: TextView,
createdAtView: TextView,
canView: TextView,
messageView: TextView,
@ColorRes headerColorResId: Int,
profileImageUrl: String,
nickname: String,
createdAtText: String,
can: Int,
message: String
) {
val context = headerView.context
headerView.setBackgroundColor(context.getColor(headerColorResId))
profileView.loadUrl(profileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
nicknameView.text = nickname
createdAtView.text = createdAtText
canView.text = context.getString(R.string.creator_channel_donation_can_format, can.moneyFormat())
messageView.text = message
if (headerColorResId == R.color.red_400) {
nicknameView.setTextColor(context.getColor(R.color.white))
createdAtView.setTextColor(context.getColor(R.color.gray_100))
} else {
nicknameView.setTextColor(context.getColor(R.color.black))
createdAtView.setTextColor(context.getColor(R.color.gray_700))
}
}

View File

@@ -15,18 +15,18 @@ import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeSection
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
import kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedSize
@@ -40,7 +40,9 @@ class CreatorChannelHomeSectionAdapter(
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {},
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {},
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {},
private val onDonationClick: () -> Unit = {}
private val onDonationClick: () -> Unit = {},
private val onSectionChevronClick: (CreatorChannelTab) -> Unit = {},
private val onCommunityClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelHomeSectionAdapter.SectionViewHolder>() {
private var items: List<CreatorChannelHomeSection> = emptyList()
@@ -54,7 +56,16 @@ class CreatorChannelHomeSectionAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return SectionViewHolder(view, onLiveClick, onScheduleClick, onAudioContentClick, onSeriesClick, onDonationClick)
return SectionViewHolder(
view,
onLiveClick,
onScheduleClick,
onAudioContentClick,
onSeriesClick,
onDonationClick,
onSectionChevronClick,
onCommunityClick
)
}
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
@@ -69,9 +80,12 @@ class CreatorChannelHomeSectionAdapter(
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit,
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit,
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit,
private val onDonationClick: () -> Unit
private val onDonationClick: () -> Unit,
private val onSectionChevronClick: (CreatorChannelTab) -> Unit,
private val onCommunityClick: (Long) -> Unit
) : RecyclerView.ViewHolder(view) {
private val title: TextView? = view.findViewById(R.id.tv_section_title)
private val sectionTitleChevron: ImageView? = view.findViewById(R.id.iv_section_title_chevron)
private val currentLiveTitle: TextView? = view.findViewById(R.id.tv_current_live_title)
private val currentLiveStartTime: TextView? = view.findViewById(R.id.tv_current_live_start_time)
private val currentLivePrice: TextView? = view.findViewById(R.id.tv_current_live_price)
@@ -94,6 +108,7 @@ class CreatorChannelHomeSectionAdapter(
private val audioContentsRecyclerView: RecyclerView? = view.findViewById(R.id.rv_audio_contents)
private val seriesItems: LinearLayout? = view.findViewById(R.id.ll_series_items)
private val communityItems: LinearLayout? = view.findViewById(R.id.ll_community_items)
private val communityMoreButton: View? = view.findViewById(R.id.layout_community_more_button)
private val fanTalkCard: View? = view.findViewById(R.id.layout_fantalk_card)
private val fanTalkTotalRow: View? = view.findViewById(R.id.layout_fantalk_total_row)
private val fanTalkLatestRow: View? = view.findViewById(R.id.layout_fantalk_latest_row)
@@ -121,6 +136,11 @@ class CreatorChannelHomeSectionAdapter(
fun bind(item: CreatorChannelHomeSection) {
itemView.setOnClickListener(null)
title?.setText(item.titleResId)
sectionTitleChevron?.isVisible = item.chevronTab != null
sectionTitleChevron?.setOnClickListener {
item.chevronTab?.let(onSectionChevronClick)
}
communityMoreButton?.setOnClickListener(null)
donationItems?.removeAllViews()
noticeItems?.removeAllViews()
scheduleTimeline?.removeAllViews()
@@ -195,22 +215,24 @@ class CreatorChannelHomeSectionAdapter(
).apply {
marginEnd = if (index == visibleDonations.lastIndex) 0 else 4.dp()
}
row.findViewById<View>(R.id.layout_donation_header)
.setBackgroundColor(itemView.context.getColor(calculateCreatorChannelDonationHeaderColorRes(donation.can)))
row.findViewById<ImageView>(R.id.iv_donation_profile).loadUrl(donation.profileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
row.findViewById<TextView>(R.id.tv_donation_nickname).text = donation.nickname
row.findViewById<TextView>(R.id.tv_donation_created_at).text =
formatUtcRelativeTimeText(itemView.context, donation.createdAtUtc)
row.findViewById<TextView>(R.id.tv_donation_can).text = itemView.context.getString(
R.string.creator_channel_donation_can_format,
donation.can.moneyFormat()
)
row.findViewById<TextView>(R.id.tv_donation_message).text = donation.message.ifBlank {
val headerColorResId = calculateCreatorChannelDonationHeaderColorRes(donation.can)
val donationMessage = donation.message.ifBlank {
itemView.context.getString(R.string.creator_channel_donation_fallback_message, donation.can)
}
bindCreatorChannelDonationCard(
headerView = row.findViewById(R.id.layout_donation_header),
profileView = row.findViewById(R.id.iv_donation_profile),
nicknameView = row.findViewById(R.id.tv_donation_nickname),
createdAtView = row.findViewById(R.id.tv_donation_created_at),
canView = row.findViewById(R.id.tv_donation_can),
messageView = row.findViewById(R.id.tv_donation_message),
headerColorResId = headerColorResId,
profileImageUrl = donation.profileImageUrl,
nickname = donation.nickname,
createdAtText = formatUtcRelativeTimeText(itemView.context, donation.createdAtUtc),
can = donation.can,
message = donationMessage
)
donationItems?.addView(row)
}
}
@@ -350,6 +372,7 @@ class CreatorChannelHomeSectionAdapter(
}
private fun bindCommunities(item: CreatorChannelHomeSection.Communities) {
communityMoreButton?.setOnClickListener { onSectionChevronClick(CreatorChannelTab.Community) }
val visibleCommunities = item.communities.take(MAX_COMMUNITY_ITEM_COUNT)
visibleCommunities.forEachIndexed { index, community ->
val communityWidthDp = calculateCreatorChannelCommunityCardWidthDp(
@@ -376,6 +399,7 @@ class CreatorChannelHomeSectionAdapter(
).apply {
bottomMargin = if (index == visibleCommunities.lastIndex) 0 else 8.dp()
}
row.setOnClickListener { onCommunityClick(community.postId) }
communityItems?.addView(row)
}
}
@@ -589,6 +613,15 @@ class CreatorChannelHomeSectionAdapter(
is CreatorChannelHomeSection.Sns -> R.string.creator_channel_section_sns
}
val CreatorChannelHomeSection.chevronTab: CreatorChannelTab?
get() = when (this) {
is CreatorChannelHomeSection.Donations -> CreatorChannelTab.Donation
is CreatorChannelHomeSection.AudioContents -> CreatorChannelTab.Audio
is CreatorChannelHomeSection.Series -> CreatorChannelTab.Series
is CreatorChannelHomeSection.FanTalk -> CreatorChannelTab.FanTalk
else -> null
}
private const val MAX_DONATION_ITEM_COUNT = 8
private const val MAX_NOTICE_ITEM_COUNT = 3
private const val MAX_SCHEDULE_ITEM_COUNT = 3
@@ -678,7 +711,7 @@ private const val SNS_TOTAL_GAP_DP = 64
@ColorRes
internal fun calculateCreatorChannelDonationHeaderColorRes(can: Int): Int = when {
can >= 500 -> R.color.red_400
can >= 501 -> R.color.red_400
can >= 101 -> R.color.creator_channel_donation_cyan
can >= 51 -> R.color.green_400
else -> R.color.gray_200

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.v2.live.onair
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.media3.common.util.UnstableApi
@@ -14,7 +13,6 @@ import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -34,6 +32,7 @@ import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.user.login.LoginActivity
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState
import kr.co.vividnext.sodalive.v2.live.onair.model.canEnterHomeOnAirLiveRoom
import kr.co.vividnext.sodalive.v2.live.onair.ui.HomeOnAirLiveAdapter
@@ -183,7 +182,7 @@ class HomeOnAirLiveActivity : BaseActivity<ActivityHomeOnAirLiveBinding>(
if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.auth_title),
@@ -191,8 +190,7 @@ class HomeOnAirLiveActivity : BaseActivity<ActivityHomeOnAirLiveBinding>(
confirmButtonTitle = getString(R.string.auth_go),
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {},
descGravity = Gravity.CENTER
cancelButtonClick = {}
).show(screenWidth)
return
}

View File

@@ -1,9 +1,12 @@
package kr.co.vividnext.sodalive.v2.main
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ComponentName
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.os.Handler
@@ -39,6 +42,7 @@ import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
@@ -72,6 +76,7 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private val audioContentReceiver = AudioContentReceiver()
private var playerStateJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -82,11 +87,16 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
trackAppLaunchIfNeeded()
pushTokenUpdate()
handler.postDelayed({
if (!handleAudioNotificationRoute(intent) && isLoggedIn()) {
executeDeeplink(intent)
}
}, 1000)
if (isLoggedIn()) {
updatePidAndGaid()
getEventPopup()
observePlayerState()
handler.postDelayed({ executeDeeplink(intent) }, 1000)
}
}
@@ -114,14 +124,22 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
return
}
if (isLoggedIn()) {
if (!handleAudioNotificationRoute(intent) && isLoggedIn()) {
executeDeeplink(intent)
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onResume() {
super.onResume()
getMemberInfo()
val intentFilter = IntentFilter(Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(audioContentReceiver, intentFilter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(audioContentReceiver, intentFilter)
}
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.INIT.name
@@ -129,6 +147,11 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
)
}
override fun onPause() {
unregisterReceiver(audioContentReceiver)
super.onPause()
}
override fun onDestroy() {
deInitMiniPlayer()
playerStateJob?.cancel()
@@ -405,6 +428,39 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
playerFragment.show(supportFragmentManager, playerFragment.tag)
}
private fun handleAudioNotificationRoute(intent: Intent): Boolean {
val route = intent.getStringExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE) ?: return false
intent.removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)
return when (route) {
ROUTE_AUDIO_PLAYER -> {
if (isLoggedIn()) {
showPlayerFragment()
} else {
showLoginActivity()
}
true
}
ROUTE_AUDIO_DETAIL -> {
val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID)
if (contentId > 0 && isLoggedIn()) {
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
} else if (!isLoggedIn()) {
showLoginActivity()
}
contentId > 0
}
else -> false
}
}
private fun checkPermissions() {
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
@@ -734,8 +790,90 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
return SharedPreferenceManager.token.isNotBlank() && SharedPreferenceManager.token.length > 10
}
inner class AudioContentReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val contentId = intent?.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
val title = intent?.getStringExtra(Constants.EXTRA_AUDIO_CONTENT_TITLE)
val nickname = intent?.getStringExtra(Constants.EXTRA_NICKNAME)
val coverImageUrl = intent?.getStringExtra(
Constants.EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL
)
val isPlaying = intent?.getBooleanExtra(Constants.EXTRA_AUDIO_CONTENT_PLAYING, false)
val isShowing = intent?.getBooleanExtra(Constants.EXTRA_AUDIO_CONTENT_SHOWING, false)
if (isShowing == true) {
binding.clMiniPlayer.visibility = View.VISIBLE
if (contentId != null && contentId > 0) {
binding.clMiniPlayer.setOnClickListener {
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
}
}
if (isPlaying == true) {
binding.ivPlayerPlayOrPause.setImageResource(R.drawable.ic_noti_pause)
binding.ivPlayerPlayOrPause.setOnClickListener {
startService(
Intent(this@MainV2Activity, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.PAUSE.name
}
)
}
} else {
binding.ivPlayerPlayOrPause.setImageResource(R.drawable.ic_noti_play)
binding.ivPlayerPlayOrPause.setOnClickListener {
startService(
Intent(this@MainV2Activity, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.PLAY.name
}
)
}
}
binding.ivPlayerStop.setOnClickListener {
startService(
Intent(this@MainV2Activity, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.STOP.name
}
)
}
if (!title.isNullOrBlank()) {
binding.tvPlayerTitle.text = title
}
if (!nickname.isNullOrBlank()) {
binding.tvPlayerNickname.text = nickname
}
if (!coverImageUrl.isNullOrBlank()) {
binding.ivPlayerCover.load(coverImageUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(5.3f.dpToPx()))
}
}
} else {
handler.post {
binding.ivPlayerPlayOrPause.setImageResource(0)
binding.ivPlayerCover.setImageResource(0)
binding.tvPlayerTitle.text = ""
binding.tvPlayerNickname.text = ""
binding.clMiniPlayer.visibility = View.GONE
binding.ivPlayerPlayOrPause.setOnClickListener {}
}
}
}
}
companion object {
private const val EXTRA_CHAT_FILTER: String = "extra_chat_filter"
const val EXTRA_AUDIO_NOTIFICATION_ROUTE: String = "extra_audio_notification_route"
const val ROUTE_AUDIO_PLAYER: String = "audio_player"
const val ROUTE_AUDIO_DETAIL: String = "audio_detail"
fun newChatDmIntent(context: Context): Intent {
return Intent(context, MainV2Activity::class.java).apply {

View File

@@ -1,11 +1,9 @@
package kr.co.vividnext.sodalive.v2.main
import android.content.Intent
import android.view.Gravity
import androidx.fragment.app.Fragment
import com.google.gson.Gson
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.mypage.MyPageViewModel
@@ -14,6 +12,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import org.koin.android.ext.android.get
fun Fragment.ensureMainV2NavigationAllowed(
@@ -46,7 +45,7 @@ fun Fragment.ensureMainV2NavigationAllowed(
}
private fun Fragment.showMainV2AuthDialog() {
SodaDialog(
V2ModalDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
title = getString(R.string.auth_title),
@@ -54,8 +53,7 @@ private fun Fragment.showMainV2AuthDialog() {
confirmButtonTitle = getString(R.string.auth_go),
confirmButtonClick = { startMainV2AuthFlow() },
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {},
descGravity = Gravity.CENTER
cancelButtonClick = {}
).show(resources.displayMetrics.widthPixels)
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.main.chat
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.ImageView
@@ -12,6 +13,9 @@ import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.v2.main.ensureMainV2NavigationAllowed
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
@@ -63,8 +67,11 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
private fun setupTitleBar() {
binding.viewChatTitleBar.tvTitleBarTitle.setText(R.string.tab_chat)
binding.viewChatTitleBar.ivTitleBarMenu.setImageResource(R.drawable.ic_bar_cash)
binding.viewChatTitleBar.llTitleBarActions.addView(
ImageView(requireContext()).apply {
binding.viewChatTitleBar.ivTitleBarMenu.setOnClickListener {
openWithLoginGuard(CanChargeActivity::class.java)
}
val searchAction = ImageView(requireContext()).apply {
setImageResource(R.drawable.ic_bar_search)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
@@ -73,7 +80,16 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
marginStart = resources.getDimensionPixelSize(R.dimen.spacing_14)
}
}
)
searchAction.setOnClickListener {
openWithLoginGuard(SearchActivity::class.java)
}
binding.viewChatTitleBar.llTitleBarActions.addView(searchAction)
}
private fun openWithLoginGuard(activityClass: Class<*>) {
ensureMainV2NavigationAllowed {
startActivity(Intent(requireContext(), activityClass))
}
}
private fun setupFilterTabs(initialFilter: ChatRoomFilter) {

View File

@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.box.AudioContentBoxActivity
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.base.BaseFragment
@@ -23,6 +24,8 @@ import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
@@ -51,6 +54,7 @@ import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
import kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivity
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_ALL_GRID_SPAN_COUNT
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_GAP_DP
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_SPAN_COUNT
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllAudioCardAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllSeriesCardAdapter
@@ -113,6 +117,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
setUpAllTypeTabs()
setUpAllDayFilter()
setUpAllSortButton()
setupTitleBarActions()
showContentTab(CONTENT_TAB_RECOMMENDATION)
setUpSectionTitles()
setUpAdapters()
@@ -129,6 +134,24 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
super.onDestroyView()
}
private fun setupTitleBarActions() {
binding.viewContentTitleBar.ivTitleBarCash.setOnClickListener {
openWithLoginGuard(CanChargeActivity::class.java)
}
binding.viewContentTitleBar.ivTitleBarSearch.setOnClickListener {
openWithLoginGuard(SearchActivity::class.java)
}
binding.viewContentTitleBar.ivTitleBarStorage.setOnClickListener {
openWithLoginGuard(AudioContentBoxActivity::class.java)
}
}
private fun openWithLoginGuard(activityClass: Class<*>) {
ensureMainV2NavigationAllowed {
startActivity(Intent(requireContext(), activityClass))
}
}
fun selectAllTab(type: MainContentAllType, sort: ContentSort = ContentSort.LATEST) {
pendingAllTabSelection = ContentAllTabSelection(type, sort)
if (view != null) {
@@ -296,7 +319,8 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
binding.rvContentRecommendedAudios.apply {
layoutManager = GridLayoutManager(requireContext(), CONTENT_RECOMMENDED_GRID_SPAN_COUNT)
adapter = recommendedAudioAdapter
addContentGridItemSpacing()
addContentGridItemSpacing(gapDp = CONTENT_RECOMMENDED_GRID_GAP_DP)
doOnLayout { updateRecommendedAudioGridItemWidth() }
}
binding.rvContentRankings.apply {
layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext())
@@ -401,6 +425,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
}
}
private fun updateRecommendedAudioGridItemWidth() {
val widthPx = binding.rvContentRecommendedAudios.calculateContentGridItemWidthPx(
CONTENT_RECOMMENDED_GRID_SPAN_COUNT,
gapDp = CONTENT_RECOMMENDED_GRID_GAP_DP
)
recommendedAudioAdapter.setGridItemWidthPx(widthPx)
}
private fun updateAllTabGridItemWidth() {
val widthPx = binding.rvContentAllItems.calculateContentGridItemWidthPx(CONTENT_ALL_GRID_SPAN_COUNT)
contentAllAudioCardAdapter.setGridItemWidthPx(widthPx)
@@ -524,6 +556,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private fun bindRecommendedAudioSection(section: ContentAudioCardSection) {
binding.llContentRecommendedAudioSection.visibility = section.items.toSectionVisibility()
updateRecommendedAudioGridItemWidth()
recommendedAudioAdapter.submitItems(section.items)
}

View File

@@ -10,6 +10,6 @@ interface AudioRankingsApi {
@GET("/api/v2/audio/rankings")
fun getRankings(
@Header("Authorization") authHeader: String,
@Query("type") type: AudioRankingType
@Query("type") type: String
): Single<ApiResponse<AudioRankingResponse>>
}

View File

@@ -14,11 +14,22 @@ enum class AudioRankingType(
val queryValue: String,
val label: String
) {
@SerializedName("WEEKLY_POPULAR")
WEEKLY_POPULAR("WEEKLY_POPULAR", "주간 인기"),
@SerializedName("RISING")
RISING("RISING", "지금 뜨는 중"),
@SerializedName("REVENUE")
REVENUE("REVENUE", "매출"),
@SerializedName("SALES_COUNT")
SALES_COUNT("SALES_COUNT", "판매량"),
@SerializedName("COMMENT_COUNT")
COMMENT_COUNT("COMMENT_COUNT", "댓글수"),
@SerializedName("LIKE_COUNT")
LIKE_COUNT("LIKE_COUNT", "좋아요")
}

View File

@@ -3,6 +3,6 @@ package kr.co.vividnext.sodalive.v2.main.content.data
class AudioRankingsRepository(private val api: AudioRankingsApi) {
fun getRankings(token: String, type: AudioRankingType) = api.getRankings(
authHeader = token,
type = type
type = type.queryValue
)
}

View File

@@ -2,8 +2,6 @@ package kr.co.vividnext.sodalive.v2.main.content.data
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
@@ -12,10 +10,10 @@ interface MainContentAllTabApi {
@GET("/api/v2/audio/contents")
fun getContents(
@Header("Authorization") authHeader: String,
@Query("type") type: MainContentAllType,
@Query("sort") sort: ContentSort,
@Query("type") type: String,
@Query("sort") sort: String,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek?
@Query("dayOfWeek") dayOfWeek: String?
): Single<ApiResponse<MainContentAllTabResponse>>
}

View File

@@ -18,12 +18,22 @@ data class MainContentAllTabResponse(
@SerializedName("hasNext") val hasNext: Boolean
)
enum class MainContentAllType {
AUDIO,
SERIES,
ORIGINAL,
FREE,
POINT
@Keep
enum class MainContentAllType(val queryValue: String) {
@SerializedName("AUDIO")
AUDIO("AUDIO"),
@SerializedName("SERIES")
SERIES("SERIES"),
@SerializedName("ORIGINAL")
ORIGINAL("ORIGINAL"),
@SerializedName("FREE")
FREE("FREE"),
@SerializedName("POINT")
POINT("POINT")
}
@Keep

View File

@@ -13,10 +13,10 @@ class MainContentAllTabRepository(private val api: MainContentAllTabApi) {
dayOfWeek: SeriesPublishedDaysOfWeek?
) = api.getContents(
authHeader = token,
type = type,
sort = sort,
type = type.queryValue,
sort = sort.queryValue,
page = page,
size = size,
dayOfWeek = dayOfWeek
dayOfWeek = dayOfWeek?.queryValue
)
}

View File

@@ -12,6 +12,6 @@ interface ContentOverviewApi {
@Header("Authorization") authHeader: String,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("type") type: ContentOverviewType
@Query("type") type: String
): Single<ApiResponse<ContentOverviewPageResponse>>
}

View File

@@ -12,8 +12,10 @@ data class ContentOverviewPageResponse(
@SerializedName("hasNext") val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO
@Keep
enum class ContentOverviewType(val queryValue: String) {
@SerializedName("NEW_AND_HOT_AUDIO")
NEW_AND_HOT_AUDIO("NEW_AND_HOT_AUDIO")
}
@Keep

View File

@@ -10,6 +10,6 @@ class ContentOverviewRepository(private val api: ContentOverviewApi) {
authHeader = token,
page = page,
size = size,
type = type
type = type.queryValue
)
}

View File

@@ -14,10 +14,35 @@ class ContentAudioCardAdapter(
) : RecyclerView.Adapter<ContentAudioCardAdapter.ViewHolder>() {
private var items: List<ContentAudioCardUiModel> = emptyList()
private var gridItemWidthPx: Int = 0
fun setGridItemWidthPx(widthPx: Int) {
if (widthPx <= 0 || gridItemWidthPx == widthPx) return
gridItemWidthPx = widthPx
if (items.isNotEmpty()) notifyItemRangeChanged(0, items.size)
}
fun submitItems(items: List<ContentAudioCardUiModel>) {
val previousItemCount = this.items.size
this.items = items
notifyDataSetChanged()
notifyItemChanges(previousItemCount, items.size)
}
private fun notifyItemChanges(previousItemCount: Int, currentItemCount: Int) {
when {
previousItemCount == 0 && currentItemCount > 0 -> notifyItemRangeInserted(0, currentItemCount)
previousItemCount > 0 && currentItemCount == 0 -> notifyItemRangeRemoved(0, previousItemCount)
previousItemCount == currentItemCount -> notifyItemRangeChanged(0, currentItemCount)
else -> {
val changedItemCount = minOf(previousItemCount, currentItemCount)
if (changedItemCount > 0) notifyItemRangeChanged(0, changedItemCount)
if (currentItemCount > previousItemCount) {
notifyItemRangeInserted(previousItemCount, currentItemCount - previousItemCount)
} else {
notifyItemRangeRemoved(currentItemCount, previousItemCount - currentItemCount)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@@ -29,7 +54,10 @@ class ContentAudioCardAdapter(
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
holder.bind(
item = items[position],
gridItemWidthPx = gridItemWidthPx
)
}
override fun getItemCount(): Int = items.size
@@ -39,8 +67,12 @@ class ContentAudioCardAdapter(
private val cardSize: AudioContentCardSize,
private val onAudioClick: (ContentAudioCardUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ContentAudioCardUiModel) = with(binding.audioContentCard.root) {
fun bind(item: ContentAudioCardUiModel, gridItemWidthPx: Int) = with(binding.audioContentCard.root) {
if (gridItemWidthPx > 0) {
setGridItemWidthPx(gridItemWidthPx)
} else {
setSize(cardSize)
}
setContent(item.title, item.creatorNickname)
setTags(item.tags)
setAdultVisible(item.showAdultBadge)

View File

@@ -9,6 +9,7 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemContentCommentedAudioBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioUiModel
@@ -24,8 +25,11 @@ class ContentCommentedAudioAdapter(
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemContentCommentedAudioBinding.inflate(LayoutInflater.from(parent.context), parent, false).apply {
root.layoutParams.width = parent.resources.displayMetrics.widthPixels - (CONTENT_HORIZONTAL_MARGIN_DP * 2).dpToPx().toInt()
}
return ViewHolder(
ItemContentCommentedAudioBinding.inflate(LayoutInflater.from(parent.context), parent, false),
binding,
onAudioClick
)
}
@@ -47,6 +51,12 @@ class ContentCommentedAudioAdapter(
outline.setRoundRect(0, 0, view.width, view.height, view.resources.getDimension(R.dimen.radius_14))
}
}
binding.ivContentCommentProfile.clipToOutline = true
binding.ivContentCommentProfile.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setOval(0, 0, view.width, view.height)
}
}
}
fun bind(item: ContentCommentedAudioUiModel) = with(binding) {
@@ -58,4 +68,8 @@ class ContentCommentedAudioAdapter(
root.setOnClickListener { onAudioClick(item) }
}
}
private companion object {
const val CONTENT_HORIZONTAL_MARGIN_DP = 14
}
}

View File

@@ -10,14 +10,20 @@ fun RecyclerView.addContentHorizontalItemSpacing() {
if (itemDecorationCount == 0) addItemDecoration(ContentHorizontalItemDecoration())
}
fun RecyclerView.addContentGridItemSpacing(spanCount: Int = CONTENT_RECOMMENDED_GRID_SPAN_COUNT) {
if (itemDecorationCount == 0) addItemDecoration(ContentGridItemDecoration(spanCount))
fun RecyclerView.addContentGridItemSpacing(
spanCount: Int = CONTENT_RECOMMENDED_GRID_SPAN_COUNT,
gapDp: Int = GRID_ITEM_GAP_DP
) {
if (itemDecorationCount == 0) addItemDecoration(ContentGridItemDecoration(spanCount, gapDp))
}
fun RecyclerView.calculateContentGridItemWidthPx(spanCount: Int): Int {
fun RecyclerView.calculateContentGridItemWidthPx(
spanCount: Int,
gapDp: Int = GRID_ITEM_GAP_DP
): Int {
val availableWidth = measuredWidth - paddingLeft - paddingRight
if (availableWidth <= 0 || spanCount <= 0) return 0
val totalGap = GRID_ITEM_GAP_DP.dpToPx() * (spanCount - 1)
val totalGap = gapDp.dpToPx() * (spanCount - 1)
return ((availableWidth - totalGap) / spanCount).roundToInt()
}
@@ -31,13 +37,14 @@ private class ContentHorizontalItemDecoration : RecyclerView.ItemDecoration() {
}
private class ContentGridItemDecoration(
private val spanCount: Int
private val spanCount: Int,
private val gapDp: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) return
val columnIndex = position % spanCount
val gap = GRID_ITEM_GAP_DP.dpToPx()
val gap = gapDp.dpToPx()
outRect.left = (columnIndex * gap / spanCount).roundToInt()
outRect.right = (gap - (columnIndex + 1) * gap / spanCount).roundToInt()
if (position >= spanCount) outRect.top = GRID_ITEM_VERTICAL_GAP_DP.dpToPx().toInt()
@@ -45,6 +52,7 @@ private class ContentGridItemDecoration(
}
private const val HORIZONTAL_ITEM_GAP_DP = 8
const val CONTENT_RECOMMENDED_GRID_GAP_DP = 4
private const val GRID_ITEM_GAP_DP = 8
private const val GRID_ITEM_VERTICAL_GAP_DP = 28
const val CONTENT_RECOMMENDED_GRID_SPAN_COUNT = 2

View File

@@ -14,8 +14,14 @@ import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.following.FollowingCreatorActivity
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelLiveCoordinator
import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveActivity
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
@@ -70,6 +76,7 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentDebutCreatorAdapter
import kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapter
import kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
@@ -78,7 +85,18 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private val homeRecommendationViewModel: HomeRecommendationViewModel by viewModel()
private val homeCreatorRankingViewModel: HomeCreatorRankingViewModel by viewModel()
private val homeFollowingViewModel: HomeFollowingViewModel by viewModel()
private val liveViewModel: LiveViewModel by inject()
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) }
private val liveCoordinator: CreatorChannelLiveCoordinator by lazy {
CreatorChannelLiveCoordinator(
activity = requireActivity(),
layoutInflater = layoutInflater,
fragmentManager = requireActivity().supportFragmentManager,
liveViewModel = liveViewModel,
screenWidthProvider = { screenWidth },
refreshHome = { homeRecommendationViewModel.loadRecommendations() }
)
}
private val liveAdapter = HomeLiveAdapter()
private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { onRecentActivityClick(it) }
private val recentDebutCreatorAdapter = HomeRecentDebutCreatorAdapter { openCreatorProfile(it.creatorId) }
@@ -93,7 +111,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
)
private val popularCommunityAdapter = HomePopularCommunityAdapter { openPopularCommunityPost(it) }
private val creatorRankingAdapter = CreatorRankingAdapter { openCreatorRankingProfile(it) }
private val followingCreatorAdapter = HomeFollowingCreatorAdapter { openCreatorProfile(it.creatorId) }
private val followingCreatorAdapter = HomeFollowingCreatorAdapter(
onClickItem = { openCreatorProfile(it.creatorId) },
onClickAll = { openFollowingCreatorAll() }
)
private val followingLiveAdapter = HomeFollowingLiveAdapter { onFollowingLiveClick(it) }
private val followingChatAdapter = HomeFollowingChatAdapter { openFollowingChat(it) }
private val followingScheduleAdapter = HomeFollowingScheduleAdapter { onFollowingScheduleClick(it) }
@@ -104,6 +125,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private var hasLoadedCreatorRankings = false
private var hasLoadedFollowing = false
private var currentHomeTabIndex = HOME_TAB_RECOMMENDATION
private var isRecommendationLoading = false
private var isCreatorRankingLoading = false
private var isFollowingLoading = false
private var isLiveEntryLoading = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -127,12 +152,32 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
setUpCreatorRankingAdapter()
setUpFollowingAdapters()
setUpBusinessInfo()
setupTitleBarActions()
bindHomeRecommendationObservers()
bindHomeCreatorRankingObservers()
bindHomeFollowingObservers()
bindLiveEntryObservers()
homeRecommendationViewModel.loadRecommendations()
}
private fun setupTitleBarActions() {
binding.viewHomeTitleBar.ivTitleBarCash.setOnClickListener {
openWithLoginGuard(CanChargeActivity::class.java)
}
binding.viewHomeTitleBar.ivTitleBarSearch.setOnClickListener {
openWithLoginGuard(SearchActivity::class.java)
}
binding.viewHomeTitleBar.ivTitleBarBell.setOnClickListener {
openWithLoginGuard(PushNotificationListActivity::class.java)
}
}
private fun openWithLoginGuard(activityClass: Class<*>) {
ensureMainV2NavigationAllowed {
startActivity(Intent(requireContext(), activityClass))
}
}
private fun setUpBusinessInfo() {
val businessInfoText = binding.tvHomeBusinessInfo.text
HomeBusinessInfoBinder.bind(binding.tvHomeBusinessInfo)
@@ -226,11 +271,8 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
}
}
homeCreatorRankingViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
if (isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
isCreatorRankingLoading = isLoading
updateLoadingDialog()
}
homeCreatorRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
@@ -289,11 +331,8 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
}
}
homeFollowingViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
if (isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
isFollowingLoading = isLoading
updateLoadingDialog()
}
homeFollowingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
@@ -311,17 +350,32 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
}
}
homeRecommendationViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
if (isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
isRecommendationLoading = isLoading
updateLoadingDialog()
}
homeRecommendationViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
}
}
private fun bindLiveEntryObservers() {
liveViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
isLiveEntryLoading = isLoading
updateLoadingDialog()
}
liveViewModel.toastLiveData.observe(viewLifecycleOwner) { message ->
message?.let { showToast(it) }
}
}
private fun updateLoadingDialog() {
if (isRecommendationLoading || isCreatorRankingLoading || isFollowingLoading || isLiveEntryLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
private fun bindHomeRecommendationContent(content: HomeRecommendationUiState.Content) {
bindLiveSection(content.lives)
bindBannerSection(content.banners)
@@ -488,7 +542,16 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
ivSectionTitleChevron.visibility = if (showMore) View.VISIBLE else View.GONE
}
private fun onLiveClick(item: HomeRecommendationLiveUiModel) = Unit
private fun onLiveClick(item: HomeRecommendationLiveUiModel) {
ensureMainV2NavigationAllowed {
liveViewModel.getRoomDetail(item.roomId) { roomDetail ->
if (!isAdded || view == null) return@getRoomDetail
ensureMainV2NavigationAllowed(requiresAdultContentAccess = roomDetail.isAdult) {
liveCoordinator.enterLiveRoom(item.roomId, roomDetail)
}
}
}
}
private fun openHomeOnAirLive() {
ensureMainV2NavigationAllowed {
@@ -504,6 +567,12 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem) = Unit
private fun openFollowingCreatorAll() {
ensureMainV2NavigationAllowed {
startActivity(Intent(requireContext(), FollowingCreatorActivity::class.java))
}
}
private fun openFollowingChat(item: ChatRoomListUiItem) {
ensureMainV2NavigationAllowed {
when (item.chatType) {

View File

@@ -48,15 +48,49 @@ data class FollowingScheduleResponse(
data class FollowingNewsResponse(
@SerializedName("newsId") val newsId: String,
@SerializedName("type") val type: FollowingNewsType,
@SerializedName("creatorProfileImageUrl") val creatorProfileImageUrl: String,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("title") val title: String,
@SerializedName("body") val body: String,
@SerializedName("thumbnailImageUrl") val thumbnailImageUrl: String?,
@SerializedName("targetId") val targetId: Long,
@SerializedName("occurredAtUtc") val occurredAtUtc: String,
@SerializedName("visibleFromAtUtc") val visibleFromAtUtc: String,
@SerializedName("rank") val rank: Int?
@SerializedName("creatorRanking") val creatorRanking: FollowingCreatorRankingNewsResponse? = null,
@SerializedName("audioContent") val audioContent: FollowingContentNewsResponse? = null,
@SerializedName("photoContent") val photoContent: FollowingContentNewsResponse? = null,
@SerializedName("contentRanking") val contentRanking: FollowingContentRankingNewsResponse? = null,
@SerializedName("communityPost") val communityPost: FollowingCommunityPostNewsResponse? = null
)
@Keep
data class FollowingCreatorRankingNewsResponse(
@SerializedName("rank") val rank: Int,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String
)
@Keep
data class FollowingContentNewsResponse(
@SerializedName("contentId") val contentId: Long,
@SerializedName("contentImageUrl") val contentImageUrl: String?,
@SerializedName("title") val title: String,
@SerializedName("creatorProfileImageUrl") val creatorProfileImageUrl: String,
@SerializedName("creatorNickname") val creatorNickname: String
)
@Keep
data class FollowingContentRankingNewsResponse(
@SerializedName("rank") val rank: Int,
@SerializedName("contentId") val contentId: Long,
@SerializedName("contentImageUrl") val contentImageUrl: String?,
@SerializedName("title") val title: String
)
@Keep
data class FollowingCommunityPostNewsResponse(
@SerializedName("postId") val postId: Long,
@SerializedName("creatorProfileImage") val creatorProfileImage: String,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("imageUrl") val imageUrl: String?,
@SerializedName("content") val content: String,
@SerializedName("createdAt") val createdAt: String,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("commentCount") val commentCount: Int
)
enum class FollowingNewsType {

View File

@@ -4,7 +4,11 @@ import androidx.annotation.StringRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.main.chat.model.toUiItems
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCommunityPostNewsResponse
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingContentNewsResponse
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingContentRankingNewsResponse
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCreatorResponse
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCreatorRankingNewsResponse
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingLiveResponse
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsResponse
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
@@ -62,41 +66,87 @@ private fun FollowingNewsResponse.toUiItem(
): HomeFollowingNewsUiItem? {
val visibleFromText = relativeTimeTextFormatter.format(visibleFromAtUtc)
return when (type) {
FollowingNewsType.CREATOR_RANKING,
FollowingNewsType.CONTENT_RANKING -> rank?.let {
HomeFollowingNewsUiItem.Ranking(
newsId = newsId,
type = type,
creatorProfileImageUrl = creatorProfileImageUrl,
creatorNickname = creatorNickname,
title = title,
body = body,
thumbnailImageUrl = thumbnailImageUrl,
targetId = targetId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
visibleFromText = visibleFromText,
rank = it
)
FollowingNewsType.CREATOR_RANKING -> creatorRanking?.toUiItem(newsId, type, visibleFromAtUtc, visibleFromText)
FollowingNewsType.CONTENT_RANKING -> contentRanking?.toUiItem(newsId, type, visibleFromAtUtc, visibleFromText)
FollowingNewsType.COMMUNITY_POST -> communityPost?.toUiItem(newsId, type, visibleFromAtUtc, visibleFromText)
FollowingNewsType.AUDIO_CONTENT -> audioContent?.toUiItem(newsId, type, visibleFromAtUtc, visibleFromText)
FollowingNewsType.PHOTO_CONTENT -> photoContent?.toUiItem(newsId, type, visibleFromAtUtc, visibleFromText)
}
FollowingNewsType.COMMUNITY_POST,
FollowingNewsType.AUDIO_CONTENT,
FollowingNewsType.PHOTO_CONTENT -> HomeFollowingNewsUiItem.Content(
}
private fun FollowingCreatorRankingNewsResponse.toUiItem(
newsId: String,
type: FollowingNewsType,
visibleFromAtUtc: String,
visibleFromText: String
) = HomeFollowingNewsUiItem.Ranking(
newsId = newsId,
type = type,
creatorProfileImageUrl = creatorProfileImageUrl,
creatorNickname = creatorNickname,
title = title,
body = body,
thumbnailImageUrl = thumbnailImageUrl,
targetId = targetId,
occurredAtUtc = occurredAtUtc,
targetId = creatorId,
visibleFromAtUtc = visibleFromAtUtc,
visibleFromText = visibleFromText,
rank = rank,
imageUrl = profileImageUrl,
title = nickname,
creatorNickname = nickname
)
private fun FollowingContentRankingNewsResponse.toUiItem(
newsId: String,
type: FollowingNewsType,
visibleFromAtUtc: String,
visibleFromText: String
) = HomeFollowingNewsUiItem.Ranking(
newsId = newsId,
type = type,
targetId = contentId,
visibleFromAtUtc = visibleFromAtUtc,
visibleFromText = visibleFromText,
rank = rank,
imageUrl = contentImageUrl,
title = title,
creatorNickname = ""
)
private fun FollowingContentNewsResponse.toUiItem(
newsId: String,
type: FollowingNewsType,
visibleFromAtUtc: String,
visibleFromText: String
) = HomeFollowingNewsUiItem.Content(
newsId = newsId,
type = type,
targetId = contentId,
visibleFromAtUtc = visibleFromAtUtc,
visibleFromText = visibleFromText,
contentId = contentId,
contentImageUrl = contentImageUrl,
title = title,
creatorProfileImageUrl = creatorProfileImageUrl,
creatorNickname = creatorNickname,
labelResId = type.toLabelResId()
)
}
}
private fun FollowingCommunityPostNewsResponse.toUiItem(
newsId: String,
type: FollowingNewsType,
visibleFromAtUtc: String,
visibleFromText: String
) = HomeFollowingNewsUiItem.Community(
newsId = newsId,
type = type,
targetId = postId,
visibleFromAtUtc = visibleFromAtUtc,
visibleFromText = visibleFromText,
postId = postId,
creatorProfileImageUrl = creatorProfileImage,
creatorNickname = creatorNickname,
imageUrl = imageUrl,
content = content,
createdAt = createdAt,
likeCount = likeCount,
commentCount = commentCount
)
@StringRes
private fun FollowingNewsType.toLabelResId(): Int = when (this) {

View File

@@ -55,43 +55,49 @@ data class HomeFollowingScheduleUiItem(
sealed interface HomeFollowingNewsUiItem {
val newsId: String
val type: FollowingNewsType
val creatorProfileImageUrl: String
val creatorNickname: String
val title: String
val body: String
val thumbnailImageUrl: String?
val targetId: Long
val occurredAtUtc: String
val visibleFromAtUtc: String
val visibleFromText: String
data class Ranking(
override val newsId: String,
override val type: FollowingNewsType,
override val creatorProfileImageUrl: String,
override val creatorNickname: String,
override val title: String,
override val body: String,
override val thumbnailImageUrl: String?,
override val targetId: Long,
override val occurredAtUtc: String,
override val visibleFromAtUtc: String,
override val visibleFromText: String,
val rank: Int
val rank: Int,
val imageUrl: String?,
val title: String,
val creatorNickname: String
) : HomeFollowingNewsUiItem
data class Content(
override val newsId: String,
override val type: FollowingNewsType,
override val creatorProfileImageUrl: String,
override val creatorNickname: String,
override val title: String,
override val body: String,
override val thumbnailImageUrl: String?,
override val targetId: Long,
override val occurredAtUtc: String,
override val visibleFromAtUtc: String,
override val visibleFromText: String,
val contentId: Long,
val contentImageUrl: String?,
val title: String,
val creatorProfileImageUrl: String,
val creatorNickname: String,
@param:StringRes val labelResId: Int
) : HomeFollowingNewsUiItem
data class Community(
override val newsId: String,
override val type: FollowingNewsType,
override val targetId: Long,
override val visibleFromAtUtc: String,
override val visibleFromText: String,
val postId: Long,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val imageUrl: String?,
val content: String,
val createdAt: String,
val likeCount: Int,
val commentCount: Int
) : HomeFollowingNewsUiItem
}

View File

@@ -10,8 +10,9 @@ import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingCreatorUiItem
class HomeFollowingCreatorAdapter(
private val onClickItem: (HomeFollowingCreatorUiItem) -> Unit = {}
) : RecyclerView.Adapter<HomeFollowingCreatorAdapter.CreatorViewHolder>() {
private val onClickItem: (HomeFollowingCreatorUiItem) -> Unit = {},
private val onClickAll: () -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: List<HomeFollowingCreatorUiItem> = emptyList()
fun submitItems(items: List<HomeFollowingCreatorUiItem>) {
@@ -19,17 +20,29 @@ class HomeFollowingCreatorAdapter(
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CreatorViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_home_following_creator, parent, false)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutResId = when (viewType) {
VIEW_TYPE_ALL -> R.layout.item_home_following_creator_all
else -> R.layout.item_home_following_creator
}
val view = LayoutInflater.from(parent.context).inflate(layoutResId, parent, false)
view.layoutParams = recyclerItemLayoutParams(parent)
return CreatorViewHolder(view, onClickItem)
return when (viewType) {
VIEW_TYPE_ALL -> AllViewHolder(view, onClickAll)
else -> CreatorViewHolder(view, onClickItem)
}
}
override fun onBindViewHolder(holder: CreatorViewHolder, position: Int) {
holder.bind(items[position])
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is AllViewHolder -> holder.bind()
is CreatorViewHolder -> holder.bind(items[position])
}
}
override fun getItemCount(): Int = items.size
override fun getItemCount(): Int = items.size + if (items.isNotEmpty()) 1 else 0
override fun getItemViewType(position: Int): Int = if (position < items.size) VIEW_TYPE_CREATOR else VIEW_TYPE_ALL
class CreatorViewHolder(
itemView: View,
@@ -44,4 +57,18 @@ class HomeFollowingCreatorAdapter(
itemView.setOnClickListener { onClickItem(item) }
}
}
class AllViewHolder(
itemView: View,
private val onClickAll: () -> Unit
) : RecyclerView.ViewHolder(itemView) {
fun bind() {
itemView.setOnClickListener { onClickAll() }
}
}
private companion object {
private const val VIEW_TYPE_CREATOR = 0
private const val VIEW_TYPE_ALL = 1
}
}

View File

@@ -8,7 +8,6 @@ import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveUiItem
class HomeFollowingLiveAdapter(
@@ -37,14 +36,12 @@ class HomeFollowingLiveAdapter(
itemView: View,
private val onClickItem: (HomeFollowingLiveUiItem) -> Unit
) : RecyclerView.ViewHolder(itemView) {
private val thumbnailImage = itemView.findViewById<ImageView>(R.id.iv_home_following_live_thumbnail)
private val profileImage = itemView.findViewById<ImageView>(R.id.iv_home_following_live_creator_profile)
private val titleText = itemView.findViewById<TextView>(R.id.tv_home_following_live_title)
private val nicknameText = itemView.findViewById<TextView>(R.id.tv_home_following_live_creator_nickname)
private val startedAtText = itemView.findViewById<TextView>(R.id.tv_home_following_live_started_at)
fun bind(item: HomeFollowingLiveUiItem) {
thumbnailImage.loadUrl(item.creatorProfileImageUrl)
profileImage.loadHomeCreatorProfileImage(item.creatorProfileImageUrl)
titleText.text = item.title
nicknameText.text = item.creatorNickname

View File

@@ -8,7 +8,9 @@ import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingNewsUiItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedContentCategory
import kr.co.vividnext.sodalive.v2.widget.feed.FeedContentView
import kr.co.vividnext.sodalive.v2.widget.feed.FeedRankHighlight
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedRankView
import kr.co.vividnext.sodalive.v2.widget.feed.FeedSize
@@ -29,13 +31,8 @@ class HomeFollowingNewsAdapter(
override fun getItemViewType(position: Int): Int = when (val item = items[position]) {
is HomeFollowingNewsUiItem.Ranking -> VIEW_TYPE_RANKING
is HomeFollowingNewsUiItem.Content -> when (item.type) {
FollowingNewsType.COMMUNITY_POST -> VIEW_TYPE_COMMUNITY
FollowingNewsType.AUDIO_CONTENT,
FollowingNewsType.PHOTO_CONTENT -> VIEW_TYPE_CONTENT
FollowingNewsType.CREATOR_RANKING,
FollowingNewsType.CONTENT_RANKING -> VIEW_TYPE_CONTENT
}
is HomeFollowingNewsUiItem.Content -> VIEW_TYPE_CONTENT
is HomeFollowingNewsUiItem.Community -> VIEW_TYPE_COMMUNITY
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
@@ -76,18 +73,39 @@ class HomeFollowingNewsAdapter(
override fun bind(item: HomeFollowingNewsUiItem) {
val ranking = item as HomeFollowingNewsUiItem.Ranking
val context = view.context
val rankText = "${ranking.rank}"
val message = ranking.toRankingMessage(rankText)
val feedItem = FeedItem.Rank(
feedId = ranking.newsId,
imageUrl = ranking.thumbnailImageUrl.orEmpty(),
imageUrl = ranking.imageUrl.orEmpty(),
rankText = ranking.rank.toString(),
message = ranking.body.ifBlank { ranking.title },
highlightRanges = emptyList()
message = message,
highlightRanges = listOf(
FeedRankHighlight(
start = message.indexOf(rankText),
endExclusive = message.indexOf(rankText) + rankText.length
)
)
)
view.setFeedSize(feedSize(FeedVariant.Rank, parent))
view.bind(feedItem)
bindImage(view.imageView(), feedItem.imageUrl)
view.setOnFeedClick { onClickItem(ranking) }
}
private fun HomeFollowingNewsUiItem.Ranking.toRankingMessage(rankText: String): String {
val messageResId = when (type) {
FollowingNewsType.CREATOR_RANKING -> R.string.screen_home_following_creator_ranking_news_message
FollowingNewsType.CONTENT_RANKING -> R.string.screen_home_following_content_ranking_news_message
FollowingNewsType.COMMUNITY_POST,
FollowingNewsType.AUDIO_CONTENT,
FollowingNewsType.PHOTO_CONTENT -> R.string.screen_home_following_content_ranking_news_message
}
val subject = if (type == FollowingNewsType.CREATOR_RANKING) creatorNickname else title
val context = view.context
return context.getString(messageResId, subject, rankText)
}
}
class CommunityViewHolder(
@@ -97,19 +115,19 @@ class HomeFollowingNewsAdapter(
) : NewsViewHolder(view) {
override fun bind(item: HomeFollowingNewsUiItem) {
val community = item as HomeFollowingNewsUiItem.Content
val community = item as HomeFollowingNewsUiItem.Community
val feedItem = FeedItem.Community(
feedId = community.newsId,
creatorId = community.targetId.toString(),
creatorName = community.creatorNickname,
creatorImageUrl = community.creatorProfileImageUrl,
postId = community.targetId.toString(),
bodyText = community.body.ifBlank { community.title },
postId = community.postId.toString(),
bodyText = community.content,
keywordText = "",
createdAtText = community.visibleFromText,
commentCount = 0,
likeCount = 0,
imageUrl = community.thumbnailImageUrl
commentCount = community.commentCount,
likeCount = community.likeCount,
imageUrl = community.imageUrl
)
view.setFeedSize(feedSize(FeedVariant.Community, parent))
view.bind(feedItem)
@@ -132,10 +150,11 @@ class HomeFollowingNewsAdapter(
creatorId = content.targetId.toString(),
creatorName = content.creatorNickname,
creatorImageUrl = content.creatorProfileImageUrl,
contentId = content.targetId.toString(),
contentId = content.contentId.toString(),
contentTitle = content.title,
contentImageUrl = content.thumbnailImageUrl.orEmpty(),
createdAtText = content.visibleFromText
contentImageUrl = content.contentImageUrl.orEmpty(),
createdAtText = content.visibleFromText,
category = content.type.toFeedContentCategory()
)
view.setFeedSize(feedSize(FeedVariant.Content, parent))
view.bind(feedItem)
@@ -164,5 +183,13 @@ class HomeFollowingNewsAdapter(
imageView.loadUrl(url)
}
}
private fun FollowingNewsType.toFeedContentCategory(): FeedContentCategory = when (this) {
FollowingNewsType.AUDIO_CONTENT -> FeedContentCategory.Audio
FollowingNewsType.PHOTO_CONTENT -> FeedContentCategory.Photo
FollowingNewsType.COMMUNITY_POST,
FollowingNewsType.CREATOR_RANKING,
FollowingNewsType.CONTENT_RANKING -> FeedContentCategory.Content
}
}
}

View File

@@ -4,11 +4,13 @@ import androidx.annotation.StringRes
import kr.co.vividnext.sodalive.R
enum class FeedContentCategory(
@StringRes val labelResId: Int,
@param:StringRes val labelResId: Int,
val ratioWidth: Int,
val ratioHeight: Int
) {
Content(labelResId = R.string.feed_content_category_content, ratioWidth = 1, ratioHeight = 1),
Audio(labelResId = R.string.home_recommendation_activity_audio, ratioWidth = 1, ratioHeight = 1),
Photo(labelResId = R.string.screen_home_following_photo_content, ratioWidth = 1, ratioHeight = 1),
Series(labelResId = R.string.feed_content_category_series, ratioWidth = 163, ratioHeight = 230),
Magazine(labelResId = R.string.feed_content_category_magazine, ratioWidth = 163, ratioHeight = 218)
}

View File

@@ -35,6 +35,8 @@ class FeedContentView @JvmOverloads constructor(
titleText = findViewById(R.id.tv_feed_content_title)
categoryText = findViewById(R.id.tv_feed_content_category)
createdAtText = findViewById(R.id.tv_feed_content_created_at)
clipToOutline = true
outlineProvider = roundedOutlineProvider()
contentImageView().clipToOutline = true
contentImageView().outlineProvider = roundedOutlineProvider()
profileImageView().clipToOutline = true
@@ -105,6 +107,6 @@ class FeedContentView @JvmOverloads constructor(
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val CONTENT_IMAGE_WIDTH_DP = 163
const val CONTENT_IMAGE_WIDTH_DP = 88
}
}

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/gray_800" />
<corners android:radius="@dimen/radius_14" />
</shape>

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/gray_900" />
<corners android:radius="20dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/gray_800" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/soda_400" />
</shape>

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/gray_800" />
<corners android:radius="18dp" />
</shape>

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/soda_400" />
<corners android:radius="18dp" />
</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/gray_900" />
<stroke
android:width="2dp"
android:color="@color/color_3bb9f1" />
<corners android:radius="90dp" />
</shape>

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/gray_900" />
<corners android:radius="@dimen/radius_14" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:fillColor="@color/soda_400"
android:pathData="M9,15.8L7.9,14.8C4,11.3 1.5,9 1.5,6.2C1.5,3.9 3.3,2.2 5.5,2.2C6.8,2.2 8,2.8 9,3.8C10,2.8 11.2,2.2 12.5,2.2C14.7,2.2 16.5,3.9 16.5,6.2C16.5,9 14,11.3 10.1,14.8L9,15.8Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/gray_500"
android:pathData="M12,5L5,12L6.4,13.4L11,8.8V19H13V8.8L17.6,13.4L19,12L12,5Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/white"
android:pathData="M12,5L5,12L6.4,13.4L11,8.8V19H13V8.8L17.6,13.4L19,12L12,5Z" />
</vector>

View File

@@ -407,6 +407,31 @@
app:tint="@color/white" />
</FrameLayout>
<FrameLayout
android:id="@+id/btn_creator_channel_fantalk_fixed_plus"
android:layout_width="66dp"
android:layout_height="66dp"
android:layout_marginEnd="@dimen/spacing_14"
android:layout_marginBottom="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab"
android:clickable="true"
android:contentDescription="@string/creator_channel_fantalk_support_action"
android:elevation="8dp"
android:focusable="true"
android:padding="@dimen/spacing_14"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible">
<ImageView
android:layout_width="38dp"
android:layout_height="38dp"
android:contentDescription="@null"
android:src="@drawable/ic_plus_no_bg"
app:tint="@color/white" />
</FrameLayout>
<FrameLayout
android:id="@+id/owner_fab_button"
android:layout_width="66dp"

View File

@@ -0,0 +1,338 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_creator_channel_community_detail_title_bar"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/btn_creator_channel_community_detail_back"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@android:color/transparent"
android:contentDescription="@string/a11y_back"
android:src="@drawable/ic_new_bar_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_creator_channel_community_detail"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_16"
app:layout_constraintBottom_toTopOf="@id/layout_creator_channel_community_detail_input_bar"
app:layout_constraintTop_toBottomOf="@id/layout_creator_channel_community_detail_title_bar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/layout_creator_channel_community_detail_post"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_feed_card"
android:orientation="vertical"
android:padding="@dimen/spacing_14">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_creator_channel_community_detail_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/a11y_feed_profile_image"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/bg_placeholder" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_nickname"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@id/layout_creator_channel_community_detail_price"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_community_detail_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_detail_profile"
tools:text="크리에이터" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_community_detail_nickname"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_community_detail_nickname"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_community_detail_nickname"
tools:text="2분 전" />
<LinearLayout
android:id="@+id/layout_creator_channel_community_detail_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_channel_community_price"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_4"
android:paddingVertical="2dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_detail_profile"
tools:visibility="visible">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_bar_cash" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_price"
style="@style/Typography.Caption3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:includeFontPadding="false"
android:textColor="@color/black"
tools:text="300" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tv_creator_channel_community_detail_body"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="게시글 본문" />
<FrameLayout
android:id="@+id/layout_creator_channel_community_detail_image_container"
android:layout_width="match_parent"
android:layout_height="236dp"
android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_feed_community_image"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:id="@+id/iv_creator_channel_community_detail_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/a11y_feed_content_image"
android:scaleType="centerCrop" />
<LinearLayout
android:id="@+id/layout_creator_channel_community_detail_paywall"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/gray_800"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_community_lock" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_paywall_price"
style="@style/Typography.Body3"
android:layout_width="70dp"
android:layout_height="36dp"
android:layout_marginTop="@dimen/spacing_4"
android:background="@drawable/bg_creator_channel_community_price"
android:drawableStart="@drawable/ic_bar_cash"
android:drawablePadding="@dimen/spacing_6"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/black"
tools:text="30" />
</LinearLayout>
</FrameLayout>
<TextView
android:id="@+id/tv_creator_channel_community_detail_audio"
style="@style/Typography.Body6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:background="@drawable/bg_creator_channel_fantalk_reply"
android:drawableStart="@drawable/ic_new_player_play"
android:drawablePadding="@dimen/spacing_8"
android:includeFontPadding="false"
android:padding="@dimen/spacing_12"
android:textColor="@color/white"
android:visibility="gone"
tools:text="Audio"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_purchased"
style="@style/Typography.Caption2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_community_price"
android:includeFontPadding="false"
android:paddingHorizontal="@dimen/spacing_8"
android:paddingVertical="@dimen/spacing_4"
android:text="@string/creator_channel_community_purchased_badge"
android:textColor="@color/black"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_marginTop="@dimen/spacing_16"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_creator_channel_community_detail_comment"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_feed_community_reply" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_comment_count"
style="@style/Typography.Body3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_4"
android:includeFontPadding="false"
android:textColor="@color/gray_400"
tools:text="5" />
<ImageView
android:id="@+id/iv_creator_channel_community_detail_heart"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="15dp"
android:contentDescription="@null"
android:src="@drawable/ic_feed_community_heart" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_like_count"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_4"
android:layout_weight="1"
android:includeFontPadding="false"
android:textColor="@color/gray_400"
tools:text="6" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_creator_channel_community_detail_comment_header"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_14"
android:includeFontPadding="false"
android:text="@string/creator_channel_community_comment_header"
android:textColor="@color/white"
android:visibility="gone"
tools:text="댓글 2"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_creator_channel_community_detail_comments"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:nestedScrollingEnabled="false"
tools:itemCount="2"
tools:listitem="@layout/item_creator_channel_community_comment" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<LinearLayout
android:id="@+id/layout_creator_channel_community_detail_input_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/black"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingVertical="10dp"
app:layout_constraintBottom_toBottomOf="parent">
<EditText
android:id="@+id/et_creator_channel_community_detail_comment"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_creator_channel_community_input_field"
android:hint="@string/creator_channel_community_comment_placeholder"
android:importantForAutofill="no"
android:inputType="textMultiLine|textCapSentences"
android:maxLines="3"
android:minHeight="40dp"
android:minLines="1"
android:paddingHorizontal="@dimen/spacing_14"
android:scrollbars="vertical"
android:textColor="@color/white"
android:textColorHint="@color/gray_500"
tools:ignore="LabelFor" />
<ImageButton
android:id="@+id/btn_creator_channel_community_detail_send"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_community_send_disabled"
android:contentDescription="@string/action_send"
android:enabled="false"
android:src="@drawable/ic_new_arrow_up_gray"
tools:background="@drawable/bg_creator_channel_community_send_enabled" />
<ImageButton
android:id="@+id/btn_creator_channel_community_detail_edit_cancel"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@android:color/transparent"
android:contentDescription="@string/cancel"
android:src="@drawable/ic_x_white"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_creator_channel_community_reply_title_bar"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/btn_creator_channel_community_reply_back"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@android:color/transparent"
android:contentDescription="@string/a11y_back"
android:src="@drawable/ic_new_bar_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:orientation="vertical"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintBottom_toTopOf="@id/layout_creator_channel_community_reply_input_bar"
app:layout_constraintTop_toBottomOf="@id/layout_creator_channel_community_reply_title_bar">
<include
android:id="@+id/layout_creator_channel_community_reply_parent"
layout="@layout/item_creator_channel_community_comment"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_creator_channel_community_reply"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingTop="@dimen/spacing_14"
tools:itemCount="2"
tools:listitem="@layout/item_creator_channel_community_reply" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_creator_channel_community_reply_input_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/black"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingVertical="10dp"
app:layout_constraintBottom_toBottomOf="parent">
<EditText
android:id="@+id/et_creator_channel_community_reply"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_creator_channel_community_input_field"
android:hint="@string/creator_channel_community_reply_placeholder"
android:importantForAutofill="no"
android:inputType="textMultiLine|textCapSentences"
android:maxLines="3"
android:minHeight="40dp"
android:minLines="1"
android:paddingHorizontal="@dimen/spacing_14"
android:scrollbars="vertical"
android:textColor="@color/white"
android:textColorHint="@color/gray_500"
tools:ignore="LabelFor" />
<ImageButton
android:id="@+id/btn_creator_channel_community_reply_send"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_community_send_disabled"
android:contentDescription="@string/action_send"
android:enabled="false"
android:src="@drawable/ic_new_arrow_up_gray" />
<ImageButton
android:id="@+id/btn_creator_channel_community_reply_edit_cancel"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@android:color/transparent"
android:contentDescription="@string/cancel"
android:src="@drawable/ic_x_white"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,249 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_creator_channel_fantalk_detail_title_bar"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/btn_creator_channel_fantalk_detail_back"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@android:color/transparent"
android:contentDescription="@string/a11y_back"
android:src="@drawable/ic_new_bar_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_creator_channel_fantalk_detail"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintBottom_toTopOf="@id/layout_creator_channel_fantalk_detail_input_bar"
app:layout_constraintTop_toBottomOf="@id/layout_creator_channel_fantalk_detail_title_bar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_14">
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/a11y_feed_profile_image"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_placeholder_profile" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_nickname"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_14"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@id/iv_creator_channel_fantalk_detail_more"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_fantalk_detail_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_fantalk_detail_profile"
tools:text="닉네임" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_4"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_fantalk_detail_nickname"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_nickname"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_fantalk_detail_nickname"
tools:text="2분 전" />
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_more"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_more"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tv_creator_channel_fantalk_detail_nickname"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_content"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:includeFontPadding="false"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_nickname"
app:layout_constraintTop_toBottomOf="@id/iv_creator_channel_fantalk_detail_profile"
tools:text="팬톡 응원 메시지 내용입니다." />
<View
android:id="@+id/view_creator_channel_fantalk_detail_reply_connector"
android:layout_width="1dp"
android:layout_height="@dimen/spacing_14"
android:layout_marginTop="@dimen/spacing_14"
android:background="@color/gray_800"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_content"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_fantalk_detail_content"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/layout_creator_channel_fantalk_detail_reply"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_fantalk_reply"
android:orientation="vertical"
android:padding="@dimen/spacing_14"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_content"
app:layout_constraintTop_toBottomOf="@id/view_creator_channel_fantalk_detail_reply_connector"
tools:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_reply_profile"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/a11y_feed_profile_image"
android:scaleType="centerCrop"
tools:src="@drawable/ic_placeholder_profile" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_reply_nickname"
style="@style/Typography.Caption3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
tools:text="크리에이터" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_reply_time"
style="@style/Typography.Caption3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_4"
android:includeFontPadding="false"
android:textColor="@color/gray_400"
tools:text="방금 전" />
<View
android:id="@+id/view_creator_channel_fantalk_detail_reply_header_spacer"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_reply_more"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="@dimen/spacing_8"
android:contentDescription="@null"
android:src="@drawable/ic_new_more" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_reply_content"
style="@style/Typography.Body6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="응원 고마워요!" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
<LinearLayout
android:id="@+id/layout_creator_channel_fantalk_detail_input_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/black"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingVertical="10dp"
app:layout_constraintBottom_toBottomOf="parent">
<EditText
android:id="@+id/et_creator_channel_fantalk_detail_reply"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_creator_channel_community_input_field"
android:hint="@string/creator_channel_community_reply_placeholder"
android:importantForAutofill="no"
android:inputType="textMultiLine|textCapSentences"
android:maxLines="3"
android:minHeight="40dp"
android:minLines="1"
android:paddingHorizontal="@dimen/spacing_14"
android:scrollbars="vertical"
android:textColor="@color/white"
android:textColorHint="@color/gray_500"
tools:ignore="LabelFor" />
<ImageButton
android:id="@+id/btn_creator_channel_fantalk_detail_send"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_community_send_disabled"
android:contentDescription="@string/action_send"
android:enabled="false"
android:src="@drawable/ic_new_arrow_up_gray" />
<ImageButton
android:id="@+id/btn_creator_channel_fantalk_detail_edit_cancel"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@android:color/transparent"
android:contentDescription="@string/cancel"
android:src="@drawable/ic_x_white"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<TextView
android:id="@+id/tv_creator_channel_fantalk_write_cancel"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="60dp"
android:layout_marginStart="@dimen/spacing_14"
android:gravity="center"
android:includeFontPadding="false"
android:text="@string/creator_channel_fantalk_write_cancel"
android:textColor="@color/white"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_write_send"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginEnd="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_fantalk_write_send_disabled"
android:enabled="false"
android:gravity="center"
android:includeFontPadding="false"
android:minWidth="68dp"
android:paddingHorizontal="@dimen/spacing_14"
android:text="@string/creator_channel_fantalk_write_send"
android:textColor="@color/gray_500"
app:layout_constraintBottom_toBottomOf="@id/tv_creator_channel_fantalk_write_cancel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tv_creator_channel_fantalk_write_cancel" />
<EditText
android:id="@+id/et_creator_channel_fantalk_write_content"
style="@style/Typography.Body2"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="@dimen/spacing_14"
android:layout_marginTop="@dimen/spacing_20"
android:background="@android:color/transparent"
android:gravity="top|start"
android:hint="@string/creator_channel_fantalk_write_hint"
android:imeOptions="actionDone"
android:includeFontPadding="false"
android:inputType="textMultiLine|textCapSentences"
android:maxLength="500"
android:minHeight="180dp"
android:padding="0dp"
android:textColor="@color/white"
android:textColorHint="@color/gray_500"
app:layout_constraintBottom_toTopOf="@id/layout_creator_channel_fantalk_write_info_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_fantalk_write_cancel"
tools:text="크리에이터를 향한 응원 메시지를 입력합니다." />
<LinearLayout
android:id="@+id/layout_creator_channel_fantalk_write_info_bar"
android:layout_width="0dp"
android:layout_height="52dp"
android:background="@color/black"
android:gravity="center_vertical|end"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<TextView
android:id="@+id/tv_creator_channel_fantalk_write_count"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="23" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_write_count_limit"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="/500자"
android:textColor="@color/gray_500" />
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_700"
app:layout_constraintBottom_toTopOf="@id/layout_creator_channel_fantalk_write_info_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_v2_modal"
android:orientation="vertical">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/bold"
android:gravity="center"
android:minHeight="64dp"
android:paddingHorizontal="@dimen/spacing_20"
android:textColor="@color/white"
android:textSize="20sp"
tools:text="모달" />
<TextView
android:id="@+id/tv_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/regular"
android:gravity="center"
android:lineSpacingMultiplier="1.45"
android:padding="@dimen/spacing_20"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="모달 안내 문구가\n들어가는 부분입니다." />
<LinearLayout
android:id="@+id/layout_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_20"
android:paddingVertical="@dimen/spacing_14"
tools:ignore="UseCompoundDrawables">
<TextView
android:id="@+id/tv_cancel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_8"
android:layout_weight="1"
android:fontFamily="@font/medium"
android:gravity="center"
android:padding="@dimen/spacing_14"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="취소" />
<TextView
android:id="@+id/tv_confirm"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="@font/medium"
android:gravity="center"
android:padding="@dimen/spacing_14"
android:textColor="@color/soda_400"
android:textSize="18sp"
tools:text="확인" />
</LinearLayout>
</LinearLayout>

View File

@@ -151,7 +151,9 @@
android:background="@drawable/bg_creator_channel_fantalk_write_button"
android:contentDescription="@string/creator_channel_fantalk_support_action"
android:padding="@dimen/spacing_16"
android:src="@drawable/ic_new_fantalk_plus"
android:src="@drawable/ic_plus_no_bg"
android:visibility="gone"
app:tint="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -226,28 +226,6 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_content_latest_audio_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_48"
android:orientation="vertical">
<include
android:id="@+id/view_content_latest_audio_title"
layout="@layout/view_section_title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_content_latest_audios"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_14"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_content_new_and_hot_section"
android:layout_width="match_parent"
@@ -270,6 +248,28 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_content_latest_audio_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_48"
android:orientation="vertical">
<include
android:id="@+id/view_content_latest_audio_title"
layout="@layout/view_section_title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_content_latest_audios"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_14"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_content_free_audio_section"
android:layout_width="match_parent"

View File

@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="310dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:background="@drawable/bg_feed_card"
android:orientation="vertical"
android:padding="@dimen/spacing_14">
<LinearLayout
android:layout_width="match_parent"
@@ -46,8 +48,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:background="@drawable/bg_content_commented_audio_comment"
android:gravity="center_vertical"
android:orientation="horizontal">
android:orientation="horizontal"
android:padding="@dimen/spacing_12">
<ImageView
android:id="@+id/iv_content_comment_profile"
@@ -66,8 +70,9 @@
android:layout_weight="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:lineSpacingMultiplier="1.45"
android:maxLines="2"
android:textColor="@color/gray_500"
android:textColor="@color/white"
tools:text="최근 댓글 내용" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="@dimen/spacing_14">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_creator_channel_community_comment_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/a11y_profile_image"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/bg_placeholder" />
<TextView
android:id="@+id/tv_creator_channel_community_comment_nickname"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@id/iv_creator_channel_community_comment_more"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_community_comment_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_comment_profile"
tools:text="닉네임" />
<TextView
android:id="@+id/tv_creator_channel_community_comment_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_community_comment_nickname"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_community_comment_nickname"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_community_comment_nickname"
tools:text="2분 전" />
<ImageView
android:id="@+id/iv_creator_channel_community_comment_more"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_more"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tv_creator_channel_community_comment_body"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="50dp"
android:layout_marginTop="@dimen/spacing_8"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="댓글 내용" />
<LinearLayout
android:id="@+id/layout_creator_channel_community_comment_latest_reply"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="50dp"
android:layout_marginTop="@dimen/spacing_12"
android:background="@drawable/bg_creator_channel_fantalk_reply"
android:orientation="vertical"
android:padding="@dimen/spacing_14"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/tv_creator_channel_community_comment_latest_reply_nickname"
style="@style/Typography.Body5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="답글 닉네임" />
<TextView
android:id="@+id/tv_creator_channel_community_comment_latest_reply_body"
style="@style/Typography.Body6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_6"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="최신 답글" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/spacing_14"
android:background="@color/gray_800" />
</LinearLayout>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginBottom="@dimen/spacing_12"
android:background="@drawable/bg_creator_channel_fantalk_reply"
android:orientation="vertical"
android:padding="@dimen/spacing_14">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_creator_channel_community_reply_item_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/a11y_profile_image"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/bg_placeholder" />
<TextView
android:id="@+id/tv_creator_channel_community_reply_item_nickname"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@id/iv_creator_channel_community_reply_item_more"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_community_reply_item_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_reply_item_profile"
tools:text="닉네임" />
<TextView
android:id="@+id/tv_creator_channel_community_reply_item_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_community_reply_item_nickname"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_community_reply_item_nickname"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_community_reply_item_nickname"
tools:text="2분 전" />
<ImageView
android:id="@+id/iv_creator_channel_community_reply_item_more"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_more"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tv_creator_channel_community_reply_item_body"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="답글 내용" />
</LinearLayout>

View File

@@ -67,21 +67,14 @@
android:id="@+id/tv_fantalk_content"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_14"
android:layout_weight="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="2"
android:maxLines="1"
android:textColor="@color/white"
tools:text="팬이 쓴 응원이 보이는 부분입니다. 두 줄까지만 보여주고 초과 시 말줄임 처리." />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="@dimen/spacing_14"
android:contentDescription="@null"
android:src="@drawable/ic_chevron_down_white" />
</LinearLayout>
<FrameLayout

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingHorizontal="16dp">
<TextView
android:id="@+id/tv_home_following_creator_all"
style="@style/Typography.Body5"
android:layout_width="wrap_content"
android:layout_height="75dp"
android:gravity="center"
android:includeFontPadding="false"
android:text="@string/screen_home_theme_all"
android:textColor="@color/soda_400" />
<TextView
style="@style/Typography.Body5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_6"
android:includeFontPadding="false"
android:maxLines="1"
android:text="@string/screen_home_theme_all"
android:visibility="invisible" />
</LinearLayout>

View File

@@ -1,38 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="244dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_feed_card"
android:orientation="vertical"
android:padding="@dimen/spacing_14">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="120dp"
android:background="@drawable/bg_audio_content_card_thumbnail">
android:layout_width="263dp"
android:layout_height="100dp"
android:background="@drawable/bg_home_following_live_capsule"
android:paddingStart="12dp"
android:paddingEnd="22dp">
<ImageView
android:id="@+id/iv_home_following_live_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/iv_home_following_live_creator_profile"
android:layout_width="75dp"
android:layout_height="75dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_placeholder_profile" />
<LinearLayout
android:id="@+id/layout_home_following_live_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/iv_home_following_live_creator_profile"
app:layout_constraintTop_toTopOf="@id/iv_home_following_live_creator_profile"
tools:ignore="UseCompoundDrawables">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="18dp"
android:layout_gravity="top|start"
android:layout_margin="@dimen/spacing_8"
android:background="@drawable/bg_live_thumbnail_badge_capsule"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_4">
android:paddingHorizontal="@dimen/spacing_4"
android:paddingVertical="2dp">
<View
android:layout_width="@dimen/spacing_8"
android:layout_height="@dimen/spacing_8"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/bg_live_thumbnail_dot" />
<TextView
@@ -40,53 +50,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_4"
android:includeFontPadding="true"
android:includeFontPadding="false"
android:text="LIVE"
android:textColor="@color/white"
tools:ignore="HardcodedText,SmallSp" />
tools:ignore="HardcodedText" />
</LinearLayout>
</FrameLayout>
<TextView
android:id="@+id/tv_home_following_live_title"
style="@style/Typography.Heading4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
tools:text="라이브 제목" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_home_following_live_creator_profile"
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/bg_round_corner_999_263238"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_placeholder_profile" />
<TextView
android:id="@+id/tv_home_following_live_creator_nickname"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_300"
tools:text="크리에이터 이름" />
<TextView
android:id="@+id/tv_home_following_live_started_at"
@@ -96,6 +64,36 @@
android:layout_marginStart="@dimen/spacing_8"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
tools:text="2분 전" />
</LinearLayout>
tools:text="00:00" />
</LinearLayout>
<TextView
android:id="@+id/tv_home_following_live_title"
style="@style/Typography.Heading4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_4"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="@id/layout_home_following_live_status"
app:layout_constraintStart_toStartOf="@id/layout_home_following_live_status"
app:layout_constraintTop_toBottomOf="@id/layout_home_following_live_status"
tools:text="라이브 제목" />
<TextView
android:id="@+id/tv_home_following_live_creator_nickname"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_4"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_home_following_live_title"
app:layout_constraintStart_toStartOf="@id/tv_home_following_live_title"
app:layout_constraintTop_toBottomOf="@id/tv_home_following_live_title"
tools:text="크리에이터이름" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_channel_sort_popup"
android:orientation="vertical"
android:paddingHorizontal="@dimen/spacing_12"
android:paddingVertical="@dimen/spacing_8">
<TextView
android:id="@+id/tv_creator_channel_community_more_edit"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/regular"
android:includeFontPadding="false"
android:paddingHorizontal="@dimen/spacing_12"
android:paddingVertical="@dimen/spacing_8"
android:text="@string/creator_channel_fantalk_edit"
android:textColor="@color/white"
tools:ignore="UnusedAttribute" />
<TextView
android:id="@+id/tv_creator_channel_community_more_delete"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/regular"
android:includeFontPadding="false"
android:paddingHorizontal="@dimen/spacing_12"
android:paddingVertical="@dimen/spacing_8"
android:text="@string/creator_channel_fantalk_delete"
android:textColor="@color/white"
tools:ignore="UnusedAttribute" />
</LinearLayout>

View File

@@ -5,20 +5,19 @@
android:layout_width="374dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_feed_card"
android:clipToOutline="true"
android:padding="@dimen/spacing_14">
<FrameLayout
android:id="@+id/fl_feed_content_image_container"
android:layout_width="163dp"
android:layout_height="wrap_content"
android:layout_width="88dp"
android:layout_height="88dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/iv_feed_content_image"
android:layout_width="163dp"
android:layout_height="163dp"
android:layout_width="88dp"
android:layout_height="88dp"
android:contentDescription="@string/a11y_feed_content_image"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />

View File

@@ -4,11 +4,11 @@
<item
android:id="@+id/menu_review_modify"
android:title="@string/menu_modify_action"
android:title="@string/creator_channel_fantalk_edit"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_review_delete"
android:title="@string/menu_delete_action"
android:title="@string/creator_channel_fantalk_delete"
app:showAsAction="ifRoom" />
</menu>

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