diff --git a/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt b/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt new file mode 100644 index 00000000..05b78667 --- /dev/null +++ b/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt @@ -0,0 +1,146 @@ +package com.gamss.android.app.share + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.graphics.Bitmap +import androidx.core.graphics.createBitmap +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.gamss.android.core.ui.share.StoryShareResult +import com.gamss.android.core.ui.share.shareBitmapToInstagramStory +import com.gamss.android.core.ui.share.shareTextToKakaoTalk +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CardShareInstrumentedTest { + + private val applicationContext = ApplicationProvider.getApplicationContext() + + @Test + fun 카카오톡_공유_인텐트에_패키지와_문구를_담는다() { + val context = RecordingContext(applicationContext) + + val shared = context.shareTextToKakaoTalk("카드 문구\nhttps://gamss.kr/cards/42") + + val intent = requireNotNull(context.startedIntent) + assertTrue(shared) + assertEquals(Intent.ACTION_SEND, intent.action) + assertEquals("text/plain", intent.type) + assertEquals("com.kakao.talk", intent.`package`) + assertEquals("카드 문구\nhttps://gamss.kr/cards/42", intent.getStringExtra(Intent.EXTRA_TEXT)) + } + + @Test + fun 카카오톡_공유가_보안_예외로_막히면_false를_반환한다() { + val context = ThrowingContext(applicationContext, SecurityException("test")) + + assertFalse(context.shareTextToKakaoTalk("공유 문구")) + } + + @Test + fun 카카오톡이_없으면_false를_반환한다() { + val context = ThrowingContext(applicationContext, ActivityNotFoundException("test")) + + assertFalse(context.shareTextToKakaoTalk("공유 문구")) + } + + @Test + fun 인스타그램_공유_인텐트에_FileProvider_Uri와_읽기_권한을_담는다() = runBlocking { + val context = RecordingContext(applicationContext) + val bitmap = createBitmap(width = 20, height = 30) + + val result = try { + context.shareBitmapToInstagramStory(bitmap) + } finally { + bitmap.recycle() + } + + val intent = requireNotNull(context.startedIntent) + assertEquals(StoryShareResult.Shared, result) + assertEquals("com.instagram.share.ADD_TO_STORY", intent.action) + assertEquals("com.instagram.android", intent.`package`) + assertEquals("image/png", intent.type) + assertEquals("content", intent.data?.scheme) + assertTrue(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + assertNotNull(applicationContext.contentResolver.openInputStream(requireNotNull(intent.data)).use { it?.read() }) + } + + @Test + fun 새_스토리_이미지를_만들기_전에_한시간_지난_캐시를_지운다() = runBlocking { + val shareDir = File(applicationContext.cacheDir, "shared_images").apply { mkdirs() } + val expired = File(shareDir, "story_share_expired.png").apply { + writeBytes(byteArrayOf(1)) + setLastModified(System.currentTimeMillis() - 2 * 60 * 60 * 1_000L) + } + val context = RecordingContext(applicationContext) + val bitmap: Bitmap = createBitmap(width = 20, height = 30) + + try { + assertEquals(StoryShareResult.Shared, context.shareBitmapToInstagramStory(bitmap)) + } finally { + bitmap.recycle() + } + + assertFalse(expired.exists()) + } + + @Test + fun 인스타그램이_없으면_InstagramUnavailable을_반환한다() = runBlocking { + val context = ThrowingContext(applicationContext, ActivityNotFoundException("test")) + val bitmap = createBitmap(width = 20, height = 30) + + val result = try { + context.shareBitmapToInstagramStory(bitmap) + } finally { + bitmap.recycle() + } + + assertEquals(StoryShareResult.InstagramUnavailable, result) + } + + @Test + fun 공유_캐시_디렉터리_자리에_파일이_있으면_ImageUnavailable을_반환한다() = runBlocking { + // shared_images 자리를 디렉터리가 아닌 일반 파일로 선점해, 캐시 쓰기가 실제로 + // 실패하는 경로(IOException)를 재현한다. + val shareDirPath = File(applicationContext.cacheDir, "shared_images") + shareDirPath.deleteRecursively() + check(shareDirPath.createNewFile()) { "테스트 전제 조건: $shareDirPath 를 파일로 선점하지 못했다" } + val context = RecordingContext(applicationContext) + val bitmap = createBitmap(width = 20, height = 30) + + val result = try { + context.shareBitmapToInstagramStory(bitmap) + } finally { + bitmap.recycle() + shareDirPath.delete() + } + + assertEquals(StoryShareResult.ImageUnavailable, result) + assertNull(context.startedIntent) + } + + private class RecordingContext(base: Context) : ContextWrapper(base) { + var startedIntent: Intent? = null + + override fun startActivity(intent: Intent) { + startedIntent = intent + } + } + + /** [startActivity] 를 호출하면 [exception] 을 던진다. 공유 대상 앱이 없거나 권한이 없는 상황을 흉내낸다. */ + private class ThrowingContext(base: Context, private val exception: RuntimeException) : ContextWrapper(base) { + override fun startActivity(intent: Intent) { + throw exception + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4dd1891..faf2d7ca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,8 @@ + + + + + + diff --git a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt index 5e022313..92e6a39c 100644 --- a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt +++ b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt @@ -9,9 +9,13 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope import com.gamss.android.app.navigation.GamssRootNavHost import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.ui.share.clearStaleInstagramStoryShareCache import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -43,4 +47,11 @@ class MainActivity : ComponentActivity() { } } } + + // 인스타그램에서 돌아와 화면이 다시 보일 때마다 지운다. 최초 실행 때도 onCreate 직후 + // onResume 이 불려서 앱 시작 시점 정리도 그대로 진행됨. + override fun onResume() { + super.onResume() + lifecycleScope.launch(Dispatchers.IO) { clearStaleInstagramStoryShareCache() } + } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..f4d005be --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt index e61698c2..ca46fc1c 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt @@ -29,12 +29,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -181,18 +183,6 @@ private fun GamssImageCardEmotionDarkPreview() { } } -/** 공유 줄은 아직 화면에서 꺼져 있어, 되살릴 때 쓸 배치를 프리뷰로만 남깁니다. */ -@Preview(name = "Emotion - 공유 켬", showBackground = true) -@Suppress("UnusedPrivateMember") -@Composable -private fun GamssImageCardEmotionShareVisiblePreview() { - GamssTheme(darkTheme = false) { - Box(modifier = Modifier.padding(GamssTheme.spacing.spacing300)) { - EmotionCardPreviewContent(isShareVisible = true) - } - } -} - /** 좁은 폭과 폰트 확대가 각각 세로 예산을 깎으므로 둘 다 둡니다. */ @Preview(name = "Emotion - 360dp", showBackground = true, widthDp = 360, heightDp = 520) @Preview(name = "Emotion - 320dp", showBackground = true, widthDp = 320, heightDp = 470) @@ -218,36 +208,56 @@ private fun GamssImageCardEmotionNarrowPreview() { EmotionCardPreviewContent( description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이 " + "머츠니가 도랴흐로 뎌슨하다", - isShareVisible = false, ) } } } +/** + * 카드 아래쪽 점선 밑에 들어가는 것. 시안이 상세 팝업과 공유 이미지 두 가지만 두므로 그대로 나눈다. + */ +sealed interface GamssEmotionCardFooter { + + /** + * 카드 상세에서 쓰는 버튼 두 개와 공유 행. + * + * 여섯 값이 늘 함께 쓰이고 함께 사라지므로 한 덩어리로 받는다. + */ + data class Actions( + val primaryLabel: String, + val secondaryLabel: String, + val shareLabel: String, + val onPrimaryClick: () -> Unit, + val onSecondaryClick: () -> Unit, + val onShareClick: () -> Unit, + ) : GamssEmotionCardFooter + + /** + * 공유 이미지에서 쓰는 GAMSS 워드마크. + * + * 그림에는 누를 수 없는 버튼 대신 브랜드를 남긴다. 이 상태를 넘기면 액션 문구와 콜백을 함께 + * 넘길 수 없으므로, 눌리지 않는 버튼이 그려지는 경우를 만들 수 없다. + */ + data object Brand : GamssEmotionCardFooter +} + /** * 감정 캐릭터와 대화 요약을 보여 주는 이미지 카드 퍼사드. * * 카드의 고정 구조와 감정별 캐릭터 선택은 이 컴포넌트가 맡고, 문구와 사용자 동작만 호출부가 제공한다. * 따라서 화면마다 [GamssImageCard]의 간격과 텍스트 스타일을 다시 조합할 필요가 없다. * - * @param isShareVisible 공유 줄을 보일지 정합니다. 끄면 자리까지 비웁니다. + * 아래쪽 점선까지는 두 시안이 같고, 그 밑에 무엇이 오는지만 [footer] 가 정한다. */ @Composable -@Suppress("LongParameterList") fun GamssEmotionCard( date: String, character: GamssEmotionCardCharacter, title: String, description: String, - primaryActionLabel: String, - secondaryActionLabel: String, - shareActionLabel: String, - onPrimaryActionClick: () -> Unit, - onSecondaryActionClick: () -> Unit, - onShareClick: () -> Unit, + footer: GamssEmotionCardFooter, modifier: Modifier = Modifier, shape: Shape = RectangleShape, - isShareVisible: Boolean = false, topEndAction: @Composable BoxScope.() -> Unit = {}, ) { GamssImageCard( @@ -277,50 +287,75 @@ fun GamssEmotionCard( Spacer(modifier = Modifier.height(DividerToContentGap * scale)) GamssCardDashedDivider() Spacer(modifier = Modifier.height(DividerToContentGap * scale)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100 * scale), - ) { - CardOutlinedButton( - text = primaryActionLabel, - onClick = onPrimaryActionClick, - scale = scale, - modifier = Modifier.weight(1f), - ) - CardOutlinedButton( - text = secondaryActionLabel, - onClick = onSecondaryActionClick, - scale = scale, - modifier = Modifier.weight(1f), - ) - } - if (isShareVisible) { - Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200 * scale)) - Row( - modifier = Modifier - .fillMaxWidth() - .noRippleClickable(onClick = onShareClick), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = shareActionLabel, - style = GamssTheme.typography.body5Medium, - color = GamssTheme.colors.gray600, - ) - Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing025 * scale)) - Icon( - painter = painterResource(GamssIcons.RightChevron), - contentDescription = null, - tint = GamssTheme.colors.gray600, - modifier = Modifier.size(GamssTheme.spacing.spacing300 * scale), - ) - } + when (footer) { + is GamssEmotionCardFooter.Actions -> CardActionsFooter(footer, scale) + GamssEmotionCardFooter.Brand -> CardBrandFooter() } } } } +/** 상세 팝업 아래쪽. 버튼 두 개와 그 밑 공유 행. */ +@Composable +private fun CardActionsFooter(actions: GamssEmotionCardFooter.Actions, scale: Float) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100 * scale), + ) { + CardOutlinedButton( + text = actions.primaryLabel, + onClick = actions.onPrimaryClick, + scale = scale, + modifier = Modifier.weight(1f), + ) + CardOutlinedButton( + text = actions.secondaryLabel, + onClick = actions.onSecondaryClick, + scale = scale, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200 * scale)) + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable(role = Role.Button, onClick = actions.onShareClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = actions.shareLabel, + style = GamssTheme.typography.body5Medium, + color = GamssTheme.colors.gray600, + ) + Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing025 * scale)) + Icon( + painter = painterResource(GamssIcons.RightChevron), + contentDescription = null, + tint = GamssTheme.colors.gray600, + modifier = Modifier.size(GamssTheme.spacing.spacing300 * scale), + ) + } +} + +/** + * 공유 이미지 아래쪽. 시안(Figma 4243:18230)대로 버튼이 있던 자리에 GAMSS 워드마크를 둔다. + * + * 카드 안은 [LightGamssColors] 로 고정이라 여기서 읽는 보라도 라이트 값으로 정해진다. + */ +@Composable +private fun ColumnScope.CardBrandFooter() { + Image( + painter = painterResource(R.drawable.ic_gamss_logo), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(GamssTheme.colors.purple), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(width = BrandLogoWidth, height = BrandLogoHeight), + ) +} + @Composable fun ColumnScope.GamssEmotionCardContent( character: GamssEmotionCardCharacter, @@ -401,20 +436,20 @@ fun GamssChattingCard( @Composable private fun EmotionCardPreviewContent( description: String = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", - isShareVisible: Boolean = false, ) { GamssEmotionCard( date = "26.08.03", character = GamssEmotionCardCharacter.ANGER, title = "오늘 화~나네", description = description, - primaryActionLabel = "기록 버리기", - secondaryActionLabel = "대화보기", - shareActionLabel = "공유하기", - onPrimaryActionClick = {}, - onSecondaryActionClick = {}, - onShareClick = {}, - isShareVisible = isShareVisible, + footer = GamssEmotionCardFooter.Actions( + primaryLabel = "기록 버리기", + secondaryLabel = "대화보기", + shareLabel = "공유하기", + onPrimaryClick = {}, + onSecondaryClick = {}, + onShareClick = {}, + ), topEndAction = { CloseIconPlaceholder() }, ) } @@ -528,6 +563,10 @@ private val CharacterImageHeight = 156.dp // 쓰고 위아래 22 씩 띄우며, 캐릭터 이미지와는 16 만 띄운다. private val CharacterToDividerGap = 16.dp private val DividerToContentGap = 22.dp + +/** 공유 이미지 워드마크 크기. 시안 94x28. */ +private val BrandLogoWidth = 94.dp +private val BrandLogoHeight = 28.dp private val CardDividerThickness = 1.3.dp private val CardDividerDashLength = 6.dp private val CardDividerDashGap = 4.dp diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Color.kt index 7b051286..52f17b79 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Color.kt @@ -24,6 +24,15 @@ internal val GrayLight800 = Color(0xFF474951) internal val GrayLight900 = Color(0xFF303136) internal val GrayLight950 = Color(0xFF1E1F22) +/** + * 카드를 이미지로 내보낼 때 카드 바깥 여백에 깔 색. + * + * `GamssImageCard` 가 카드 안쪽을 [LightGamssColors] 로 고정하므로 이 색도 테마에 따라 반전하면 + * 안 된다. `GamssTheme.colors.gray950` 을 쓰면 다크에서 밝은 색으로 뒤집혀 밝은 카드 뒤에 + * 밝은 배경이 깔린다. 그래서 라이트 톤을 그대로 노출한다. + */ +val GamssCardExportBackground: Color = GrayLight950 + // Gray - Dark internal val GrayDark025 = Color(0xFF1E1F22) internal val GrayDark050 = Color(0xFF24262B) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 310d765e..eb4e51a4 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -14,5 +14,8 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.compose.material.icons.core) + // 카드 캡처가 그리기 신호를 Channel 로 주고받는다. + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) } diff --git a/core/ui/src/main/java/com/gamss/android/core/ui/share/CardShareLink.kt b/core/ui/src/main/java/com/gamss/android/core/ui/share/CardShareLink.kt new file mode 100644 index 00000000..50368ab7 --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/share/CardShareLink.kt @@ -0,0 +1,15 @@ +package com.gamss.android.core.ui.share + +/** + * 카드 공유 링크. 주소는 이 파일에서만 바꾼다. 호출부에 URL 문자열을 직접 쓰지 않는다. + */ +object CardShareLink { + + /** + * 공유 링크가 가리키는 웹 랜딩. + */ + private const val LANDING_BASE_URL = "https://gamss.kr" + + /** [cardId] 카드를 여는 링크. */ + fun of(cardId: Long): String = "$LANDING_BASE_URL/cards/$cardId" +} diff --git a/core/ui/src/main/java/com/gamss/android/core/ui/share/ComposeCapture.kt b/core/ui/src/main/java/com/gamss/android/core/ui/share/ComposeCapture.kt new file mode 100644 index 00000000..1bdd03ee --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/share/ComposeCapture.kt @@ -0,0 +1,77 @@ +package com.gamss.android.core.ui.share + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeoutOrNull + +/** 그리기를 기다리다 포기하는 시간. 창이 가려져 아예 다시 그려지지 않는 경우를 끊는다. */ +private const val CAPTURE_TIMEOUT_MILLIS = 1_000L + +/** + * [Modifier.captureTo] 를 붙인 컴포저블을 비트맵으로 가져온다. + * + * 공유 전에 버튼을 감추는 것처럼 화면을 바꾼 뒤 캡처해야 할 때가 있는데, 바뀐 그림이 레이어에 + * 들어오는 시점은 프레임 수로 셀 수 없다. 그래서 그리기가 실제로 끝날 때마다 신호를 남기고 + * [captureWith] 가 그 신호를 기다린다. + */ +class ComposeCapture internal constructor(internal val layer: GraphicsLayer) { + + /** 그리기 신호. 최신 하나만 있으면 되므로 [Channel.CONFLATED] 를 쓴다. */ + private val draws = Channel(Channel.CONFLATED) + + internal fun onDrawn() { + draws.trySend(Unit) + } + + /** + * [setCapturing] 으로 캡처용 화면 전환을 켠 뒤, 그 전환이 실제로 그려진 다음의 그림을 준다. + * 상태를 켜고 끄는 순서를 이 함수가 스스로 지켜서, 호출부가 순서를 잘못 둬 이전 프레임이 + * 그대로 캡처될 일이 없다. 시간 초과나 예외로 끊겨도 [setCapturing] 은 항상 꺼진 채로 끝난다. + * + * [CAPTURE_TIMEOUT_MILLIS] 안에 한 번도 그려지지 않으면 `null`. + * 호출부는 다 쓴 비트맵을 직접 [Bitmap.recycle] 한다. + */ + suspend fun captureWith(setCapturing: (Boolean) -> Unit): Bitmap? { + setCapturing(true) + return try { + // 이 호출 전에 쌓인 신호는 바꾸기 전 그림이므로 버린다. + while (draws.tryReceive().isSuccess) Unit + val drew = withTimeoutOrNull(CAPTURE_TIMEOUT_MILLIS) { draws.receive() } != null + // toImageBitmap() 은 취소되면 만든 비트맵을 돌려줄 곳이 없어 새 버리므로, 그리기를 + // 기다리는 구간에만 시간 제한을 걸고 캡처 자체는 취소되지 않게 한다. + if (drew) layer.toImageBitmap().asAndroidBitmap() else null + } finally { + setCapturing(false) + } + } +} + +@Composable +fun rememberComposeCapture(): ComposeCapture { + val layer = rememberGraphicsLayer() + return remember(layer) { ComposeCapture(layer) } +} + +/** + * [isCapturing] 인 동안만 이 컴포저블을 [capture] 의 오프스크린 레이어에 기록해 캡처할 수 있게 + * 한다. 레이어 기록 자체가 그리기 비용이라, 캡처를 시도하지 않을 때는 화면에 바로 그려 여분의 + * 레이어를 물고 있지 않는다. + */ +fun Modifier.captureTo(capture: ComposeCapture, isCapturing: Boolean): Modifier = + drawWithContent { + if (isCapturing) { + capture.layer.record { this@drawWithContent.drawContent() } + drawLayer(capture.layer) + capture.onDrawn() + } else { + drawContent() + } + } diff --git a/core/ui/src/main/java/com/gamss/android/core/ui/share/InstagramStoryShare.kt b/core/ui/src/main/java/com/gamss/android/core/ui/share/InstagramStoryShare.kt new file mode 100644 index 00000000..b45c910a --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/share/InstagramStoryShare.kt @@ -0,0 +1,199 @@ +package com.gamss.android.core.ui.share + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.net.Uri +import android.util.Log +import androidx.compose.ui.graphics.toArgb +import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import com.gamss.android.core.designsystem.theme.GamssCardExportBackground +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +private const val TAG = "InstagramStoryShare" + +private const val INSTAGRAM_PACKAGE = "com.instagram.android" +private const val ADD_TO_STORY_ACTION = "com.instagram.share.ADD_TO_STORY" +private const val PNG_MIME_TYPE = "image/png" + +private const val PNG_QUALITY = 100 + +private const val SHARE_CACHE_DIR = "shared_images" +private const val SHARE_FILE_PREFIX = "story_share_" +private const val SHARE_FILE_SUFFIX = ".png" + +/** 지난 공유 이미지를 지우는 기준. 인스타그램이 URI 를 읽는 중일 수 있어 방금 것은 건드리지 않는다. */ +private const val SHARE_FILE_TTL_MILLIS = 60L * 60L * 1000L + +/** 공유 시도의 결과. 실패 이유마다 사용자에게 알릴 말이 다르므로 나눠서 돌려준다. */ +sealed interface StoryShareResult { + + /** 인스타그램 스토리 편집기로 넘어갔다. */ + data object Shared : StoryShareResult + + /** 인스타그램이 없거나 스토리 공유를 받지 못한다. */ + data object InstagramUnavailable : StoryShareResult + + /** 공유할 이미지를 만들지 못했다. 캡처 크기가 0 이거나 캐시에 쓰지 못한 경우. */ + data object ImageUnavailable : StoryShareResult +} + +/** + * [bitmap] 을 인스타그램 스토리로 공유한다. + * + * 인스타그램은 넘겨받은 배경 이미지를 9:16 캔버스에 꽉 채우므로, 카드를 그대로 보내면 비율이 + * 맞지 않아 잘리고 여백도 사라진다. 그래서 미리 9:16 캔버스 가운데에 카드를 놓고 남는 곳을 + * 공유용 카드 배경색으로 채운 이미지를 만들어 보낸다. + * + * [bitmap] 은 여기서 해제하지 않는다. 호출부가 계속 들고 있을 수 있으므로 소유권을 넘기지 않는다. + */ +suspend fun Context.shareBitmapToInstagramStory( + bitmap: Bitmap, +): StoryShareResult { + val uri = withContext(Dispatchers.IO) { + createStoryImageUri(bitmap) + } ?: return StoryShareResult.ImageUnavailable + + val intent = Intent(ADD_TO_STORY_ACTION).apply { + setDataAndType(uri, PNG_MIME_TYPE) + setPackage(INSTAGRAM_PACKAGE) + // 인텐트로 열리는 액티비티에 data URI 읽기를 허용한다. 별도 grantUriPermission 은 회수할 + // 시점이 없어 계속 남으므로 쓰지 않는다. + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return try { + startActivity(intent) + StoryShareResult.Shared + } catch (e: ActivityNotFoundException) { + Log.i(TAG, "인스타그램 스토리 공유를 받을 액티비티가 없다", e) + withContext(Dispatchers.IO) { contentResolver.delete(uri, null, null) } + StoryShareResult.InstagramUnavailable + } catch (e: SecurityException) { + Log.i(TAG, "인스타그램 스토리 공유 액티비티를 실행할 권한이 없다", e) + withContext(Dispatchers.IO) { contentResolver.delete(uri, null, null) } + StoryShareResult.InstagramUnavailable + } +} + +/** + * 지난 공유 이미지를 지운다. + * + * 화면이 다시 보일 때마다(앱 시작 포함) 이 함수를 불러 오래된 캐시가 다음 공유 전까지 + * 남아있지 않게 한다. + */ +fun Context.clearStaleInstagramStoryShareCache() { + val dir = File(cacheDir, SHARE_CACHE_DIR) + if (!dir.exists()) return + dir.deleteFilesOlderThan(SHARE_FILE_TTL_MILLIS) +} + +/** 공유용 9:16 이미지를 만들어 캐시에 쓰고 URI 를 준다. 만들지 못하면 `null`. */ +private fun Context.createStoryImageUri(bitmap: Bitmap): Uri? { + val story = bitmap.toStoryCanvas() ?: return null + return try { + story.saveToShareCache(this) + } finally { + // 파일로 쓴 뒤에는 8MB 남짓한 캔버스를 들고 있을 이유가 없다. + story.recycle() + } +} + +/** + * 9:16 캔버스 가운데에 원본 비율을 유지한 카드를 놓고 나머지를 [GamssCardExportBackground] 로 채운다. + * 카드 크기가 0 이거나 소프트웨어 비트맵으로 바꾸지 못하면 그릴 수 없어 `null`. + */ +private fun Bitmap.toStoryCanvas(): Bitmap? { + val bounds = storyCardBounds(width, height) ?: return null + + return toSoftwareBitmap()?.let { source -> + try { + val story = createBitmap(STORY_WIDTH, STORY_HEIGHT) + val canvas = Canvas(story) + canvas.drawColor(GamssCardExportBackground.toArgb()) + canvas.drawBitmap( + source, + null, + RectF(bounds.left, bounds.top, bounds.right, bounds.bottom), + Paint(Paint.FILTER_BITMAP_FLAG), + ) + story + } finally { + if (source !== this) source.recycle() + } + } +} + +/** + * 소프트웨어 [Canvas] 에 그릴 수 있는 비트맵으로 바꾼다. 변환에 실패하면(메모리 부족 등) `null`. + * + * Compose 의 GraphicsLayer 캡처는 [Bitmap.Config.HARDWARE] 비트맵을 주는데, 이건 GPU 메모리에만 + * 있어서 소프트웨어 캔버스에 그리면 예외가 난다. PNG 압축만 할 때는 문제가 없어 이 변환이 필요 없었다. + * [Bitmap.copy] 는 실패하면 원본을 되돌려주지 않고 `null` 을 주므로, 여기서도 그대로 `null` 을 + * 돌려준다. 원본(HARDWARE)을 그대로 반환하면 호출부가 소프트웨어 캔버스에 그리려다 죽는다. + */ +private fun Bitmap.toSoftwareBitmap(): Bitmap? = + if (config == Bitmap.Config.HARDWARE) { + copy(Bitmap.Config.ARGB_8888, false) + } else { + this + } + +/** + * 공유 이미지를 캐시에 쓰고 FileProvider URI 를 준다. + * + * 인스타그램은 [Context.startActivity] 가 돌아온 뒤 편집기가 열릴 때 URI 를 읽는다. 그래서 이름을 + * 고정하면 다음 공유가 아직 읽히는 중인 파일을 덮어써 깨진 그림이 넘어갈 수 있다. 매번 새 파일을 + * 만들고, 다 읽혔을 만한 지난 파일만 지운다. + */ +private fun Bitmap.saveToShareCache(context: Context): Uri? { + val dir = File(context.cacheDir, SHARE_CACHE_DIR) + if (!dir.exists() && !dir.mkdirs()) { + Log.i(TAG, "공유 캐시 디렉터리를 만들지 못했다: $dir") + return null + } + dir.deleteFilesOlderThan(SHARE_FILE_TTL_MILLIS) + + var file: File? = null + return try { + file = File.createTempFile(SHARE_FILE_PREFIX, SHARE_FILE_SUFFIX, dir) + val compressed = FileOutputStream(file).use { out -> + compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out) + } + check(compressed) { "공유 이미지를 PNG로 압축하지 못했다" } + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + } catch (e: IOException) { + Log.i(TAG, "공유 이미지를 캐시에 쓰지 못했다", e) + file?.delete() + null + } catch (e: IllegalArgumentException) { + Log.i(TAG, "공유 이미지의 FileProvider URI를 만들지 못했다", e) + file?.delete() + null + } catch (e: IllegalStateException) { + Log.i(TAG, e.message.orEmpty()) + file?.delete() + null + } catch (e: SecurityException) { + Log.i(TAG, "공유 이미지 캐시에 접근할 권한이 없다", e) + file?.delete() + null + } +} + +private fun File.deleteFilesOlderThan(ageMillis: Long) { + val threshold = System.currentTimeMillis() - ageMillis + listFiles { file -> file.name.startsWith(SHARE_FILE_PREFIX) }?.forEach { file -> + if (file.lastModified() < threshold && !file.delete()) { + Log.i(TAG, "지난 공유 이미지를 지우지 못했다: $file") + } + } +} diff --git a/core/ui/src/main/java/com/gamss/android/core/ui/share/KakaoTalkShare.kt b/core/ui/src/main/java/com/gamss/android/core/ui/share/KakaoTalkShare.kt new file mode 100644 index 00000000..4726cd46 --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/share/KakaoTalkShare.kt @@ -0,0 +1,38 @@ +package com.gamss.android.core.ui.share + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.util.Log + +private const val TAG = "KakaoTalkShare" + +private const val KAKAO_TALK_PACKAGE = "com.kakao.talk" +private const val PLAIN_TEXT_MIME_TYPE = "text/plain" + +/** + * [text] 를 카카오톡으로 보낸다. 카카오톡이 없으면 `false`. + * + * 카카오 SDK 대신 [Intent.ACTION_SEND] 를 카카오톡에 직접 지정한다. 보내는 것이 한 줄 문구와 + * 링크뿐이라 SDK·앱키·메시지 템플릿을 들일 이유가 없다. 링크 미리보기는 카카오톡이 링크의 OG + * 태그를 읽어 알아서 만든다. + * + * 받는 사람을 고르는 화면은 카카오톡이 띄운다. 이 함수는 거기까지만 책임진다. + */ +fun Context.shareTextToKakaoTalk(text: String): Boolean { + val intent = Intent(Intent.ACTION_SEND).apply { + type = PLAIN_TEXT_MIME_TYPE + setPackage(KAKAO_TALK_PACKAGE) + putExtra(Intent.EXTRA_TEXT, text) + } + return try { + startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + Log.i(TAG, "카카오톡 공유를 받을 액티비티가 없다", e) + false + } catch (e: SecurityException) { + Log.i(TAG, "카카오톡 공유 액티비티를 실행할 권한이 없다", e) + false + } +} diff --git a/core/ui/src/main/java/com/gamss/android/core/ui/share/StoryCanvas.kt b/core/ui/src/main/java/com/gamss/android/core/ui/share/StoryCanvas.kt new file mode 100644 index 00000000..442f9800 --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/share/StoryCanvas.kt @@ -0,0 +1,46 @@ +package com.gamss.android.core.ui.share + +/** 스토리 캔버스 크기(9:16). 인스타그램은 배경 이미지를 이 비율로 늘려 채운다. */ +internal const val STORY_WIDTH = 1080 +internal const val STORY_HEIGHT = 1920 + +/** 카드가 스토리 화면에 꽉 차지 않도록 남기는 좌우·상하 여백 비율. */ +private const val HORIZONTAL_MARGIN_RATIO = 0.16f +private const val VERTICAL_MARGIN_RATIO = 0.10f + +/** 스토리 캔버스 안에서 카드가 차지할 영역. */ +internal data class StoryBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) { + val width: Float get() = right - left + val height: Float get() = bottom - top +} + +/** + * [cardWidth] × [cardHeight] 카드를 스토리 캔버스 가운데에 놓을 영역을 구한다. + * + * 원본 비율을 유지하면서 여백 안에 들어가는 가장 큰 크기를 고르므로, 세로로 긴 카드는 높이가, + * 가로로 넓은 카드는 폭이 먼저 한계에 닿는다. 카드 크기가 0 이면(그려지기 전에 캡처된 경우) + * 놓을 자리를 정할 수 없어 `null`. + */ +internal fun storyCardBounds(cardWidth: Int, cardHeight: Int): StoryBounds? { + if (cardWidth <= 0 || cardHeight <= 0) return null + + val availableWidth = STORY_WIDTH * (1f - HORIZONTAL_MARGIN_RATIO * 2) + val availableHeight = STORY_HEIGHT * (1f - VERTICAL_MARGIN_RATIO * 2) + val scale = minOf(availableWidth / cardWidth, availableHeight / cardHeight) + val targetWidth = cardWidth * scale + val targetHeight = cardHeight * scale + val left = (STORY_WIDTH - targetWidth) / 2f + val top = (STORY_HEIGHT - targetHeight) / 2f + + return StoryBounds( + left = left, + top = top, + right = left + targetWidth, + bottom = top + targetHeight, + ) +} diff --git a/core/ui/src/test/java/com/gamss/android/core/ui/share/StoryCanvasTest.kt b/core/ui/src/test/java/com/gamss/android/core/ui/share/StoryCanvasTest.kt new file mode 100644 index 00000000..1b4e2b0b --- /dev/null +++ b/core/ui/src/test/java/com/gamss/android/core/ui/share/StoryCanvasTest.kt @@ -0,0 +1,61 @@ +package com.gamss.android.core.ui.share + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 캔버스 좌표는 실수 계산이라 픽셀 단위 이하 오차는 허용한다. */ +private const val TOLERANCE = 0.01f + +class StoryCanvasTest { + + @Test + fun `카드를 캔버스 가운데에 놓는다`() { + val bounds = requireNotNull(storyCardBounds(cardWidth = 366, cardHeight = 500)) + + assertEquals(STORY_WIDTH / 2f, (bounds.left + bounds.right) / 2f, TOLERANCE) + assertEquals(STORY_HEIGHT / 2f, (bounds.top + bounds.bottom) / 2f, TOLERANCE) + } + + @Test + fun `원본 비율을 유지한다`() { + val bounds = requireNotNull(storyCardBounds(cardWidth = 366, cardHeight = 500)) + + assertEquals(366f / 500f, bounds.width / bounds.height, TOLERANCE) + } + + @Test + fun `세로로 긴 카드는 높이 여백에 먼저 닿는다`() { + val bounds = requireNotNull(storyCardBounds(cardWidth = 100, cardHeight = 1000)) + + // 상하 여백 10% 를 뺀 높이에 딱 맞고, 좌우로는 여백보다 더 남는다. + assertEquals(STORY_HEIGHT * 0.8f, bounds.height, TOLERANCE) + assertTrue(bounds.left > STORY_WIDTH * 0.16f) + } + + @Test + fun `가로로 넓은 카드는 폭 여백에 먼저 닿는다`() { + val bounds = requireNotNull(storyCardBounds(cardWidth = 1000, cardHeight = 100)) + + // 좌우 여백 16% 씩을 뺀 폭에 딱 맞고, 위아래로는 여백보다 더 남는다. + assertEquals(STORY_WIDTH * 0.68f, bounds.width, TOLERANCE) + assertTrue(bounds.top > STORY_HEIGHT * 0.10f) + } + + @Test + fun `여백 안에 들어간다`() { + val bounds = requireNotNull(storyCardBounds(cardWidth = 366, cardHeight = 500)) + + assertTrue(bounds.left >= STORY_WIDTH * 0.16f - TOLERANCE) + assertTrue(bounds.right <= STORY_WIDTH * 0.84f + TOLERANCE) + assertTrue(bounds.top >= STORY_HEIGHT * 0.10f - TOLERANCE) + assertTrue(bounds.bottom <= STORY_HEIGHT * 0.90f + TOLERANCE) + } + + @Test + fun `크기가 0 인 캡처는 놓을 자리가 없다`() { + assertNull(storyCardBounds(cardWidth = 0, cardHeight = 500)) + assertNull(storyCardBounds(cardWidth = 366, cardHeight = 0)) + } +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt index d1f77755..7f3183eb 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt @@ -1,7 +1,5 @@ package com.gamss.android.feature.archive -import android.content.Context -import android.content.Intent import android.widget.Toast import androidx.annotation.StringRes import androidx.compose.foundation.clickable @@ -35,6 +33,7 @@ import com.gamss.android.core.designsystem.component.GamssTopBar import com.gamss.android.core.designsystem.dialog.GamssDialog import com.gamss.android.core.designsystem.dialog.GamssDialogAction import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.ui.share.StoryShareResult import com.gamss.android.domain.card.Card import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.archive.component.CardDetailDialog @@ -63,8 +62,10 @@ fun ArchiveDetailScreen( ) { val state by viewModel.collectAsState() val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.archive_card_share_chooser_title) val conversationLoadFailedMessage = stringResource(R.string.archive_conversation_load_error) + val instagramUnavailableMessage = stringResource(R.string.archive_card_share_instagram_unavailable) + val imageUnavailableMessage = stringResource(R.string.archive_card_share_image_failed) + val kakaoTalkUnavailableMessage = stringResource(R.string.archive_card_share_kakaotalk_unavailable) // 방금 버린 카드는 목록에 아직 없어 다시 받아야 한다. 파쇄한 카드는 서버에서 이미 지워진 // 뒤라, 남은 목록을 그대로 두고 그 한 장만 뺀다. @@ -83,6 +84,19 @@ fun ArchiveDetailScreen( ArchiveDetailSideEffect.ConversationLoadFailed -> Toast.makeText(context, conversationLoadFailedMessage, Toast.LENGTH_SHORT).show() + + is ArchiveDetailSideEffect.CardShareFailed -> { + val message = when (sideEffect.result) { + StoryShareResult.InstagramUnavailable -> instagramUnavailableMessage + StoryShareResult.ImageUnavailable -> imageUnavailableMessage + // 호출부가 성공(Shared)까지 올리지 않으므로 여기 닿을 일이 없다. + StoryShareResult.Shared -> return@collectSideEffect + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + + ArchiveDetailSideEffect.KakaoTalkShareFailed -> + Toast.makeText(context, kakaoTalkUnavailableMessage, Toast.LENGTH_LONG).show() } } @@ -107,8 +121,11 @@ fun ArchiveDetailScreen( onCardDismiss = viewModel::dismissCard, onCardDiscard = viewModel::discardSelectedCard, onCardConversationClick = viewModel::viewSelectedConversation, - onCardShare = { card -> shareCard(context, card, shareChooserTitle) }, onConversationCardDismiss = viewModel::dismissConversationCard, + onCardShareClick = viewModel::showShareSheet, + onCardShareSheetDismiss = viewModel::dismissShareSheet, + onCardInstagramShareFailed = viewModel::reportInstagramShareFailure, + onCardKakaoTalkShareFailed = viewModel::reportKakaoTalkShareFailure, ) } @@ -164,8 +181,11 @@ private fun ArchiveDetailOverlays( onCardDismiss: () -> Unit, onCardDiscard: () -> Unit, onCardConversationClick: () -> Unit, - onCardShare: (Card) -> Unit, onConversationCardDismiss: () -> Unit, + onCardShareClick: () -> Unit, + onCardShareSheetDismiss: () -> Unit, + onCardInstagramShareFailed: (StoryShareResult) -> Unit, + onCardKakaoTalkShareFailed: () -> Unit, ) { if (state.isMonthPickerVisible) { YearMonthPickerSheet( @@ -186,10 +206,14 @@ private fun ArchiveDetailOverlays( state.selectedCard?.let { card -> CardDetailDialog( card = card, + isShareSheetVisible = state.isShareSheetVisible, onDismiss = onCardDismiss, onDiscardClick = onCardDiscard, onViewConversationClick = onCardConversationClick, - onShareClick = { onCardShare(card) }, + onShareClick = onCardShareClick, + onShareSheetDismiss = onCardShareSheetDismiss, + onInstagramShareFailed = onCardInstagramShareFailed, + onKakaoTalkShareFailed = onCardKakaoTalkShareFailed, ) } @@ -202,14 +226,6 @@ private fun ArchiveDetailOverlays( } } -private fun shareCard(context: Context, card: Card, chooserTitle: String) { - val sendIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "${card.summary}\n\n${card.message}") - } - context.startActivity(Intent.createChooser(sendIntent, chooserTitle)) -} - /** 되돌릴 수 없는 삭제라 파쇄 화면으로 넘기기 전에 확인을 한 번 받는다. */ @Composable private fun ClearConfirmDialog( diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt index 085a7bed..047cf98b 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt @@ -1,5 +1,7 @@ package com.gamss.android.feature.archive +import com.gamss.android.core.ui.share.StoryShareResult + sealed interface ArchiveDetailSideEffect { data object ConversationLoadFailed : ArchiveDetailSideEffect @@ -9,4 +11,10 @@ sealed interface ArchiveDetailSideEffect { * @param cardId 파쇄할 카드. null 이면 이 감정 칸을 통째로 비운다. */ data class OpenCardDelete(val cardId: Long?) : ArchiveDetailSideEffect + + /** 카드를 인스타그램 스토리로 공유하지 못했다. [result] 로 실패 이유를 가른다. */ + data class CardShareFailed(val result: StoryShareResult) : ArchiveDetailSideEffect + + /** 카카오톡이 없어 카드를 공유하지 못했다. */ + data object KakaoTalkShareFailed : ArchiveDetailSideEffect } diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt index a1f086da..34774c03 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt @@ -11,6 +11,7 @@ data class ArchiveDetailState( val cards: ArchiveCards = ArchiveCards.Loading, val isMonthPickerVisible: Boolean = false, val isClearDialogVisible: Boolean = false, + val isShareSheetVisible: Boolean = false, val selectedCard: Card? = null, val conversationCard: ConversationCard? = null, val isConversationLoading: Boolean = false, diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt index f0c1bb17..865415af 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt @@ -3,6 +3,7 @@ package com.gamss.android.feature.archive import androidx.lifecycle.ViewModel import com.gamss.android.core.common.AppResult import com.gamss.android.core.common.util.KoreanTimeZone +import com.gamss.android.core.ui.share.StoryShareResult import com.gamss.android.domain.card.Card import com.gamss.android.domain.card.GetCardsByMonthAndEmotionUseCase import com.gamss.android.domain.card.MonthlyEmotionQuery @@ -85,7 +86,7 @@ class ArchiveDetailViewModel @Inject constructor( } fun dismissCard() = intent { - reduce { state.copy(selectedCard = null) } + reduce { state.copy(selectedCard = null, isShareSheetVisible = false) } } /** @@ -94,7 +95,7 @@ class ArchiveDetailViewModel @Inject constructor( */ fun discardSelectedCard() = intent { val card = state.selectedCard ?: return@intent - reduce { state.copy(selectedCard = null) } + reduce { state.copy(selectedCard = null, isShareSheetVisible = false) } postSideEffect(ArchiveDetailSideEffect.OpenCardDelete(cardId = card.id)) } @@ -141,6 +142,7 @@ class ArchiveDetailViewModel @Inject constructor( state.copy( isConversationLoading = false, selectedCard = null, + isShareSheetVisible = false, conversationCard = conversationCard, ) } @@ -157,6 +159,23 @@ class ArchiveDetailViewModel @Inject constructor( reduce { state.copy(conversationCard = null, selectedCard = card) } } + fun showShareSheet() = intent { + reduce { state.copy(isShareSheetVisible = true) } + } + + fun dismissShareSheet() = intent { + reduce { state.copy(isShareSheetVisible = false) } + } + + /** [result] 는 항상 실패인 경우만 넘어온다. 호출부가 성공(Shared)까지 올리지 않는다. */ + fun reportInstagramShareFailure(result: StoryShareResult) = intent { + postSideEffect(ArchiveDetailSideEffect.CardShareFailed(result)) + } + + fun reportKakaoTalkShareFailure() = intent { + postSideEffect(ArchiveDetailSideEffect.KakaoTalkShareFailed) + } + private suspend fun Syntax.loadMonth( emotion: EmotionCharacter, yearMonth: YearMonth, diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt index edbd5f64..06693092 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt @@ -1,45 +1,174 @@ package com.gamss.android.feature.archive.component +import android.content.Context import android.content.res.Configuration +import android.os.SystemClock +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.gamss.android.core.common.util.formatCardDate import com.gamss.android.core.designsystem.card.GamssEmotionCard import com.gamss.android.core.designsystem.card.GamssEmotionCardCharacter +import com.gamss.android.core.designsystem.card.GamssEmotionCardFooter import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.ui.card.cardTitleRes import com.gamss.android.core.ui.card.toGamssEmotionCardCharacter +import com.gamss.android.core.ui.share.CardShareLink +import com.gamss.android.core.ui.share.ComposeCapture +import com.gamss.android.core.ui.share.StoryShareResult +import com.gamss.android.core.ui.share.captureTo +import com.gamss.android.core.ui.share.rememberComposeCapture +import com.gamss.android.core.ui.share.shareBitmapToInstagramStory +import com.gamss.android.core.ui.share.shareTextToKakaoTalk import com.gamss.android.domain.card.Card import com.gamss.android.feature.archive.R +import kotlinx.coroutines.launch @Composable internal fun CardDetailDialog( card: Card, + isShareSheetVisible: Boolean, onDismiss: () -> Unit, onDiscardClick: () -> Unit, onViewConversationClick: () -> Unit, onShareClick: () -> Unit, + onShareSheetDismiss: () -> Unit, + onInstagramShareFailed: (StoryShareResult) -> Unit, + onKakaoTalkShareFailed: () -> Unit, ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val capture = rememberComposeCapture() + val kakaoTalkText = stringResource( + R.string.archive_card_share_kakaotalk_text, + stringResource(card.character.cardTitleRes()), + CardShareLink.of(card.id), + ) + + // 공유 이미지에는 누를 수 없는 버튼과 닫기 아이콘을 남기지 않는다. 감추는 동안만 켜 두고, + // 그 상태가 실제로 그려진 뒤에 캡처한다. + var isCapturing by remember { mutableStateOf(false) } + // 인스타그램 공유는 캡처가 끝날 때까지 비동기로 이어지므로 isSharingInstagramStory 로 + // 재진입을 막는다. 카카오톡은 호출이 동기라 이런 플래그가 열려 있는 구간이 없어 이 방식으로는 + // 막히지 않으므로, 마지막 탭 시각을 따로 기억해 뒀다가 SHARE_TAP_DEBOUNCE_MILLIS 안의 + // 재탭은 대상과 무관하게 걸러낸다. + var isSharingInstagramStory by remember { mutableStateOf(false) } + var lastShareTapAtMillis by remember { mutableStateOf(0L) } + fun consumeShareTap(): Boolean { + val now = SystemClock.elapsedRealtime() + if (now - lastShareTapAtMillis < SHARE_TAP_DEBOUNCE_MILLIS) return false + lastShareTapAtMillis = now + return true + } + CardDialogScaffold(onDismiss = onDismiss) { - GamssEmotionCard( - date = formatCardDate(card.date), - character = card.character.toGamssEmotionCardCharacter(), - title = stringResource(card.character.cardTitleRes()), - description = card.summary, - primaryActionLabel = stringResource(R.string.archive_card_discard), - secondaryActionLabel = stringResource(R.string.archive_card_view_conversation), - shareActionLabel = stringResource(R.string.archive_card_share), - onPrimaryActionClick = onDiscardClick, - onSecondaryActionClick = onViewConversationClick, - onShareClick = onShareClick, - // 공유가 아직 동작하지 않아 줄 자체를 숨깁니다. - isShareVisible = false, - topEndAction = { CardCloseButton(onClick = onDismiss) }, + Box(contentAlignment = Alignment.Center) { + GamssEmotionCard( + date = formatCardDate(card.date), + character = card.character.toGamssEmotionCardCharacter(), + title = stringResource(card.character.cardTitleRes()), + description = card.summary, + // 캡처하는 동안에는 액션 대신 워드마크를 넣는다. 누를 수 없는 버튼이 그림에 + // 남지 않고, 시안(Figma 4243:18230)의 공유용 카드와 같아진다. + footer = if (isCapturing) { + GamssEmotionCardFooter.Brand + } else { + GamssEmotionCardFooter.Actions( + primaryLabel = stringResource(R.string.archive_card_discard), + secondaryLabel = stringResource(R.string.archive_card_view_conversation), + shareLabel = stringResource(R.string.archive_card_share), + onPrimaryClick = onDiscardClick, + onSecondaryClick = onViewConversationClick, + onShareClick = onShareClick, + ) + }, + modifier = Modifier.captureTo(capture, isCapturing = isCapturing), + topEndAction = { if (!isCapturing) CardCloseButton(onClick = onDismiss) }, + ) + // 캡처 중에는 실제 카드가 워드마크뿐인 상태로 화면에 그대로 그려진다. 그 전환이 + // 사용자 눈에 보이지 않도록, 캡처가 끝날 때까지 카드 위를 짧게 덮어 가린다. + if (isCapturing) { + Box( + modifier = Modifier + .matchParentSize() + .background(CaptureOverlayColor), + ) + } + } + } + + // 시트는 카드와 다른 창에 뜨므로 캡처한 그림에 섞이지 않는다. 그래도 공유를 시작하면 + // 먼저 닫아, 인스타그램으로 넘어가는 동안 시트가 남지 않게 한다. + if (isShareSheetVisible) { + ShareTargetSheet( + onKakaoTalkClick = { + onShareSheetDismiss() + if (consumeShareTap() && !context.shareTextToKakaoTalk(kakaoTalkText)) { + onKakaoTalkShareFailed() + } + }, + onInstagramStoryClick = { + onShareSheetDismiss() + if (consumeShareTap() && !isSharingInstagramStory) { + isSharingInstagramStory = true + scope.launch { + try { + val result = shareCardToStory( + context = context, + capture = capture, + setCapturing = { isCapturing = it }, + ) + if (result != StoryShareResult.Shared) { + onInstagramShareFailed(result) + } + } finally { + isSharingInstagramStory = false + } + } + } + }, + onDismiss = onShareSheetDismiss, ) } } +/** + * 카드를 캡처해 인스타그램 스토리로 넘기고 공유 결과를 돌려준다. + * + * [setCapturing] 을 켜고 끄는 순서는 [ComposeCapture.captureWith] 가 스스로 지킨다. 캡처가 + * 예외나 시간 초과로 끊겨도 버튼과 닫기 아이콘이 사라진 카드만 남는 상태를 만들지 않는다. + */ +private suspend fun shareCardToStory( + context: Context, + capture: ComposeCapture, + setCapturing: (Boolean) -> Unit, +): StoryShareResult { + val bitmap = capture.captureWith(setCapturing) ?: return StoryShareResult.ImageUnavailable + + return try { + context.shareBitmapToInstagramStory(bitmap) + } finally { + bitmap.recycle() + } +} + +/** 캡처 구간 동안 카드를 가리는 반투명 스크림. */ +private val CaptureOverlayColor = Color.Black.copy(alpha = 0.32f) + +/** 이 시간 안에 다시 들어온 공유 탭은 같은 대상이든 다른 대상이든 무시한다. */ +private const val SHARE_TAP_DEBOUNCE_MILLIS = 500L + @Preview(name = "Light", showBackground = true) @Suppress("UnusedPrivateMember") @Composable @@ -63,6 +192,22 @@ private fun CardDetailDialogDarkPreview() { } } +/** 공유 이미지로 나가는 모양. 닫기 아이콘과 버튼이 빠지고 워드마크가 들어간다. */ +@Preview(name = "Share image", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun CardShareImagePreview() { + GamssTheme(darkTheme = false) { + GamssEmotionCard( + date = "26.08.03", + character = GamssEmotionCardCharacter.ANGER, + title = "오늘 화~나네", + description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", + footer = GamssEmotionCardFooter.Brand, + ) + } +} + @Composable private fun CardDetailDialogPreviewContent() { GamssEmotionCard( @@ -70,12 +215,14 @@ private fun CardDetailDialogPreviewContent() { character = GamssEmotionCardCharacter.ANGER, title = "오늘 화~나네", description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", - primaryActionLabel = stringResource(R.string.archive_card_discard), - secondaryActionLabel = stringResource(R.string.archive_card_view_conversation), - shareActionLabel = stringResource(R.string.archive_card_share), - onPrimaryActionClick = {}, - onSecondaryActionClick = {}, - onShareClick = {}, + footer = GamssEmotionCardFooter.Actions( + primaryLabel = stringResource(R.string.archive_card_discard), + secondaryLabel = stringResource(R.string.archive_card_view_conversation), + shareLabel = stringResource(R.string.archive_card_share), + onPrimaryClick = {}, + onSecondaryClick = {}, + onShareClick = {}, + ), topEndAction = { CardCloseButton(onClick = {}) }, ) } diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/ShareTargetSheet.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/ShareTargetSheet.kt new file mode 100644 index 00000000..8e6e7326 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/ShareTargetSheet.kt @@ -0,0 +1,112 @@ +package com.gamss.android.feature.archive.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.modifier.noRippleClickable +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.feature.archive.R + +/** + * 카드를 어디에 공유할지 고르는 시트. + * + * 보내는 것이 대상마다 달라 시스템 공유 시트를 쓰지 않는다. 카카오톡은 링크와 한 줄 문구를, + * 인스타그램은 카드 그림을 보내므로 두 경로를 나눠 두고 고르게 한다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ShareTargetSheet( + onKakaoTalkClick: () -> Unit, + onInstagramStoryClick: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = GamssTheme.colors.white, + shape = RoundedCornerShape(topStart = SheetCornerRadius, topEnd = SheetCornerRadius), + dragHandle = null, + // 디자인 여백은 화면 맨 아래까지 재는 값이라, 시트가 인셋을 예약하면 그만큼 더 벌어진다. + contentWindowInsets = { WindowInsets(0) }, + ) { + ShareTargetSheetContent( + onKakaoTalkClick = onKakaoTalkClick, + onInstagramStoryClick = onInstagramStoryClick, + onDismiss = onDismiss, + ) + } +} + +/** 시트 창 없이도 모양을 볼 수 있도록 내용만 따로 둔다. [ModalBottomSheet] 는 프리뷰에 그려지지 않는다. */ +@Composable +private fun ShareTargetSheetContent( + onKakaoTalkClick: () -> Unit, + onInstagramStoryClick: () -> Unit, + onDismiss: () -> Unit, +) { + Column(modifier = Modifier.padding(bottom = sheetBottomPadding())) { + SheetCloseButton( + contentDescription = stringResource(R.string.archive_card_share_sheet_close), + onClick = onDismiss, + ) + Text( + text = stringResource(R.string.archive_card_share_sheet_title), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding), + style = GamssTheme.typography.title3, + color = GamssTheme.colors.gray950, + ) + Spacer(modifier = Modifier.height(TitleToTargetsGap)) + ShareTargetRow( + label = stringResource(R.string.archive_card_share_target_kakaotalk), + onClick = onKakaoTalkClick, + ) + ShareTargetRow( + label = stringResource(R.string.archive_card_share_target_instagram_story), + onClick = onInstagramStoryClick, + ) + } +} + +@Composable +private fun ColumnScope.ShareTargetRow(label: String, onClick: () -> Unit) { + Text( + text = label, + modifier = Modifier + .fillMaxWidth() + .noRippleClickable(role = Role.Button, onClick = onClick) + .padding(horizontal = SheetHorizontalPadding, vertical = TargetVerticalPadding), + style = GamssTheme.typography.body3Medium, + color = GamssTheme.colors.gray900, + ) +} + +private val TitleToTargetsGap = 8.dp +private val TargetVerticalPadding = 16.dp + +@Preview(name = "Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun ShareTargetSheetContentPreview() { + GamssTheme(darkTheme = false) { + ShareTargetSheetContent( + onKakaoTalkClick = {}, + onInstagramStoryClick = {}, + onDismiss = {}, + ) + } +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/SheetChrome.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/SheetChrome.kt new file mode 100644 index 00000000..a3b50d78 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/SheetChrome.kt @@ -0,0 +1,59 @@ +package com.gamss.android.feature.archive.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.component.GamssIconButton +import com.gamss.android.core.designsystem.component.GamssIcons +import com.gamss.android.core.designsystem.theme.GamssTheme + +/** + * 아카이브의 바텀시트들이 함께 쓰는 모양값과 상단 닫기 버튼. + * + * 시트가 둘 이상이 되면서 같은 여백을 각자 들고 있을 이유가 없어져 여기로 모았다. + */ + +internal val SheetCornerRadius = 20.dp +internal val SheetTopPadding = 24.dp +internal val SheetHorizontalPadding = 20.dp +internal val SheetHitPadding = 12.dp + +private val SheetBottomPadding = 32.dp +private val MinGapAboveSystemBar = 8.dp + +/** + * 제스처 바(24dp)는 디자인의 홈 인디케이터처럼 여백 위에 겹쳐도 된다. 3버튼 내비처럼 인셋이 디자인 + * 여백보다 크면 버튼이 내비바에 붙으므로, 그때는 인셋 위로 최소 간격만큼 띄운다. + */ +@Composable +internal fun sheetBottomPadding(): Dp { + val systemBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + return SheetBottomPadding.coerceAtLeast(systemBarInset + MinGapAboveSystemBar) +} + +@Composable +internal fun SheetCloseButton(contentDescription: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + // hitPadding 만큼 당겨, 아이콘 자체가 디자인 위치에 오게 한다. + .padding(top = SheetTopPadding - SheetHitPadding, end = SheetHorizontalPadding - SheetHitPadding), + horizontalArrangement = Arrangement.End, + ) { + GamssIconButton( + iconRes = GamssIcons.Close, + contentDescription = contentDescription, + onClick = onClick, + hitPadding = SheetHitPadding, + tint = GamssTheme.colors.gray400, + ) + } +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/YearMonthPickerSheet.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/YearMonthPickerSheet.kt index 786ba694..885a88f5 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/YearMonthPickerSheet.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/YearMonthPickerSheet.kt @@ -2,16 +2,13 @@ package com.gamss.android.feature.archive.component import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn @@ -35,11 +32,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.gamss.android.core.common.util.KoreanTimeZone -import com.gamss.android.core.designsystem.component.GamssIconButton -import com.gamss.android.core.designsystem.component.GamssIcons import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.feature.archive.R import java.time.YearMonth @@ -73,7 +67,10 @@ internal fun YearMonthPickerSheet( contentWindowInsets = { WindowInsets(0) }, ) { Column(modifier = Modifier.padding(bottom = sheetBottomPadding())) { - CloseButton(onClick = onDismiss) + SheetCloseButton( + contentDescription = stringResource(R.string.archive_month_picker_close), + onClick = onDismiss, + ) YearMonthWheels( years = years, months = months, @@ -87,35 +84,6 @@ internal fun YearMonthPickerSheet( } } -/** - * 제스처 바(24dp)는 디자인의 홈 인디케이터처럼 여백 위에 겹쳐도 된다. 3버튼 내비처럼 인셋이 디자인 - * 여백보다 크면 버튼이 내비바에 붙으므로, 그때는 인셋 위로 최소 간격만큼 띄운다. - */ -@Composable -private fun sheetBottomPadding(): Dp { - val systemBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - return SheetBottomPadding.coerceAtLeast(systemBarInset + MinGapAboveSystemBar) -} - -@Composable -private fun CloseButton(onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - // hitPadding 만큼 당겨, 아이콘 자체가 디자인 위치에 오게 한다. - .padding(top = SheetTopPadding - HitPadding, end = SheetHorizontalPadding - HitPadding), - horizontalArrangement = Arrangement.End, - ) { - GamssIconButton( - iconRes = GamssIcons.Close, - contentDescription = stringResource(R.string.archive_month_picker_close), - onClick = onClick, - hitPadding = HitPadding, - tint = GamssTheme.colors.gray400, - ) - } -} - /** 가운데 칸 하이라이트는 두 휠에 걸친 한 덩어리라, 휠 뒤에 한 번만 깔고 가운데 정렬한다. */ @Composable private fun YearMonthWheels( @@ -132,7 +100,7 @@ private fun YearMonthWheels( start = SheetHorizontalPadding, end = SheetHorizontalPadding, // 위 Row 가 아이콘 아래에도 hitPadding 을 물고 있어 그만큼 뺀다. - top = WheelTopGap - HitPadding, + top = WheelTopGap - SheetHitPadding, ) .fillMaxWidth(), contentAlignment = Alignment.Center, @@ -246,12 +214,6 @@ private const val YEAR_RANGE_SIZE = 5 private const val MONTHS_IN_YEAR = 12 private const val WHEEL_VISIBLE_ROWS = 5 -private val SheetCornerRadius = 20.dp -private val SheetTopPadding = 24.dp -private val SheetBottomPadding = 32.dp -private val MinGapAboveSystemBar = 8.dp -private val SheetHorizontalPadding = 20.dp -private val HitPadding = 12.dp private val WheelTopGap = 24.dp private val WheelRowHeight = 40.dp private val WheelHighlightRadius = 12.dp diff --git a/feature/archive/src/main/res/values/strings.xml b/feature/archive/src/main/res/values/strings.xml index 9b207e7b..f6e736b0 100644 --- a/feature/archive/src/main/res/values/strings.xml +++ b/feature/archive/src/main/res/values/strings.xml @@ -24,7 +24,15 @@ 기록 버리기 대화보기 공유하기 - 카드 공유하기 + 공유하기 + 공유 시트 닫기 + 카카오톡 + 인스타그램 스토리 + + %1$s\n%2$s + 인스타그램이 설치되어 있지 않아요 + 카카오톡이 설치되어 있지 않아요 + 공유 이미지를 만들지 못했어요. 다시 시도해주세요 대화를 불러오지 못했어요. 남아 있는 대화가 없어요. 마지막 대화로 이동