diff --git a/app/src/main/java/com/flint/android/core/designsystem/theme/Color.kt b/app/src/main/java/com/flint/android/core/designsystem/theme/Color.kt index b628e792..3a6a010c 100644 --- a/app/src/main/java/com/flint/android/core/designsystem/theme/Color.kt +++ b/app/src/main/java/com/flint/android/core/designsystem/theme/Color.kt @@ -21,6 +21,26 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +/** + * 투명 -> 불투명으로 잦아드는 그라데이션의 색 목록. [Brush.verticalGradient] 는 색을 균등 간격으로 배치한다. + * + * 알파를 선형으로 올리면 그라데이션이 시작되는 지점에서 기울기가 0에서 일정값으로 한 번에 꺾여 + * 가로줄처럼 보인다. 시작 기울기가 0인 2차 곡선(alpha = t*t)을 쓰면 시작점이 눈에 띄지 않는다. + * + * 끝에서는 [FADE_OPAQUE_AT] 지점에 미리 불투명해지도록 해서, 덮어야 할 경계에 원본이 남지 않게 한다. + * 마지막까지 알파를 올리면 경계 직전에 원본이 몇 % 남아 오히려 잘린 자국이 보인다. + */ +private const val FADE_OPAQUE_AT = 11f / 12f + +private fun easedFadeColors( + color: Color, + steps: Int = 12, +): List = + List(steps + 1) { index -> + val t = (index.toFloat() / steps / FADE_OPAQUE_AT).coerceAtMost(1f) + color.copy(alpha = t * t) + } + @Immutable data class Colors( val primary50: Color, @@ -79,7 +99,7 @@ data class Colors( val blueGradient: Brush, val primary400Gradient: Brush, val grayGradient: Brush, - val gray800Gradient: Brush, + val cardShadeGradient: Brush, val navbarGradient: Brush, val thumbnailGradient: Brush, val userBadgeGradient: Brush, @@ -180,22 +200,28 @@ val FlintColors = Brush.verticalGradient( colors = listOf(Color(0xFF424BBD).copy(1f), Color(0xFF121212).copy(alpha = 0.04f)), ), - blueGradient = - Brush.verticalGradient( - colors = listOf(Color(0xFF062845).copy(alpha = 0f), Color(0xFF062845).copy(1f)), - ), - primary400Gradient = - Brush.verticalGradient( - colors = listOf(Color(0xFF1ABFF2).copy(0f), Color(0xFF1ABFF2).copy(0.35f)), - ), - grayGradient = - Brush.verticalGradient( - colors = listOf(Color(0xFF21242C).copy(alpha = 0f), Color(0xFF21242C).copy(alpha = 1f)), - ), - gray800Gradient = - Brush.verticalGradient( - colors = listOf(Color(0xFF21242C).copy(alpha = 0f), Color(0xFF21242C).copy(alpha = 0.35f)), - ), + blueGradient = Brush.verticalGradient(colors = easedFadeColors(Color(0xFF062845))), + primary400Gradient = object : ShaderBrush() { + override fun createShader(size: Size): Shader { + return LinearGradientShader( + from = Offset(size.width * 0.5f, 0f), + to = Offset(size.width * 0.5f, size.height * 1.15f), + colors = listOf(Color(0xFF1ABFF2).copy(alpha = 0f), Color(0xFF1ABFF2).copy(alpha = 0.35f)), + colorStops = listOf(0.26f, 1f), + ) + } + }, + grayGradient = Brush.verticalGradient(colors = easedFadeColors(Color(0xFF21242C))), + cardShadeGradient = object : ShaderBrush() { + override fun createShader(size: Size): Shader { + return LinearGradientShader( + from = Offset(size.width * 0.5f, 0f), + to = Offset(size.width * 0.5f, size.height * 1.15f), + colors = listOf(Color(0xFF2D4254).copy(alpha = 0f), Color(0xFF2D4254)), + colorStops = listOf(0.26f, 1f), + ) + } + }, navbarGradient = Brush.verticalGradient( colors = listOf(Color(0xFF121212).copy(alpha = 1f), Color(0xFF121212).copy(alpha = 0f)), diff --git a/app/src/main/java/com/flint/android/data/analytics/AmplitudeAnalyticsTracker.kt b/app/src/main/java/com/flint/android/data/analytics/AmplitudeAnalyticsTracker.kt index 7da1b561..dfbc4baa 100644 --- a/app/src/main/java/com/flint/android/data/analytics/AmplitudeAnalyticsTracker.kt +++ b/app/src/main/java/com/flint/android/data/analytics/AmplitudeAnalyticsTracker.kt @@ -3,6 +3,7 @@ package com.flint.android.data.analytics import android.content.Context import com.amplitude.android.Amplitude import com.amplitude.android.Configuration +import com.amplitude.core.events.BaseEvent import com.flint.android.BuildConfig import com.flint.android.core.analytics.AnalyticsTracker import com.flint.android.core.analytics.FlintEvent @@ -24,6 +25,7 @@ class AmplitudeAnalyticsTracker( Configuration( apiKey = apiKey, context = context, + callback = ::logUploadResult, ), ) @@ -47,8 +49,28 @@ class AmplitudeAnalyticsTracker( } amplitude.reset() } + + /** + * 업로드 결과를 남긴다. + * + * SDK 는 서버가 재시도 불가 응답(400 등)을 주면 이벤트를 버리고 로컬 큐에서도 지운다. + * 그래서 큐가 비어 있다는 것만으로는 전송 성공을 알 수 없어, 실패를 여기서 드러낸다. + */ + private fun logUploadResult( + event: BaseEvent, + status: Int, + message: String, + ) { + if (status == HTTP_OK) { + if (BuildConfig.DEBUG) Timber.tag(ANALYTICS_TAG).d("uploaded %s", event.eventType) + } else { + Timber.tag(ANALYTICS_TAG).w("upload failed %s status=%d %s", event.eventType, status, message) + } + } } +private const val HTTP_OK = 200 + /** * 전송 없이 로그만 남기는 [AnalyticsTracker]. * diff --git a/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailScreen.kt b/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailScreen.kt index fd628095..65a48c32 100644 --- a/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailScreen.kt @@ -2,6 +2,7 @@ package com.flint.android.presentation.collectiondetail import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -287,7 +289,7 @@ fun CollectionDetailScreen( val targetIndex: Int = contents.indexOfFirst { it.imageUrl == targetImageUrl } if (targetIndex == -1) return@LaunchedEffect - lazyListState.animateScrollToItem(CONTENT_LIST_HEADER_ITEM_COUNT + targetIndex) + lazyListState.animateScrollToCenteredItem(CONTENT_LIST_HEADER_ITEM_COUNT + targetIndex) } if (showPeopleBottomSheet) { @@ -384,6 +386,26 @@ fun CollectionDetailScreen( } } +/** + * [index] 아이템이 뷰포트 중앙에 오도록 스크롤한다. + * + * 아이템 높이는 실제로 배치되기 전에는 알 수 없으므로, 먼저 상단 정렬로 스크롤해 + * 아이템을 화면에 올린 뒤 중앙까지 남은 거리만큼 추가로 이동한다. + * 아이템이 뷰포트보다 크면 중앙에 맞출 때 윗부분이 잘려 나가므로 상단 정렬을 유지한다. + */ +private suspend fun LazyListState.animateScrollToCenteredItem(index: Int) { + animateScrollToItem(index) + + val item: LazyListItemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return + val viewportStart: Int = layoutInfo.viewportStartOffset + val viewportEnd: Int = layoutInfo.viewportEndOffset + if (item.size >= viewportEnd - viewportStart) return + + val itemCenter: Int = item.offset + item.size / 2 + val viewportCenter: Int = (viewportStart + viewportEnd) / 2 + animateScrollBy((itemCenter - viewportCenter).toFloat()) +} + private data class ScreenPreviewData( val thumbnailUrl: String, val title: String, diff --git a/app/src/main/java/com/flint/android/presentation/home/component/RecommendCollectionCard.kt b/app/src/main/java/com/flint/android/presentation/home/component/RecommendCollectionCard.kt index cce6a67d..347c2608 100644 --- a/app/src/main/java/com/flint/android/presentation/home/component/RecommendCollectionCard.kt +++ b/app/src/main/java/com/flint/android/presentation/home/component/RecommendCollectionCard.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -30,6 +31,20 @@ import com.flint.android.core.designsystem.component.image.ProfileImage import com.flint.android.core.designsystem.theme.FlintTheme import com.flint.android.domain.model.collection.CollectionItemModel +private val THUMBNAIL_HEIGHT = 202.dp + +/** + * 썸네일을 카드 배경으로 잦아들게 하는 그라데이션의 시작 지점. + * 끝은 항상 썸네일 하단이라, 여기서 [THUMBNAIL_HEIGHT] 를 빼면 그라데이션 높이가 된다. + */ +private val MID_GRADIENT_TOP = 42.dp +private val BADGE_HEIGHT = 32.dp + +/** 배지가 썸네일 위로 겹쳐 올라가는 높이. 배지는 178~210dp 구간에 놓인다. */ +private val BADGE_OVERLAP = 24.dp +private val BADGE_TO_TITLE_SPACING = 16.dp +private val BOTTOM_GRADIENT_HEIGHT = 42.dp + @Composable fun RecommendCollectionCard( item: CollectionItemModel, @@ -39,7 +54,7 @@ fun RecommendCollectionCard( ) { val backgroundColor = if (isCurrentPage) FlintTheme.colors.primary900 else FlintTheme.colors.gray800 val midGradient = if (isCurrentPage) FlintTheme.colors.blueGradient else FlintTheme.colors.grayGradient - val bottomGradient = if (isCurrentPage) FlintTheme.colors.primary400Gradient else FlintTheme.colors.gray800Gradient + val bottomGradient = if (isCurrentPage) FlintTheme.colors.primary400Gradient else FlintTheme.colors.cardShadeGradient Box( modifier = modifier @@ -55,49 +70,51 @@ fun RecommendCollectionCard( contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() - .height(202.dp), + .height(THUMBNAIL_HEIGHT), ) Box( modifier = Modifier - .padding(top = 70.dp) + .padding(top = MID_GRADIENT_TOP) .fillMaxWidth() - .height(132.dp) + .height(THUMBNAIL_HEIGHT - MID_GRADIENT_TOP) .background(midGradient), ) - Row( - modifier = Modifier - .padding(top = 52.dp) - .height(32.dp) - .clip(RoundedCornerShape(16.dp)) - .background(brush = FlintTheme.colors.userBadgeGradient) - .border(width = 0.5.dp, brush = FlintTheme.colors.userBadgeStroke, shape = RoundedCornerShape(16.dp)) - .padding(top = 4.dp, bottom = 4.dp, start = 6.dp, end = 8.dp) - .align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ProfileImage( - imageUrl = item.profileUrl, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - Text( - text = item.nickname, - style = FlintTheme.typography.caption1R12, - color = FlintTheme.colors.gray200, - maxLines = 1, - ) - } - + // 배지가 썸네일 하단에 걸치도록 상단 기준으로 배치한다. + // 하단 기준으로 두면 제목/소개글이 한 줄일 때 텍스트 블록 전체가 아래로 밀린다. Column( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(bottom = 35.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + .align(Alignment.TopCenter) + .padding(top = THUMBNAIL_HEIGHT - BADGE_OVERLAP), + horizontalAlignment = Alignment.CenterHorizontally, ) { + Row( + modifier = Modifier + .height(BADGE_HEIGHT) + .clip(RoundedCornerShape(16.dp)) + .background(brush = FlintTheme.colors.userBadgeGradient) + .border(width = 0.5.dp, brush = FlintTheme.colors.userBadgeStroke, shape = RoundedCornerShape(16.dp)) + .padding(top = 4.dp, bottom = 4.dp, start = 6.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ProfileImage( + imageUrl = item.profileUrl, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = item.nickname, + style = FlintTheme.typography.caption1R12, + color = FlintTheme.colors.gray200, + maxLines = 1, + ) + } + + Spacer(Modifier.height(BADGE_TO_TITLE_SPACING)) + Text( text = item.title, style = FlintTheme.typography.head3Sb18, @@ -109,6 +126,9 @@ fun RecommendCollectionCard( .fillMaxWidth() .padding(horizontal = 48.dp) ) + + Spacer(Modifier.height(4.dp)) + Text( text = item.description, style = FlintTheme.typography.caption1R12, @@ -125,7 +145,7 @@ fun RecommendCollectionCard( Box( modifier = Modifier .fillMaxWidth() - .height(34.dp) + .height(BOTTOM_GRADIENT_HEIGHT) .align(Alignment.BottomCenter) .background(bottomGradient) ) @@ -150,3 +170,41 @@ private fun RecommendCollectionCardPreview() { ) } } + +@Preview +@Composable +private fun RecommendCollectionCardInactivePreview() { + FlintTheme { + RecommendCollectionCard( + item = CollectionItemModel( + id = "3", + thumbnailUrl = null, + title = "사랑에 빠지기 10초 전", + description = "시간이 흘러도 빛이 바래지 않는,사랑의 미묘한 온도를 담은 제 최애 영화 모음집입니다", + nickname = "얀비", + profileUrl = null + ), + isCurrentPage = false, + onItemClick = {} + ) + } +} + +@Preview +@Composable +private fun RecommendCollectionCardSingleLinePreview() { + FlintTheme { + RecommendCollectionCard( + item = CollectionItemModel( + id = "2", + thumbnailUrl = null, + title = "사랑에 빠지기 10초 전", + description = "한 줄짜리 소개글입니다", + nickname = "얀비", + profileUrl = null + ), + isCurrentPage = true, + onItemClick = {} + ) + } +} diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/component/TermsMarkdown.kt b/app/src/main/java/com/flint/android/presentation/onboarding/component/TermsMarkdown.kt index 789da64d..df5b9fe3 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/component/TermsMarkdown.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/component/TermsMarkdown.kt @@ -26,10 +26,16 @@ private val NUMBERED_REGEX = Regex("""^(\d+\.)\s+(.*)$""") private val BULLET_REGEX = Regex("""^[-*•]\s+(.*)$""") private val BOLD_REGEX = Regex("""\*\*(.+?)\*\*""") +/** + * Notion 에서 내보낸 약관 본문에는 `