From 7fe12e2f89339bf13f15134697188155d6de784c Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:11:45 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8C=9D=EC=97=85=EC=97=90=20=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캘린더 카드 상세의 '공유하기'를 텍스트 공유에서 카드 이미지 공유로 바꾼다. - Compose GraphicsLayer 로 카드를 캡처해 FileProvider URI 로 넘긴다 - 인스타그램이 배경 이미지를 9:16 으로 늘려 채우므로, 캔버스 가운데에 비율을 유지해 카드를 놓고 남는 곳을 진한 배경색으로 채운다 - 캡처한 비트맵은 HARDWARE 설정이라 소프트웨어 캔버스에 그리기 전에 복사한다 - 공유 이미지에는 누를 수 없는 버튼과 닫기 아이콘을 남기지 않는다 - 로그인·서버 없이 공유 경로를 확인하는 디버그 액티비티를 추가한다 --- app/src/main/AndroidManifest.xml | 11 ++ app/src/main/res/xml/file_paths.xml | 4 + .../core/designsystem/card/GamssImageCard.kt | 78 +++++++------ .../android/core/ui/share/ComposeCapture.kt | 21 ++++ .../core/ui/share/InstagramStoryShare.kt | 107 ++++++++++++++++++ feature/calendar/build.gradle.kts | 3 + .../calendar/src/debug/AndroidManifest.xml | 18 +++ .../calendar/debug/CardShareDebugActivity.kt | 46 ++++++++ .../feature/calendar/CalendarScreen.kt | 13 --- .../calendar/component/CardDetailDialog.kt | 41 ++++++- .../calendar/src/main/res/values/strings.xml | 2 +- 11 files changed, 291 insertions(+), 53 deletions(-) create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 core/ui/src/main/java/com/gamss/android/core/ui/share/ComposeCapture.kt create mode 100644 core/ui/src/main/java/com/gamss/android/core/ui/share/InstagramStoryShare.kt create mode 100644 feature/calendar/src/debug/AndroidManifest.xml create mode 100644 feature/calendar/src/debug/java/com/gamss/android/feature/calendar/debug/CardShareDebugActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ed11dfd..cb0494dc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,7 @@ + + + + + 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 98060254..b232ee1a 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 @@ -129,6 +129,9 @@ private fun GamssImageCardEmotionDarkPreview() { * * 카드의 고정 구조와 감정별 캐릭터 선택은 이 컴포넌트가 맡고, 문구와 사용자 동작만 호출부가 제공한다. * 따라서 화면마다 [GamssImageCard]의 간격과 텍스트 스타일을 다시 조합할 필요가 없다. + * + * [showActions] 를 `false` 로 두면 하단 버튼과 공유 행을 그리지 않는다. 카드를 이미지로 내보낼 때 + * 눌릴 수 없는 버튼이 그림에 남지 않게 하려는 용도다. */ @Composable @Suppress("LongParameterList") @@ -145,6 +148,7 @@ fun GamssEmotionCard( onShareClick: () -> Unit, modifier: Modifier = Modifier, shape: Shape = RectangleShape, + showActions: Boolean = true, topEndAction: @Composable BoxScope.() -> Unit = {}, ) { GamssImageCard( @@ -171,42 +175,44 @@ fun GamssEmotionCard( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing800)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), - ) { - CardOutlinedButton( - text = primaryActionLabel, - onClick = onPrimaryActionClick, - modifier = Modifier.weight(1f), - ) - CardOutlinedButton( - text = secondaryActionLabel, - onClick = onSecondaryActionClick, - modifier = Modifier.weight(1f), - ) - } - Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) - Row( - modifier = Modifier - .fillMaxWidth() - .noRippleClickableIfNotNull(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)) - Icon( - painter = painterResource(GamssIcons.RightChevron), - contentDescription = null, - tint = GamssTheme.colors.gray600, - modifier = Modifier.size(GamssTheme.spacing.spacing300), - ) + if (showActions) { + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing800)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), + ) { + CardOutlinedButton( + text = primaryActionLabel, + onClick = onPrimaryActionClick, + modifier = Modifier.weight(1f), + ) + CardOutlinedButton( + text = secondaryActionLabel, + onClick = onSecondaryActionClick, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickableIfNotNull(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)) + Icon( + painter = painterResource(GamssIcons.RightChevron), + contentDescription = null, + tint = GamssTheme.colors.gray600, + modifier = Modifier.size(GamssTheme.spacing.spacing300), + ) + } } } } 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..23e05ffc --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/share/ComposeCapture.kt @@ -0,0 +1,21 @@ +package com.gamss.android.core.ui.share + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +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 + +@Composable +fun rememberCaptureGraphicsLayer(): GraphicsLayer = rememberGraphicsLayer() + +fun Modifier.captureToGraphicsLayer(graphicsLayer: GraphicsLayer): Modifier = + drawWithContent { + graphicsLayer.record { this@drawWithContent.drawContent() } + drawLayer(graphicsLayer) + } + +suspend fun GraphicsLayer.toAndroidBitmap(): Bitmap = toImageBitmap().asAndroidBitmap() 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..9eb55dcf --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/share/InstagramStoryShare.kt @@ -0,0 +1,107 @@ +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 androidx.annotation.ColorInt +import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +private const val INSTAGRAM_PACKAGE = "com.instagram.android" +private const val ADD_TO_STORY_ACTION = "com.instagram.share.ADD_TO_STORY" +private const val SHARE_CACHE_DIR = "shared_images" +private const val SHARE_FILE_NAME = "story_share.png" + +/** 스토리 캔버스 크기(9:16). 인스타그램은 배경 이미지를 이 비율로 늘려 채운다. */ +private const val STORY_WIDTH = 1080 +private const val STORY_HEIGHT = 1920 + +/** 카드가 스토리 화면에 꽉 차지 않도록 남기는 좌우·상하 여백 비율. */ +private const val HORIZONTAL_MARGIN_RATIO = 0.16f +private const val VERTICAL_MARGIN_RATIO = 0.10f + +/** 여백을 채우는 기본 배경색. 밝은 카드가 돋보이도록 앱의 진한 톤(GrayLight950)을 쓴다. */ +private const val DEFAULT_STORY_BACKGROUND_COLOR = 0xFF1E1F22.toInt() + +/** + * [bitmap] 을 인스타그램 스토리로 공유한다. 인스타그램이 없으면 `false`. + * + * 인스타그램은 넘겨받은 배경 이미지를 9:16 캔버스에 꽉 채우므로, 카드를 그대로 보내면 + * 비율이 맞지 않아 잘리고 여백도 사라진다. 그래서 미리 9:16 캔버스 가운데에 카드를 놓고 + * 남는 곳을 [backgroundColor] 로 채운 이미지를 만들어 보낸다. + */ +@Suppress("SwallowedException") +fun Context.shareBitmapToInstagramStory( + bitmap: Bitmap, + @ColorInt backgroundColor: Int = DEFAULT_STORY_BACKGROUND_COLOR, +): Boolean { + val uri = bitmap.toStoryCanvas(backgroundColor).saveToShareCache(this) ?: return false + val intent = Intent(ADD_TO_STORY_ACTION).apply { + setDataAndType(uri, "image/png") + setPackage(INSTAGRAM_PACKAGE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + grantUriPermission(INSTAGRAM_PACKAGE, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + false + } +} + +/** 9:16 캔버스 가운데에 원본 비율을 유지한 카드를 놓고 나머지를 [backgroundColor] 로 채운다. */ +private fun Bitmap.toStoryCanvas(@ColorInt backgroundColor: Int): Bitmap { + val story = createBitmap(STORY_WIDTH, STORY_HEIGHT) + val canvas = Canvas(story) + canvas.drawColor(backgroundColor) + + val availableWidth = STORY_WIDTH * (1f - HORIZONTAL_MARGIN_RATIO * 2) + val availableHeight = STORY_HEIGHT * (1f - VERTICAL_MARGIN_RATIO * 2) + val scale = minOf(availableWidth / width, availableHeight / height) + val targetWidth = width * scale + val targetHeight = height * scale + val left = (STORY_WIDTH - targetWidth) / 2f + val top = (STORY_HEIGHT - targetHeight) / 2f + + canvas.drawBitmap( + toSoftwareBitmap(), + null, + RectF(left, top, left + targetWidth, top + targetHeight), + Paint(Paint.FILTER_BITMAP_FLAG), + ) + return story +} + +/** + * 소프트웨어 [Canvas] 에 그릴 수 있는 비트맵으로 바꾼다. + * + * Compose 의 GraphicsLayer 캡처는 [Bitmap.Config.HARDWARE] 비트맵을 주는데, 이건 GPU 메모리에만 + * 있어서 소프트웨어 캔버스에 그리면 예외가 난다. PNG 압축만 할 때는 문제가 없어 이 변환이 필요 없었다. + */ +private fun Bitmap.toSoftwareBitmap(): Bitmap = + if (config == Bitmap.Config.HARDWARE) { + copy(Bitmap.Config.ARGB_8888, false) ?: this + } else { + this + } + +@Suppress("SwallowedException") +private fun Bitmap.saveToShareCache(context: Context): Uri? { + val dir = File(context.cacheDir, SHARE_CACHE_DIR).apply { mkdirs() } + val file = File(dir, SHARE_FILE_NAME) + return try { + FileOutputStream(file).use { out -> compress(Bitmap.CompressFormat.PNG, 100, out) } + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + } catch (e: IOException) { + null + } +} diff --git a/feature/calendar/build.gradle.kts b/feature/calendar/build.gradle.kts index ac515dd2..c577d2a1 100644 --- a/feature/calendar/build.gradle.kts +++ b/feature/calendar/build.gradle.kts @@ -14,4 +14,7 @@ dependencies { exclude(group = "androidx.compose.ui", module = "ui-tooling") } implementation(libs.compose.material.icons.core) + + // 카드 공유 검증용 디버그 액티비티(src/debug)에서만 쓴다. + debugImplementation(libs.androidx.activity.compose) } diff --git a/feature/calendar/src/debug/AndroidManifest.xml b/feature/calendar/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..5622e923 --- /dev/null +++ b/feature/calendar/src/debug/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/feature/calendar/src/debug/java/com/gamss/android/feature/calendar/debug/CardShareDebugActivity.kt b/feature/calendar/src/debug/java/com/gamss/android/feature/calendar/debug/CardShareDebugActivity.kt new file mode 100644 index 00000000..8f52fb81 --- /dev/null +++ b/feature/calendar/src/debug/java/com/gamss/android/feature/calendar/debug/CardShareDebugActivity.kt @@ -0,0 +1,46 @@ +package com.gamss.android.feature.calendar.debug + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.domain.card.Card +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.feature.calendar.component.CardDetailDialog +import java.time.LocalDate + +/** + * 카드 상세 팝업의 인스타그램 스토리 공유를 검증하는 디버그 전용 화면. 프로덕션 아님. + * + * 로그인·서버·실제 카드 없이 [CardDetailDialog] 를 그대로 띄운다. 공유 경로(카드 캡처 → + * FileProvider → 인스타그램 인텐트)가 Dialog 창 안에서도 동작하는지 보려면 실제 화면과 + * 같은 컴포저블을 써야 하므로, 팝업을 흉내 내지 않고 [CardDetailDialog] 를 직접 호출한다. + * + * 실행: adb shell am start -n com.gamss.android.dev/com.gamss.android.feature.calendar.debug.CardShareDebugActivity + */ +class CardShareDebugActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + GamssTheme { + CardDetailDialog( + card = SampleCard, + onDismiss = ::finish, + onDiscardClick = {}, + onViewConversationClick = {}, + ) + } + } + } +} + +private val SampleCard = Card( + id = 1L, + conversationId = 1L, + character = EmotionCharacter.ANGER, + emotionLabel = "분노", + summary = "오늘 화~나네", + message = "회의에서 준비한 자료로 한 소리 들었지만 그래도 내일은 나아질 거야.", + date = LocalDate.of(2026, 8, 3), +) diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt index c54664bc..bf681b09 100644 --- a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt @@ -1,7 +1,5 @@ package com.gamss.android.feature.calendar -import android.content.Context -import android.content.Intent import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,7 +51,6 @@ fun CalendarScreen( ) { val state by viewModel.collectAsState() val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.calendar_card_share_chooser_title) val discardFailureMessage = stringResource(R.string.calendar_card_discard_failure) viewModel.collectSideEffect { sideEffect -> @@ -104,20 +101,10 @@ fun CalendarScreen( onDismiss = viewModel::dismissCardDetail, onDiscardClick = viewModel::discardSelectedCard, onViewConversationClick = viewModel::viewSelectedConversation, - onShareClick = { shareCard(context, card, shareChooserTitle) }, ) } } -private fun shareCard(context: Context, card: Card, chooserTitle: String) { - val shareText = "${card.summary}\n\n${card.message}" - val sendIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, shareText) - } - context.startActivity(Intent.createChooser(sendIntent, chooserTitle)) -} - @Composable private fun CalendarCardResults( selectedDate: LocalDate?, diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/component/CardDetailDialog.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/component/CardDetailDialog.kt index f72bd7d2..082326b8 100644 --- a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/component/CardDetailDialog.kt +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/component/CardDetailDialog.kt @@ -1,6 +1,7 @@ package com.gamss.android.feature.calendar.component import android.content.res.Configuration +import android.widget.Toast import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -10,8 +11,15 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Dialog @@ -20,8 +28,13 @@ import com.gamss.android.core.designsystem.card.GamssEmotionCard import com.gamss.android.core.designsystem.card.GamssEmotionCardCharacter import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.ui.card.toGamssEmotionCardCharacter +import com.gamss.android.core.ui.share.captureToGraphicsLayer +import com.gamss.android.core.ui.share.rememberCaptureGraphicsLayer +import com.gamss.android.core.ui.share.shareBitmapToInstagramStory +import com.gamss.android.core.ui.share.toAndroidBitmap import com.gamss.android.domain.card.Card import com.gamss.android.feature.calendar.R +import kotlinx.coroutines.launch import java.time.format.DateTimeFormatter /** @@ -36,8 +49,16 @@ internal fun CardDetailDialog( onDismiss: () -> Unit, onDiscardClick: () -> Unit, onViewConversationClick: () -> Unit, - onShareClick: () -> Unit, ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val graphicsLayer = rememberCaptureGraphicsLayer() + val shareUnavailableMessage = stringResource(R.string.calendar_card_share_unavailable) + + // 공유 이미지에는 누를 수 없는 버튼과 닫기 아이콘을 남기지 않는다. 캡처하는 한 프레임 동안만 + // 감춰야 하므로 상태로 두고, 그림이 갱신된 뒤에 GraphicsLayer 를 읽는다. + var isCapturing by remember { mutableStateOf(false) } + Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), @@ -58,8 +79,22 @@ internal fun CardDetailDialog( shareActionLabel = stringResource(R.string.calendar_card_share), onPrimaryActionClick = onDiscardClick, onSecondaryActionClick = onViewConversationClick, - onShareClick = onShareClick, - topEndAction = { CardCloseButton(onClick = onDismiss) }, + onShareClick = { + scope.launch { + isCapturing = true + // 상태 변경이 재구성과 그리기를 거쳐 레이어에 반영될 때까지 기다린다. + withFrameNanos {} + withFrameNanos {} + val bitmap = graphicsLayer.toAndroidBitmap() + isCapturing = false + if (!context.shareBitmapToInstagramStory(bitmap)) { + Toast.makeText(context, shareUnavailableMessage, Toast.LENGTH_LONG).show() + } + } + }, + modifier = Modifier.captureToGraphicsLayer(graphicsLayer), + showActions = !isCapturing, + topEndAction = { if (!isCapturing) CardCloseButton(onClick = onDismiss) }, ) } } diff --git a/feature/calendar/src/main/res/values/strings.xml b/feature/calendar/src/main/res/values/strings.xml index 3d5e5d5d..8166a1dc 100644 --- a/feature/calendar/src/main/res/values/strings.xml +++ b/feature/calendar/src/main/res/values/strings.xml @@ -14,6 +14,6 @@ 기록 버리기 대화보기 공유하기 - 카드 공유하기 + 인스타그램이 설치되어 있지 않아요 기록을 버리지 못했어요. 다시 시도해주세요 From ef00ce82d425e4b54c5992f17bb8822df931082d Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:43:04 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9C=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=ED=95=98=EB=8B=A8=EC=9D=84=20=EC=8B=9C?= =?UTF-8?q?=EC=95=88=EC=9D=98=20GAMSS=20=EC=9B=8C=EB=93=9C=EB=A7=88?= =?UTF-8?q?=ED=81=AC=EB=A1=9C=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공유용 카드 시안(Figma 4243:18230)은 상세 팝업과 아래쪽만 다르다. 점선은 그대로 두고 그 밑 버튼·공유 행 자리에 GAMSS 워드마크가 들어간다. 캡처할 때 액션만 걷어내면 점선까지 사라지고 아래가 빈 채로 남으므로, 무엇이 오는지를 GamssEmotionCardFooter 로 갈라 준다. - GamssEmotionCardActions -> GamssEmotionCardFooter.Actions / Brand - 워드마크는 카드가 고정으로 쓰는 라이트 팔레트의 purple 로 물들여 시안 색과 맞춘다 - 공유 이미지 모양을 프리뷰로 남긴다 --- .../core/designsystem/card/GamssImageCard.kt | 155 +++++++++++------- .../archive/component/CardDetailDialog.kt | 29 +++- 2 files changed, 121 insertions(+), 63 deletions(-) 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 d6899816..17cbc580 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 @@ -27,6 +27,7 @@ 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 @@ -128,19 +129,32 @@ private fun GamssImageCardEmotionDarkPreview() { } /** - * 카드 하단의 버튼 두 개와 공유 행에 필요한 문구·동작 묶음. - * - * 여섯 값이 늘 함께 쓰이고 함께 사라지므로 한 덩어리로 받는다. [GamssEmotionCard] 에 `null` 을 - * 넘기면 이 영역을 아예 그리지 않아, 누를 수 없는 버튼이 남는 상태를 만들 수 없다. + * 카드 아래쪽 점선 밑에 들어가는 것. 시안이 상세 팝업과 공유 이미지 두 가지만 두므로 그대로 나눈다. */ -data class GamssEmotionCardActions( - val primaryLabel: String, - val secondaryLabel: String, - val shareLabel: String, - val onPrimaryClick: () -> Unit, - val onSecondaryClick: () -> Unit, - val onShareClick: () -> Unit, -) +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 +} /** * 감정 캐릭터와 대화 요약을 보여 주는 이미지 카드 퍼사드. @@ -148,9 +162,7 @@ data class GamssEmotionCardActions( * 카드의 고정 구조와 감정별 캐릭터 선택은 이 컴포넌트가 맡고, 문구와 사용자 동작만 호출부가 제공한다. * 따라서 화면마다 [GamssImageCard]의 간격과 텍스트 스타일을 다시 조합할 필요가 없다. * - * [actions] 가 `null` 이면 점선과 그 아래 버튼·공유 행을 그리지 않는다. 카드를 이미지로 내보낼 때 - * 눌릴 수 없는 버튼이 그림에 남지 않게 하려는 용도다. 카드 높이는 [CardAspectRatio] 로 고정이라 - * 그만큼 아래가 빈 채로 남는다. + * 아래쪽 점선까지는 두 시안이 같고, 그 밑에 무엇이 오는지만 [footer] 가 정한다. */ @Composable fun GamssEmotionCard( @@ -158,7 +170,7 @@ fun GamssEmotionCard( character: GamssEmotionCardCharacter, title: String, description: String, - actions: GamssEmotionCardActions?, + footer: GamssEmotionCardFooter, modifier: Modifier = Modifier, shape: Shape = RectangleShape, topEndAction: @Composable BoxScope.() -> Unit = {}, @@ -188,51 +200,76 @@ fun GamssEmotionCard( maxLines = DESCRIPTION_MAX_LINES, overflow = TextOverflow.Ellipsis, ) - if (actions != null) { - Spacer(modifier = Modifier.height(DividerToContentGap)) - GamssCardDashedDivider() - Spacer(modifier = Modifier.height(DividerToContentGap)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), - ) { - CardOutlinedButton( - text = actions.primaryLabel, - onClick = actions.onPrimaryClick, - modifier = Modifier.weight(1f), - ) - CardOutlinedButton( - text = actions.secondaryLabel, - onClick = actions.onSecondaryClick, - modifier = Modifier.weight(1f), - ) - } - Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) - Row( - modifier = Modifier - .fillMaxWidth() - .noRippleClickableIfNotNull(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)) - Icon( - painter = painterResource(GamssIcons.RightChevron), - contentDescription = null, - tint = GamssTheme.colors.gray600, - modifier = Modifier.size(GamssTheme.spacing.spacing300), - ) - } + Spacer(modifier = Modifier.height(DividerToContentGap)) + GamssCardDashedDivider() + Spacer(modifier = Modifier.height(DividerToContentGap)) + when (footer) { + is GamssEmotionCardFooter.Actions -> CardActionsFooter(footer) + GamssEmotionCardFooter.Brand -> CardBrandFooter() } } } } +/** 상세 팝업 아래쪽. 버튼 두 개와 그 밑 공유 행. */ +@Composable +private fun CardActionsFooter(actions: GamssEmotionCardFooter.Actions) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), + ) { + CardOutlinedButton( + text = actions.primaryLabel, + onClick = actions.onPrimaryClick, + modifier = Modifier.weight(1f), + ) + CardOutlinedButton( + text = actions.secondaryLabel, + onClick = actions.onSecondaryClick, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickableIfNotNull(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)) + Icon( + painter = painterResource(GamssIcons.RightChevron), + contentDescription = null, + tint = GamssTheme.colors.gray600, + modifier = Modifier.size(GamssTheme.spacing.spacing300), + ) + } +} + +/** + * 공유 이미지 아래쪽. 시안(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), + ) +} + /** * 감정 카드의 캐릭터 영역과 그 아래 콘텐츠 간격을 재사용한다. * @@ -316,7 +353,7 @@ private fun EmotionCardPreviewContent() { character = GamssEmotionCardCharacter.ANGER, title = "오늘 화~나네", description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", - actions = GamssEmotionCardActions( + footer = GamssEmotionCardFooter.Actions( primaryLabel = "기록 버리기", secondaryLabel = "대화보기", shareLabel = "공유하기", @@ -427,6 +464,10 @@ private val CharacterToTitleGap = 42.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/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 a4a70ecb..0851dc6e 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 @@ -28,8 +28,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.gamss.android.core.designsystem.card.GamssEmotionCard -import com.gamss.android.core.designsystem.card.GamssEmotionCardActions 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.designsystem.theme.GamssTouchTarget import com.gamss.android.core.ui.card.cardTitleRes @@ -77,11 +77,12 @@ internal fun CardDetailDialog( character = card.character.toGamssEmotionCardCharacter(), title = stringResource(card.character.cardTitleRes()), description = card.summary, - // 캡처하는 동안에는 액션을 아예 넘기지 않아 그림에 남지 않게 한다. - actions = if (isCapturing) { - null + // 캡처하는 동안에는 액션 대신 워드마크를 넣는다. 누를 수 없는 버튼이 그림에 + // 남지 않고, 시안(Figma 4243:18230)의 공유용 카드와 같아진다. + footer = if (isCapturing) { + GamssEmotionCardFooter.Brand } else { - GamssEmotionCardActions( + GamssEmotionCardFooter.Actions( primaryLabel = stringResource(R.string.archive_card_discard), secondaryLabel = stringResource(R.string.archive_card_view_conversation), shareLabel = stringResource(R.string.archive_card_share), @@ -197,6 +198,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( @@ -204,7 +221,7 @@ private fun CardDetailDialogPreviewContent() { character = GamssEmotionCardCharacter.ANGER, title = "오늘 화~나네", description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", - actions = GamssEmotionCardActions( + 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), From c36a239107711c6922dd1449f2dcf6a700d831ce Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:57:21 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9C=A0=ED=95=98?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1/?= =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=83=80=EA=B7=B8=EB=9E=A8=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보내는 것이 대상마다 달라 시스템 공유 시트를 쓰지 않는다. 카카오톡은 카드 문구 한 줄과 링크를, 인스타그램은 카드 그림을 보내므로 두 경로를 나눠 두고 시트에서 고르게 한다. - ShareTargetSheet: 카카오톡 / 인스타그램 스토리 두 항목 - 카카오톡은 ACTION_SEND 를 com.kakao.talk 에 직접 지정한다. 한 줄 문구와 링크만 보내므로 SDK·앱키·메시지 템플릿을 들이지 않는다 - 시트 두 개가 같은 여백을 각자 들고 있었으므로 SheetChrome 으로 모았다 - 매니페스트 queries 에 com.kakao.talk 추가(API 30+ 는 이게 없으면 setPackage 해석이 실패한다) 링크 주소는 CardShareLink 한 곳에만 둔다. 지금은 열리지 않는 예약 도메인이므로 웹 랜딩 주소를 받아 바꿔야 한다. --- app/src/main/AndroidManifest.xml | 1 + .../android/core/ui/share/CardShareLink.kt | 18 +++ .../android/core/ui/share/KakaoTalkShare.kt | 35 ++++++ .../archive/component/CardDetailDialog.kt | 53 ++++++--- .../archive/component/ShareTargetSheet.kt | 112 ++++++++++++++++++ .../feature/archive/component/SheetChrome.kt | 59 +++++++++ .../archive/component/YearMonthPickerSheet.kt | 48 +------- .../archive/src/main/res/values/strings.xml | 7 ++ 8 files changed, 276 insertions(+), 57 deletions(-) create mode 100644 core/ui/src/main/java/com/gamss/android/core/ui/share/CardShareLink.kt create mode 100644 core/ui/src/main/java/com/gamss/android/core/ui/share/KakaoTalkShare.kt create mode 100644 feature/archive/src/main/java/com/gamss/android/feature/archive/component/ShareTargetSheet.kt create mode 100644 feature/archive/src/main/java/com/gamss/android/feature/archive/component/SheetChrome.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9322356d..29cc3b64 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ + - Toast.makeText(context, message, Toast.LENGTH_LONG).show() - } - } - }, + onShareClick = { isShareSheetVisible = true }, ) }, modifier = Modifier.captureTo(capture), topEndAction = { if (!isCapturing) CardCloseButton(onClick = onDismiss) }, ) } + + // 시트는 카드와 다른 창에 뜨므로 캡처한 그림에 섞이지 않는다. 그래도 공유를 시작하면 + // 먼저 닫아, 인스타그램으로 넘어가는 동안 시트가 남지 않게 한다. + if (isShareSheetVisible) { + ShareTargetSheet( + onKakaoTalkClick = { + isShareSheetVisible = false + if (!context.shareTextToKakaoTalk(kakaoTalkText)) { + Toast.makeText(context, kakaoTalkUnavailableMessage, Toast.LENGTH_LONG).show() + } + }, + onInstagramStoryClick = { + isShareSheetVisible = false + scope.launch { + val failure = shareCardToStory( + context = context, + capture = capture, + instagramUnavailableMessage = instagramUnavailableMessage, + imageUnavailableMessage = imageUnavailableMessage, + setCapturing = { isCapturing = it }, + ) + failure?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + }, + onDismiss = { isShareSheetVisible = false }, + ) + } } } 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 9c3dbe6a..09993e4c 100644 --- a/feature/archive/src/main/res/values/strings.xml +++ b/feature/archive/src/main/res/values/strings.xml @@ -25,7 +25,14 @@ 기록 버리기 대화보기 공유하기 + 공유하기 + 공유 시트 닫기 + 카카오톡 + 인스타그램 스토리 + + %1$s\n%2$s 인스타그램이 설치되어 있지 않아요 + 카카오톡이 설치되어 있지 않아요 공유 이미지를 만들지 못했어요. 다시 시도해주세요 카드를 불러오지 못했어요. 기록을 버리지 못했어요. 다시 시도해주세요 From b66299f2d5fb8bcfe9c1cb96d20e6dd59814a2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Fri, 21 Aug 2026 12:21:53 +0900 Subject: [PATCH 04/20] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=95=88=EC=A0=95=EC=84=B1=EA=B3=BC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20=20=20-=20=EA=B3=B5=EC=9C=A0=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=EB=A5=BC=20gamss.kr=20=EB=9E=9C=EB=94=A9=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4=20=20=20-=20?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1=C2=B7=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EA=B7=B8=EB=9E=A8=20=EC=8B=A4=ED=96=89=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20=EC=B2=98=EB=A6=AC=ED=95=9C=EB=8B=A4=20=20?= =?UTF-8?q?=20-=20=EC=9D=B8=EC=8A=A4=ED=83=80=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EA=B3=B5=EC=9C=A0=EC=99=80=20PNG=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=83=9D=EC=84=B1=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=A9=EC=A7=80=ED=95=9C=EB=8B=A4=20=20=20-=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20Intent,=20FileProvider,=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=95=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/share/CardShareInstrumentedTest.kt | 100 ++++++++++++++++++ .../android/core/ui/share/CardShareLink.kt | 5 +- .../core/ui/share/InstagramStoryShare.kt | 24 ++++- .../android/core/ui/share/KakaoTalkShare.kt | 3 + .../archive/component/CardDetailDialog.kt | 28 +++-- 5 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt 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..65a66c5b --- /dev/null +++ b/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt @@ -0,0 +1,100 @@ +package com.gamss.android.app.share + +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +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 = SecurityFailureContext(applicationContext) + + assertFalse(context.shareTextToKakaoTalk("공유 문구")) + } + + @Test + fun 인스타그램_공유_인텐트에_FileProvider_Uri와_읽기_권한을_담는다() { + 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 새_스토리_이미지를_만들기_전에_한시간_지난_캐시를_지운다() { + val shareDir = File(applicationContext.cacheDir, "shared_images").apply { mkdirs() } + val expired = File(shareDir, "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()) + } + + private class RecordingContext(base: Context) : ContextWrapper(base) { + var startedIntent: Intent? = null + + override fun startActivity(intent: Intent) { + startedIntent = intent + } + } + + private class SecurityFailureContext(base: Context) : ContextWrapper(base) { + override fun startActivity(intent: Intent) { + throw SecurityException("test") + } + } +} 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 index 79a34a1e..50368ab7 100644 --- 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 @@ -7,11 +7,8 @@ object CardShareLink { /** * 공유 링크가 가리키는 웹 랜딩. - * - * TODO(도메인 확정): 실제 랜딩 주소로 바꿔야 한다. 지금 값은 절대 열리지 않는 예약 도메인 - * (RFC 2606 의 `.invalid`)이라, 바꾸지 않은 채로 배포하면 카톡으로 보낸 링크가 열리지 않는다. */ - private const val LANDING_BASE_URL = "https://gamss.example.invalid" + 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/InstagramStoryShare.kt b/core/ui/src/main/java/com/gamss/android/core/ui/share/InstagramStoryShare.kt index 54e87d89..885bf828 100644 --- 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 @@ -75,6 +75,9 @@ fun Context.shareBitmapToInstagramStory( } catch (e: ActivityNotFoundException) { Log.i(TAG, "인스타그램 스토리 공유를 받을 액티비티가 없다", e) StoryShareResult.InstagramUnavailable + } catch (e: SecurityException) { + Log.w(TAG, "인스타그램 스토리 공유 액티비티를 실행할 권한이 없다", e) + StoryShareResult.InstagramUnavailable } } @@ -141,12 +144,29 @@ private fun Bitmap.saveToShareCache(context: Context): Uri? { } dir.deleteFilesOlderThan(SHARE_FILE_TTL_MILLIS) + var file: File? = null return try { - val file = File.createTempFile(SHARE_FILE_PREFIX, SHARE_FILE_SUFFIX, dir) - FileOutputStream(file).use { out -> compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out) } + file = File.createTempFile(SHARE_FILE_PREFIX, SHARE_FILE_SUFFIX, dir) + val compressed = FileOutputStream(file).use { out -> + compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out) + } + if (!compressed) { + Log.w(TAG, "공유 이미지를 PNG로 압축하지 못했다") + file.delete() + return null + } FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } catch (e: IOException) { Log.w(TAG, "공유 이미지를 캐시에 쓰지 못했다", e) + file?.delete() + null + } catch (e: IllegalArgumentException) { + Log.w(TAG, "공유 이미지의 FileProvider URI를 만들지 못했다", e) + file?.delete() + null + } catch (e: SecurityException) { + Log.w(TAG, "공유 이미지 캐시에 접근할 권한이 없다", e) + file?.delete() null } } 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 index 42cb1875..67f465f0 100644 --- 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 @@ -31,5 +31,8 @@ fun Context.shareTextToKakaoTalk(text: String): Boolean { } catch (e: ActivityNotFoundException) { Log.i(TAG, "카카오톡 공유를 받을 액티비티가 없다", e) false + } catch (e: SecurityException) { + Log.w(TAG, "카카오톡 공유 액티비티를 실행할 권한이 없다", e) + false } } 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 19897147..c68e73ed 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 @@ -70,6 +70,7 @@ internal fun CardDetailDialog( // 그 상태가 실제로 그려진 뒤에 캡처한다. var isCapturing by remember { mutableStateOf(false) } var isShareSheetVisible by remember { mutableStateOf(false) } + var isSharing by remember { mutableStateOf(false) } Dialog( onDismissRequest = onDismiss, @@ -117,16 +118,23 @@ internal fun CardDetailDialog( }, onInstagramStoryClick = { isShareSheetVisible = false - scope.launch { - val failure = shareCardToStory( - context = context, - capture = capture, - instagramUnavailableMessage = instagramUnavailableMessage, - imageUnavailableMessage = imageUnavailableMessage, - setCapturing = { isCapturing = it }, - ) - failure?.let { message -> - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + if (!isSharing) { + isSharing = true + scope.launch { + try { + val failure = shareCardToStory( + context = context, + capture = capture, + instagramUnavailableMessage = instagramUnavailableMessage, + imageUnavailableMessage = imageUnavailableMessage, + setCapturing = { isCapturing = it }, + ) + failure?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } finally { + isSharing = false + } } } }, From 01c04cc0efa0727935af6822b682f8999a5f4ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Thu, 3 Sep 2026 16:10:36 +0900 Subject: [PATCH 05/20] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=83=80=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B3=B5=EC=9C=A0=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B9=84=ED=8A=B8=EB=A7=B5=20=EB=B3=80=ED=99=98=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=81=AC=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HARDWARE 비트맵을 소프트웨어 비트맵으로 복사하는 데 실패하면(메모리 부족 등) 원본(HARDWARE)을 그대로 돌려주고 있었다. 이걸 소프트웨어 캔버스에 그리려 하면 IllegalStateException 이 나는데 어디서도 잡지 않아 앱이 죽었다. 변환 실패 시 null 을 돌려주도록 바꿔, 이미 있던 ImageUnavailable 처리 경로로 흘러가게 한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YVCS63pcgDR6Z8kGbQ8HCG --- .../android/core/ui/share/InstagramStoryShare.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 index 885bf828..10cb33ec 100644 --- 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 @@ -94,16 +94,15 @@ private fun Context.createStoryImageUri(bitmap: Bitmap, @ColorInt backgroundColo /** * 9:16 캔버스 가운데에 원본 비율을 유지한 카드를 놓고 나머지를 [backgroundColor] 로 채운다. - * 카드 크기가 0 이면 그릴 자리를 정할 수 없어 `null`. + * 카드 크기가 0 이거나 소프트웨어 비트맵으로 바꾸지 못하면 그릴 수 없어 `null`. */ private fun Bitmap.toStoryCanvas(@ColorInt backgroundColor: Int): Bitmap? { val bounds = storyCardBounds(width, height) ?: return null + val source = toSoftwareBitmap() ?: return null val story = createBitmap(STORY_WIDTH, STORY_HEIGHT) val canvas = Canvas(story) canvas.drawColor(backgroundColor) - - val source = toSoftwareBitmap() canvas.drawBitmap( source, null, @@ -117,14 +116,16 @@ private fun Bitmap.toStoryCanvas(@ColorInt backgroundColor: Int): Bitmap? { } /** - * 소프트웨어 [Canvas] 에 그릴 수 있는 비트맵으로 바꾼다. + * 소프트웨어 [Canvas] 에 그릴 수 있는 비트맵으로 바꾼다. 변환에 실패하면(메모리 부족 등) `null`. * * Compose 의 GraphicsLayer 캡처는 [Bitmap.Config.HARDWARE] 비트맵을 주는데, 이건 GPU 메모리에만 * 있어서 소프트웨어 캔버스에 그리면 예외가 난다. PNG 압축만 할 때는 문제가 없어 이 변환이 필요 없었다. + * [Bitmap.copy] 는 실패하면 원본을 되돌려주지 않고 `null` 을 주므로, 여기서도 그대로 `null` 을 + * 돌려준다. 원본(HARDWARE)을 그대로 반환하면 호출부가 소프트웨어 캔버스에 그리려다 죽는다. */ -private fun Bitmap.toSoftwareBitmap(): Bitmap = +private fun Bitmap.toSoftwareBitmap(): Bitmap? = if (config == Bitmap.Config.HARDWARE) { - copy(Bitmap.Config.ARGB_8888, false) ?: this + copy(Bitmap.Config.ARGB_8888, false) } else { this } From bb88deaf6d6846c24340bc998ea27f92fee80015 Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 8 Sep 2026 00:18:00 +0900 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20develop=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EC=A4=91=20accept=20theirs=EB=A1=9C=20=EC=9C=A0=EC=8B=A4?= =?UTF-8?q?=EB=90=9C=20=EC=B9=B4=EB=93=9C=20=EA=B3=B5=EC=9C=A0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=EB=B3=B5=EC=9B=90=ED=95=9C=EB=8B=A4=20-?= =?UTF-8?q?=20GamssEmotionCard=20=EC=9D=98=20GamssEmotionCardFooter(Action?= =?UTF-8?q?s/Brand)=20=EB=B6=84=EA=B8=B0=EB=A5=BC=20=EB=B3=B5=EC=9B=90?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20-=20CardDetailDialog=20=EC=9D=98=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=BA=A1=EC=B2=98=C2=B7=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=20=EC=8B=9C=ED=8A=B8=C2=B7=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1?= =?UTF-8?q?/=EC=9D=B8=EC=8A=A4=ED=83=80=EA=B7=B8=EB=9E=A8=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=97=B0=EB=8F=99=EC=9D=84=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=ED=95=9C=EB=8B=A4=20-=20ArchiveDetailScreen=20?= =?UTF-8?q?=EC=9D=98=20=EC=98=9B=20ACTION=5FSEND=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=B0=B0=EC=84=A0(onCardShare/shareCard)=EC=9D=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=9C=EB=8B=A4=20-=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EB=AC=B8=EC=9E=90=EC=97=B4=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=ED=95=98=EA=B3=A0=20=EC=93=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20chooser=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/card/GamssImageCard.kt | 158 +++++++++++------ .../feature/archive/ArchiveDetailScreen.kt | 14 -- .../archive/component/CardDetailDialog.kt | 160 ++++++++++++++++-- .../archive/src/main/res/values/strings.xml | 10 +- 4 files changed, 258 insertions(+), 84 deletions(-) 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 a9e05b61..0d2c6cf8 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 @@ -25,9 +25,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha 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 @@ -129,30 +129,51 @@ private fun GamssImageCardEmotionDarkPreview() { } } +/** + * 카드 아래쪽 점선 밑에 들어가는 것. 시안이 상세 팝업과 공유 이미지 두 가지만 두므로 그대로 나눈다. + */ +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 = true, topEndAction: @Composable BoxScope.() -> Unit = {}, ) { GamssImageCard( @@ -183,48 +204,73 @@ fun GamssEmotionCard( Spacer(modifier = Modifier.height(DividerToContentGap)) GamssCardDashedDivider() Spacer(modifier = Modifier.height(DividerToContentGap)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), - ) { - CardOutlinedButton( - text = primaryActionLabel, - onClick = onPrimaryActionClick, - modifier = Modifier.weight(1f), - ) - CardOutlinedButton( - text = secondaryActionLabel, - onClick = onSecondaryActionClick, - modifier = Modifier.weight(1f), - ) - } - Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) - // 숨겨도 자리는 남긴다. 빼 버리면 카드 안 다른 요소가 아래로 밀려 시안과 어긋난다. - Row( - modifier = Modifier - .fillMaxWidth() - .alpha(if (isShareVisible) 1f else 0f) - .noRippleClickableIfNotNull(onShareClick.takeIf { isShareVisible }), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = shareActionLabel, - style = GamssTheme.typography.body5Medium, - color = GamssTheme.colors.gray600, - ) - Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing025)) - Icon( - painter = painterResource(GamssIcons.RightChevron), - contentDescription = null, - tint = GamssTheme.colors.gray600, - modifier = Modifier.size(GamssTheme.spacing.spacing300), - ) + when (footer) { + is GamssEmotionCardFooter.Actions -> CardActionsFooter(footer) + GamssEmotionCardFooter.Brand -> CardBrandFooter() } } } } +/** 상세 팝업 아래쪽. 버튼 두 개와 그 밑 공유 행. */ +@Composable +private fun CardActionsFooter(actions: GamssEmotionCardFooter.Actions) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), + ) { + CardOutlinedButton( + text = actions.primaryLabel, + onClick = actions.onPrimaryClick, + modifier = Modifier.weight(1f), + ) + CardOutlinedButton( + text = actions.secondaryLabel, + onClick = actions.onSecondaryClick, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickableIfNotNull(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)) + Icon( + painter = painterResource(GamssIcons.RightChevron), + contentDescription = null, + tint = GamssTheme.colors.gray600, + modifier = Modifier.size(GamssTheme.spacing.spacing300), + ) + } +} + +/** + * 공유 이미지 아래쪽. 시안(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), + ) +} + /** * 감정 카드의 캐릭터 영역과 그 아래 콘텐츠 간격을 재사용한다. * @@ -315,12 +361,14 @@ private fun EmotionCardPreviewContent() { character = GamssEmotionCardCharacter.ANGER, title = "오늘 화~나네", description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", - primaryActionLabel = "기록 버리기", - secondaryActionLabel = "대화보기", - shareActionLabel = "공유하기", - onPrimaryActionClick = {}, - onSecondaryActionClick = {}, - onShareClick = {}, + footer = GamssEmotionCardFooter.Actions( + primaryLabel = "기록 버리기", + secondaryLabel = "대화보기", + shareLabel = "공유하기", + onPrimaryClick = {}, + onSecondaryClick = {}, + onShareClick = {}, + ), topEndAction = { CloseIconPlaceholder() }, ) } @@ -430,6 +478,10 @@ private val CharacterToTitleGap = 42.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/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..9d29aba1 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 @@ -63,7 +61,6 @@ 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) // 방금 버린 카드는 목록에 아직 없어 다시 받아야 한다. 파쇄한 카드는 서버에서 이미 지워진 @@ -107,7 +104,6 @@ fun ArchiveDetailScreen( onCardDismiss = viewModel::dismissCard, onCardDiscard = viewModel::discardSelectedCard, onCardConversationClick = viewModel::viewSelectedConversation, - onCardShare = { card -> shareCard(context, card, shareChooserTitle) }, onConversationCardDismiss = viewModel::dismissConversationCard, ) } @@ -164,7 +160,6 @@ private fun ArchiveDetailOverlays( onCardDismiss: () -> Unit, onCardDiscard: () -> Unit, onCardConversationClick: () -> Unit, - onCardShare: (Card) -> Unit, onConversationCardDismiss: () -> Unit, ) { if (state.isMonthPickerVisible) { @@ -189,7 +184,6 @@ private fun ArchiveDetailOverlays( onDismiss = onCardDismiss, onDiscardClick = onCardDiscard, onViewConversationClick = onCardConversationClick, - onShareClick = { onCardShare(card) }, ) } @@ -202,14 +196,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/component/CardDetailDialog.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt index 00199b8d..eae81bc9 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,17 +1,35 @@ package com.gamss.android.feature.archive.component +import android.content.Context import android.content.res.Configuration +import android.widget.Toast 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.Modifier +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( @@ -19,27 +37,119 @@ internal fun CardDetailDialog( onDismiss: () -> Unit, onDiscardClick: () -> Unit, onViewConversationClick: () -> Unit, - onShareClick: () -> Unit, ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val capture = rememberComposeCapture() + 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) + val kakaoTalkText = stringResource( + R.string.archive_card_share_kakaotalk_text, + stringResource(card.character.cardTitleRes()), + CardShareLink.of(card.id), + ) + + // 공유 이미지에는 누를 수 없는 버튼과 닫기 아이콘을 남기지 않는다. 감추는 동안만 켜 두고, + // 그 상태가 실제로 그려진 뒤에 캡처한다. + var isCapturing by remember { mutableStateOf(false) } + var isShareSheetVisible by remember { mutableStateOf(false) } + var isSharing by remember { mutableStateOf(false) } + 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) }, + // 캡처하는 동안에는 액션 대신 워드마크를 넣는다. 누를 수 없는 버튼이 그림에 + // 남지 않고, 시안(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 = { isShareSheetVisible = true }, + ) + }, + modifier = Modifier.captureTo(capture), + topEndAction = { if (!isCapturing) CardCloseButton(onClick = onDismiss) }, + ) + } + + // 시트는 카드와 다른 창에 뜨므로 캡처한 그림에 섞이지 않는다. 그래도 공유를 시작하면 + // 먼저 닫아, 인스타그램으로 넘어가는 동안 시트가 남지 않게 한다. + if (isShareSheetVisible) { + ShareTargetSheet( + onKakaoTalkClick = { + isShareSheetVisible = false + if (!context.shareTextToKakaoTalk(kakaoTalkText)) { + Toast.makeText(context, kakaoTalkUnavailableMessage, Toast.LENGTH_LONG).show() + } + }, + onInstagramStoryClick = { + isShareSheetVisible = false + if (!isSharing) { + isSharing = true + scope.launch { + try { + val failure = shareCardToStory( + context = context, + capture = capture, + instagramUnavailableMessage = instagramUnavailableMessage, + imageUnavailableMessage = imageUnavailableMessage, + setCapturing = { isCapturing = it }, + ) + failure?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } finally { + isSharing = false + } + } + } + }, + onDismiss = { isShareSheetVisible = false }, ) } } +/** + * 카드를 캡처해 인스타그램 스토리로 넘긴다. 실패하면 사용자에게 보일 말을, 성공하면 `null` 을 준다. + * + * [setCapturing] 은 try/finally 로 되돌린다. 캡처가 예외나 시간 초과로 끊겨도 버튼과 닫기 + * 아이콘이 사라진 카드만 남는 상태를 만들지 않으려는 것이다. + */ +private suspend fun shareCardToStory( + context: Context, + capture: ComposeCapture, + instagramUnavailableMessage: String, + imageUnavailableMessage: String, + setCapturing: (Boolean) -> Unit, +): String? { + val bitmap = try { + setCapturing(true) + capture.captureAfterNextDraw() + } finally { + setCapturing(false) + } + if (bitmap == null) return imageUnavailableMessage + + return try { + when (context.shareBitmapToInstagramStory(bitmap)) { + StoryShareResult.Shared -> null + StoryShareResult.InstagramUnavailable -> instagramUnavailableMessage + StoryShareResult.ImageUnavailable -> imageUnavailableMessage + } + } finally { + bitmap.recycle() + } +} + @Preview(name = "Light", showBackground = true) @Suppress("UnusedPrivateMember") @Composable @@ -63,6 +173,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 +196,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/res/values/strings.xml b/feature/archive/src/main/res/values/strings.xml index 9b207e7b..dd05b1e6 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 + 인스타그램이 설치되어 있지 않아요 + 카카오톡이 설치되어 있지 않아요 + 공유 이미지를 만들지 못했어요. 다시 시도해주세요 대화를 불러오지 못했어요. 남아 있는 대화가 없어요. 마지막 대화로 이동 From 2ea9e36e30c9ac4aa7e6bc483700842a061c4807 Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 8 Sep 2026 00:18:22 +0900 Subject: [PATCH 07/20] =?UTF-8?q?chore:=20=EA=B3=B5=EC=9C=A0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=20=EB=A0=88=EB=B2=A8=EC=9D=84=20?= =?UTF-8?q?warn=EC=97=90=EC=84=9C=20info=EB=A1=9C=20=EB=82=AE=EC=B6=98?= =?UTF-8?q?=EB=8B=A4=20-=20=EC=9D=B8=EC=8A=A4=ED=83=80=EA=B7=B8=EB=9E=A8/?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1=20=EB=AF=B8=EC=84=A4?= =?UTF-8?q?=EC=B9=98=C2=B7=EA=B6=8C=ED=95=9C=20=EA=B1=B0=EB=B6=80=EC=B2=98?= =?UTF-8?q?=EB=9F=BC=20=EC=9D=B4=EB=AF=B8=20=EC=B2=98=EB=A6=AC=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=8B=A4=ED=8C=A8=EB=9D=BC=20warn=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20info=EB=A1=9C=20=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/core/ui/share/InstagramStoryShare.kt | 14 +++++++------- .../gamss/android/core/ui/share/KakaoTalkShare.kt | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) 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 index 10cb33ec..0918cf51 100644 --- 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 @@ -76,7 +76,7 @@ fun Context.shareBitmapToInstagramStory( Log.i(TAG, "인스타그램 스토리 공유를 받을 액티비티가 없다", e) StoryShareResult.InstagramUnavailable } catch (e: SecurityException) { - Log.w(TAG, "인스타그램 스토리 공유 액티비티를 실행할 권한이 없다", e) + Log.i(TAG, "인스타그램 스토리 공유 액티비티를 실행할 권한이 없다", e) StoryShareResult.InstagramUnavailable } } @@ -140,7 +140,7 @@ private fun Bitmap.toSoftwareBitmap(): Bitmap? = private fun Bitmap.saveToShareCache(context: Context): Uri? { val dir = File(context.cacheDir, SHARE_CACHE_DIR) if (!dir.exists() && !dir.mkdirs()) { - Log.w(TAG, "공유 캐시 디렉터리를 만들지 못했다: $dir") + Log.i(TAG, "공유 캐시 디렉터리를 만들지 못했다: $dir") return null } dir.deleteFilesOlderThan(SHARE_FILE_TTL_MILLIS) @@ -152,21 +152,21 @@ private fun Bitmap.saveToShareCache(context: Context): Uri? { compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out) } if (!compressed) { - Log.w(TAG, "공유 이미지를 PNG로 압축하지 못했다") + Log.i(TAG, "공유 이미지를 PNG로 압축하지 못했다") file.delete() return null } FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } catch (e: IOException) { - Log.w(TAG, "공유 이미지를 캐시에 쓰지 못했다", e) + Log.i(TAG, "공유 이미지를 캐시에 쓰지 못했다", e) file?.delete() null } catch (e: IllegalArgumentException) { - Log.w(TAG, "공유 이미지의 FileProvider URI를 만들지 못했다", e) + Log.i(TAG, "공유 이미지의 FileProvider URI를 만들지 못했다", e) file?.delete() null } catch (e: SecurityException) { - Log.w(TAG, "공유 이미지 캐시에 접근할 권한이 없다", e) + Log.i(TAG, "공유 이미지 캐시에 접근할 권한이 없다", e) file?.delete() null } @@ -176,7 +176,7 @@ private fun File.deleteFilesOlderThan(ageMillis: Long) { val threshold = System.currentTimeMillis() - ageMillis listFiles()?.forEach { file -> if (file.lastModified() < threshold && !file.delete()) { - Log.w(TAG, "지난 공유 이미지를 지우지 못했다: $file") + 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 index 67f465f0..4726cd46 100644 --- 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 @@ -32,7 +32,7 @@ fun Context.shareTextToKakaoTalk(text: String): Boolean { Log.i(TAG, "카카오톡 공유를 받을 액티비티가 없다", e) false } catch (e: SecurityException) { - Log.w(TAG, "카카오톡 공유 액티비티를 실행할 권한이 없다", e) + Log.i(TAG, "카카오톡 공유 액티비티를 실행할 권한이 없다", e) false } } From 23984a871e3b275d4b84c982d89f04a7d40d75ac Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 8 Sep 2026 00:25:05 +0900 Subject: [PATCH 08/20] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=92=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EA=B3=A0=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20nul?= =?UTF-8?q?lable=20=ED=81=B4=EB=A6=AD=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamss/android/core/designsystem/card/GamssImageCard.kt | 5 +++-- feature/archive/src/main/res/values/strings.xml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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 0d2c6cf8..cac16661 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 @@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale 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 @@ -44,7 +45,7 @@ import com.gamss.android.core.designsystem.component.chat.ChatSender import com.gamss.android.core.designsystem.component.chat.GamssChatBubbleDefaults import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble -import com.gamss.android.core.designsystem.modifier.noRippleClickableIfNotNull +import com.gamss.android.core.designsystem.modifier.noRippleClickable import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.theme.LightGamssColors import com.gamss.android.core.designsystem.theme.LocalGamssColors @@ -234,7 +235,7 @@ private fun CardActionsFooter(actions: GamssEmotionCardFooter.Actions) { Row( modifier = Modifier .fillMaxWidth() - .noRippleClickableIfNotNull(actions.onShareClick), + .noRippleClickable(role = Role.Button, onClick = actions.onShareClick), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/feature/archive/src/main/res/values/strings.xml b/feature/archive/src/main/res/values/strings.xml index dd05b1e6..f6e736b0 100644 --- a/feature/archive/src/main/res/values/strings.xml +++ b/feature/archive/src/main/res/values/strings.xml @@ -28,7 +28,7 @@ 공유 시트 닫기 카카오톡 인스타그램 스토리 - + %1$s\n%2$s 인스타그램이 설치되어 있지 않아요 카카오톡이 설치되어 있지 않아요 From 2e68cfdf0b5c2114cf408c81d8df05a9a9551eb7 Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 8 Sep 2026 00:35:48 +0900 Subject: [PATCH 09/20] =?UTF-8?q?fix:=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=ED=86=A1=20=EA=B3=B5=EC=9C=A0=20=EC=A4=91=EB=B3=B5=20=ED=83=AD?= =?UTF-8?q?=EC=9D=84=20=EB=A7=89=EA=B3=A0=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=8B=A4=ED=8C=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4=20-=20CardDeta?= =?UTF-8?q?ilDialog=20=EC=97=90=EC=84=9C=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=ED=86=A1=20=EA=B3=B5=EC=9C=A0=EB=8F=84=20isSharing=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=90=EC=8B=B8=20=EC=8B=9C=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EB=8B=AB=ED=9E=88=EB=8A=94=20=EB=8F=99=EC=95=88=20=EB=91=90=20?= =?UTF-8?q?=EB=B2=88=20=EB=88=8C=EB=A0=A4=20ACTION=5FSEND=20=EA=B0=80=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=90=98=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20-=20=EC=9D=B8=EC=8A=A4=ED=83=80=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8/=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1=20=EB=AF=B8?= =?UTF-8?q?=EC=84=A4=EC=B9=98(ActivityNotFoundException),=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=BA=90=EC=8B=9C=20=EC=93=B0=EA=B8=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8(ImageUnavailable)=20=EA=B3=84=EC=B8=A1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/share/CardShareInstrumentedTest.kt | 51 +++++++++++++++++-- .../core/ui/share/InstagramStoryShare.kt | 41 ++++++++------- .../archive/component/CardDetailDialog.kt | 11 +++- 3 files changed, 77 insertions(+), 26 deletions(-) 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 index 65a66c5b..99cc5047 100644 --- a/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt @@ -1,5 +1,6 @@ package com.gamss.android.app.share +import android.content.ActivityNotFoundException import android.content.Context import android.content.ContextWrapper import android.content.Intent @@ -14,6 +15,7 @@ import java.io.File 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 @@ -39,7 +41,14 @@ class CardShareInstrumentedTest { @Test fun 카카오톡_공유가_보안_예외로_막히면_false를_반환한다() { - val context = SecurityFailureContext(applicationContext) + val context = ThrowingContext(applicationContext, SecurityException("test")) + + assertFalse(context.shareTextToKakaoTalk("공유 문구")) + } + + @Test + fun 카카오톡이_없으면_false를_반환한다() { + val context = ThrowingContext(applicationContext, ActivityNotFoundException("test")) assertFalse(context.shareTextToKakaoTalk("공유 문구")) } @@ -84,6 +93,41 @@ class CardShareInstrumentedTest { assertFalse(expired.exists()) } + @Test + fun 인스타그램이_없으면_InstagramUnavailable을_반환한다() { + 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을_반환한다() { + // 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 @@ -92,9 +136,10 @@ class CardShareInstrumentedTest { } } - private class SecurityFailureContext(base: Context) : ContextWrapper(base) { + /** [startActivity] 를 호출하면 [exception] 을 던진다. 공유 대상 앱이 없거나 권한이 없는 상황을 흉내낸다. */ + private class ThrowingContext(base: Context, private val exception: RuntimeException) : ContextWrapper(base) { override fun startActivity(intent: Intent) { - throw SecurityException("test") + throw exception } } } 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 index 0918cf51..41269005 100644 --- 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 @@ -24,7 +24,6 @@ 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" -/** PNG 는 무손실이라 이 값이 파일 크기에만 영향을 준다. */ private const val PNG_QUALITY = 100 private const val SHARE_CACHE_DIR = "shared_images" @@ -98,21 +97,21 @@ private fun Context.createStoryImageUri(bitmap: Bitmap, @ColorInt backgroundColo */ private fun Bitmap.toStoryCanvas(@ColorInt backgroundColor: Int): Bitmap? { val bounds = storyCardBounds(width, height) ?: return null - val source = toSoftwareBitmap() ?: return null - - val story = createBitmap(STORY_WIDTH, STORY_HEIGHT) - val canvas = Canvas(story) - canvas.drawColor(backgroundColor) - canvas.drawBitmap( - source, - null, - RectF(bounds.left, bounds.top, bounds.right, bounds.bottom), - Paint(Paint.FILTER_BITMAP_FLAG), - ) - // 변환 때문에 새로 만든 복사본만 해제한다. 원본은 호출부 소유다. - if (source !== this) source.recycle() - - return story + + return toSoftwareBitmap()?.let { source -> + val story = createBitmap(STORY_WIDTH, STORY_HEIGHT) + val canvas = Canvas(story) + canvas.drawColor(backgroundColor) + canvas.drawBitmap( + source, + null, + RectF(bounds.left, bounds.top, bounds.right, bounds.bottom), + Paint(Paint.FILTER_BITMAP_FLAG), + ) + // 변환 때문에 새로 만든 복사본만 해제한다. 원본은 호출부 소유다. + if (source !== this) source.recycle() + story + } } /** @@ -151,11 +150,7 @@ private fun Bitmap.saveToShareCache(context: Context): Uri? { val compressed = FileOutputStream(file).use { out -> compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out) } - if (!compressed) { - Log.i(TAG, "공유 이미지를 PNG로 압축하지 못했다") - file.delete() - return null - } + check(compressed) { "공유 이미지를 PNG로 압축하지 못했다" } FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } catch (e: IOException) { Log.i(TAG, "공유 이미지를 캐시에 쓰지 못했다", e) @@ -165,6 +160,10 @@ private fun Bitmap.saveToShareCache(context: Context): Uri? { 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() 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 eae81bc9..31dbad04 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 @@ -87,8 +87,15 @@ internal fun CardDetailDialog( ShareTargetSheet( onKakaoTalkClick = { isShareSheetVisible = false - if (!context.shareTextToKakaoTalk(kakaoTalkText)) { - Toast.makeText(context, kakaoTalkUnavailableMessage, Toast.LENGTH_LONG).show() + // 시트가 닫히는 애니메이션 도중 행이 두 번 눌리는 걸 막는다. 카카오톡 호출은 + // 동기라 인스타그램처럼 코루틴으로 감쌀 필요는 없지만, 같은 isSharing 으로 + // 재진입을 막아 공유 실행 창을 하나로 유지한다. + if (!isSharing) { + isSharing = true + if (!context.shareTextToKakaoTalk(kakaoTalkText)) { + Toast.makeText(context, kakaoTalkUnavailableMessage, Toast.LENGTH_LONG).show() + } + isSharing = false } }, onInstagramStoryClick = { From 1cafcb6e96e59cb3e3f7584970df085174bb8794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Wed, 9 Sep 2026 14:10:02 +0900 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20IO=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공유 이미지 캐시 생성 작업을 suspend 함수로 전환해 IO 디스패처에서 실행 - 공유 배경색을 내부 기본값으로 고정해 호출부의 불필요한 파라미터 제거 - 변경된 suspend API에 맞춰 공유 계측 테스트를 runBlocking으로 실행 --- .../android/app/share/CardShareInstrumentedTest.kt | 9 +++++---- .../android/core/ui/share/InstagramStoryShare.kt | 11 +++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) 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 index 99cc5047..4a88a807 100644 --- a/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt @@ -12,6 +12,7 @@ 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 @@ -54,7 +55,7 @@ class CardShareInstrumentedTest { } @Test - fun 인스타그램_공유_인텐트에_FileProvider_Uri와_읽기_권한을_담는다() { + fun 인스타그램_공유_인텐트에_FileProvider_Uri와_읽기_권한을_담는다() = runBlocking { val context = RecordingContext(applicationContext) val bitmap = createBitmap(width = 20, height = 30) @@ -75,7 +76,7 @@ class CardShareInstrumentedTest { } @Test - fun 새_스토리_이미지를_만들기_전에_한시간_지난_캐시를_지운다() { + fun 새_스토리_이미지를_만들기_전에_한시간_지난_캐시를_지운다() = runBlocking { val shareDir = File(applicationContext.cacheDir, "shared_images").apply { mkdirs() } val expired = File(shareDir, "expired.png").apply { writeBytes(byteArrayOf(1)) @@ -94,7 +95,7 @@ class CardShareInstrumentedTest { } @Test - fun 인스타그램이_없으면_InstagramUnavailable을_반환한다() { + fun 인스타그램이_없으면_InstagramUnavailable을_반환한다() = runBlocking { val context = ThrowingContext(applicationContext, ActivityNotFoundException("test")) val bitmap = createBitmap(width = 20, height = 30) @@ -108,7 +109,7 @@ class CardShareInstrumentedTest { } @Test - fun 공유_캐시_디렉터리_자리에_파일이_있으면_ImageUnavailable을_반환한다() { + fun 공유_캐시_디렉터리_자리에_파일이_있으면_ImageUnavailable을_반환한다() = runBlocking { // shared_images 자리를 디렉터리가 아닌 일반 파일로 선점해, 캐시 쓰기가 실제로 // 실패하는 경로(IOException)를 재현한다. val shareDirPath = File(applicationContext.cacheDir, "shared_images") 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 index 41269005..d5b03123 100644 --- 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 @@ -14,6 +14,8 @@ 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 @@ -51,15 +53,16 @@ sealed interface StoryShareResult { * * 인스타그램은 넘겨받은 배경 이미지를 9:16 캔버스에 꽉 채우므로, 카드를 그대로 보내면 비율이 * 맞지 않아 잘리고 여백도 사라진다. 그래서 미리 9:16 캔버스 가운데에 카드를 놓고 남는 곳을 - * [backgroundColor] 로 채운 이미지를 만들어 보낸다. + * 공유용 카드 배경색으로 채운 이미지를 만들어 보낸다. * * [bitmap] 은 여기서 해제하지 않는다. 호출부가 계속 들고 있을 수 있으므로 소유권을 넘기지 않는다. */ -fun Context.shareBitmapToInstagramStory( +suspend fun Context.shareBitmapToInstagramStory( bitmap: Bitmap, - @ColorInt backgroundColor: Int = GamssCardExportBackground.toArgb(), ): StoryShareResult { - val uri = createStoryImageUri(bitmap, backgroundColor) ?: return StoryShareResult.ImageUnavailable + val uri = withContext(Dispatchers.IO) { + createStoryImageUri(bitmap, GamssCardExportBackground.toArgb()) + } ?: return StoryShareResult.ImageUnavailable val intent = Intent(ADD_TO_STORY_ACTION).apply { setDataAndType(uri, PNG_MIME_TYPE) From 3a5db89ae3625e8671c448fe23a438d4d72eabbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Wed, 9 Sep 2026 14:33:52 +0900 Subject: [PATCH 11/20] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EA=B3=B5=EC=9C=A0=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공유 함수가 사용자 메시지 대신 결과 타입을 반환하도록 변경 - 다이얼로그에서 공유 결과에 따라 토스트 메시지를 결정 - 공유 캐시 정리 대상을 공유 이미지 파일로 제한 --- .../core/ui/share/InstagramStoryShare.kt | 2 +- .../archive/component/CardDetailDialog.kt | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) 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 index d5b03123..1d00044e 100644 --- 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 @@ -176,7 +176,7 @@ private fun Bitmap.saveToShareCache(context: Context): Uri? { private fun File.deleteFilesOlderThan(ageMillis: Long) { val threshold = System.currentTimeMillis() - ageMillis - listFiles()?.forEach { file -> + listFiles { file -> file.name.startsWith(SHARE_FILE_PREFIX) }?.forEach { file -> if (file.lastModified() < threshold && !file.delete()) { Log.i(TAG, "지난 공유 이미지를 지우지 못했다: $file") } 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 31dbad04..6fc0edbb 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 @@ -104,14 +104,17 @@ internal fun CardDetailDialog( isSharing = true scope.launch { try { - val failure = shareCardToStory( + val result = shareCardToStory( context = context, capture = capture, - instagramUnavailableMessage = instagramUnavailableMessage, - imageUnavailableMessage = imageUnavailableMessage, setCapturing = { isCapturing = it }, ) - failure?.let { message -> + val errorMessage = when (result) { + StoryShareResult.InstagramUnavailable -> instagramUnavailableMessage + StoryShareResult.ImageUnavailable -> imageUnavailableMessage + StoryShareResult.Shared -> null + } + errorMessage?.let { message -> Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } finally { @@ -126,7 +129,7 @@ internal fun CardDetailDialog( } /** - * 카드를 캡처해 인스타그램 스토리로 넘긴다. 실패하면 사용자에게 보일 말을, 성공하면 `null` 을 준다. + * 카드를 캡처해 인스타그램 스토리로 넘기고 공유 결과를 돌려준다. * * [setCapturing] 은 try/finally 로 되돌린다. 캡처가 예외나 시간 초과로 끊겨도 버튼과 닫기 * 아이콘이 사라진 카드만 남는 상태를 만들지 않으려는 것이다. @@ -134,24 +137,18 @@ internal fun CardDetailDialog( private suspend fun shareCardToStory( context: Context, capture: ComposeCapture, - instagramUnavailableMessage: String, - imageUnavailableMessage: String, setCapturing: (Boolean) -> Unit, -): String? { +): StoryShareResult { val bitmap = try { setCapturing(true) capture.captureAfterNextDraw() } finally { setCapturing(false) } - if (bitmap == null) return imageUnavailableMessage + if (bitmap == null) return StoryShareResult.ImageUnavailable return try { - when (context.shareBitmapToInstagramStory(bitmap)) { - StoryShareResult.Shared -> null - StoryShareResult.InstagramUnavailable -> instagramUnavailableMessage - StoryShareResult.ImageUnavailable -> imageUnavailableMessage - } + context.shareBitmapToInstagramStory(bitmap) } finally { bitmap.recycle() } From f57f789ae66487cd9d03f196f1284029d7b7af9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Wed, 9 Sep 2026 14:53:49 +0900 Subject: [PATCH 12/20] =?UTF-8?q?test:=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=BA=90=EC=8B=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 만료된 공유 이미지 캐시를 스토리 공유 파일명 규칙에 맞게 생성 - 한 시간 지난 캐시 삭제 검증이 실제 대상 파일 패턴을 따르도록 수정 --- .../com/gamss/android/app/share/CardShareInstrumentedTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4a88a807..05b78667 100644 --- a/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/com/gamss/android/app/share/CardShareInstrumentedTest.kt @@ -78,7 +78,7 @@ class CardShareInstrumentedTest { @Test fun 새_스토리_이미지를_만들기_전에_한시간_지난_캐시를_지운다() = runBlocking { val shareDir = File(applicationContext.cacheDir, "shared_images").apply { mkdirs() } - val expired = File(shareDir, "expired.png").apply { + val expired = File(shareDir, "story_share_expired.png").apply { writeBytes(byteArrayOf(1)) setLastModified(System.currentTimeMillis() - 2 * 60 * 60 * 1_000L) } From 670ab96092170dee46ae86498eef679a0f73c298 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 10 Sep 2026 02:51:19 +0900 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B0=B0=EA=B2=BD?= =?UTF-8?q?=EC=83=89=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EC=83=81=EC=88=98=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배경색을 파라미터로 넘기지 않고 GamssCardExportBackground 를 직접 참조하도록 정리 - 인스타그램 미설치/권한 부족으로 공유에 실패하면 생성해둔 캐시 이미지 URI 를 삭제 --- .../android/core/ui/share/InstagramStoryShare.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 index 1d00044e..22e301f3 100644 --- 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 @@ -9,7 +9,6 @@ import android.graphics.Paint import android.graphics.RectF import android.net.Uri import android.util.Log -import androidx.annotation.ColorInt import androidx.compose.ui.graphics.toArgb import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap @@ -61,7 +60,7 @@ suspend fun Context.shareBitmapToInstagramStory( bitmap: Bitmap, ): StoryShareResult { val uri = withContext(Dispatchers.IO) { - createStoryImageUri(bitmap, GamssCardExportBackground.toArgb()) + createStoryImageUri(bitmap) } ?: return StoryShareResult.ImageUnavailable val intent = Intent(ADD_TO_STORY_ACTION).apply { @@ -76,16 +75,18 @@ suspend fun Context.shareBitmapToInstagramStory( 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 } } /** 공유용 9:16 이미지를 만들어 캐시에 쓰고 URI 를 준다. 만들지 못하면 `null`. */ -private fun Context.createStoryImageUri(bitmap: Bitmap, @ColorInt backgroundColor: Int): Uri? { - val story = bitmap.toStoryCanvas(backgroundColor) ?: return null +private fun Context.createStoryImageUri(bitmap: Bitmap): Uri? { + val story = bitmap.toStoryCanvas() ?: return null return try { story.saveToShareCache(this) } finally { @@ -95,16 +96,16 @@ private fun Context.createStoryImageUri(bitmap: Bitmap, @ColorInt backgroundColo } /** - * 9:16 캔버스 가운데에 원본 비율을 유지한 카드를 놓고 나머지를 [backgroundColor] 로 채운다. + * 9:16 캔버스 가운데에 원본 비율을 유지한 카드를 놓고 나머지를 [GamssCardExportBackground] 로 채운다. * 카드 크기가 0 이거나 소프트웨어 비트맵으로 바꾸지 못하면 그릴 수 없어 `null`. */ -private fun Bitmap.toStoryCanvas(@ColorInt backgroundColor: Int): Bitmap? { +private fun Bitmap.toStoryCanvas(): Bitmap? { val bounds = storyCardBounds(width, height) ?: return null return toSoftwareBitmap()?.let { source -> val story = createBitmap(STORY_WIDTH, STORY_HEIGHT) val canvas = Canvas(story) - canvas.drawColor(backgroundColor) + canvas.drawColor(GamssCardExportBackground.toArgb()) canvas.drawBitmap( source, null, From 8c8f4a02633aa72b71b871aa0ee37b8e1375553c Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 10 Sep 2026 03:23:47 +0900 Subject: [PATCH 14/20] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=20=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EC=BA=A1=EC=B2=98=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=EB=A1=9C=20=ED=86=B5=ED=95=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - captureAfterNextDraw를 captureWith로 바꿔 setCapturing on/off를 캡처 함수가 직접 책임지고, 그리기 대기 구간에만 타임아웃을 걸어 캡처 자체는 취소되지 않게 한다 - captureTo에 isCapturing 플래그를 추가해 캡처 중이 아닐 때는 오프스크린 레이어 기록 없이 바로 그리도록 해 불필요한 레이어 비용을 없앤다 - toStoryCanvas에서 story 비트맵 생성이 실패해도 복사본 source는 try/finally로 반드시 recycle되게 한다 --- .../android/core/ui/share/ComposeCapture.kt | 43 +++++++++++++------ .../core/ui/share/InstagramStoryShare.kt | 26 +++++------ .../archive/component/CardDetailDialog.kt | 14 ++---- 3 files changed, 47 insertions(+), 36 deletions(-) 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 index 4f56205c..1bdd03ee 100644 --- 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 @@ -20,7 +20,7 @@ private const val CAPTURE_TIMEOUT_MILLIS = 1_000L * * 공유 전에 버튼을 감추는 것처럼 화면을 바꾼 뒤 캡처해야 할 때가 있는데, 바뀐 그림이 레이어에 * 들어오는 시점은 프레임 수로 셀 수 없다. 그래서 그리기가 실제로 끝날 때마다 신호를 남기고 - * [captureAfterNextDraw] 가 그 신호를 기다린다. + * [captureWith] 가 그 신호를 기다린다. */ class ComposeCapture internal constructor(internal val layer: GraphicsLayer) { @@ -32,17 +32,24 @@ class ComposeCapture internal constructor(internal val layer: GraphicsLayer) { } /** - * 다음 그리기가 끝난 뒤의 그림을 준다. 그 사이 화면을 바꿔 뒀다면 바뀐 그림이 담긴다. - * [CAPTURE_TIMEOUT_MILLIS] 안에 한 번도 그려지지 않으면 `null`. + * [setCapturing] 으로 캡처용 화면 전환을 켠 뒤, 그 전환이 실제로 그려진 다음의 그림을 준다. + * 상태를 켜고 끄는 순서를 이 함수가 스스로 지켜서, 호출부가 순서를 잘못 둬 이전 프레임이 + * 그대로 캡처될 일이 없다. 시간 초과나 예외로 끊겨도 [setCapturing] 은 항상 꺼진 채로 끝난다. * + * [CAPTURE_TIMEOUT_MILLIS] 안에 한 번도 그려지지 않으면 `null`. * 호출부는 다 쓴 비트맵을 직접 [Bitmap.recycle] 한다. */ - suspend fun captureAfterNextDraw(): Bitmap? { - // 이 호출 전에 쌓인 신호는 바꾸기 전 그림이므로 버린다. - while (draws.tryReceive().isSuccess) Unit - return withTimeoutOrNull(CAPTURE_TIMEOUT_MILLIS) { - draws.receive() - layer.toImageBitmap().asAndroidBitmap() + 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) } } } @@ -53,10 +60,18 @@ fun rememberComposeCapture(): ComposeCapture { return remember(layer) { ComposeCapture(layer) } } -/** 이 컴포저블이 그려질 때마다 [capture] 에 같은 그림을 남긴다. */ -fun Modifier.captureTo(capture: ComposeCapture): Modifier = +/** + * [isCapturing] 인 동안만 이 컴포저블을 [capture] 의 오프스크린 레이어에 기록해 캡처할 수 있게 + * 한다. 레이어 기록 자체가 그리기 비용이라, 캡처를 시도하지 않을 때는 화면에 바로 그려 여분의 + * 레이어를 물고 있지 않는다. + */ +fun Modifier.captureTo(capture: ComposeCapture, isCapturing: Boolean): Modifier = drawWithContent { - capture.layer.record { this@drawWithContent.drawContent() } - drawLayer(capture.layer) - capture.onDrawn() + 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 index 22e301f3..92c918eb 100644 --- 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 @@ -103,18 +103,20 @@ private fun Bitmap.toStoryCanvas(): Bitmap? { val bounds = storyCardBounds(width, height) ?: return null return toSoftwareBitmap()?.let { source -> - 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), - ) - // 변환 때문에 새로 만든 복사본만 해제한다. 원본은 호출부 소유다. - if (source !== this) source.recycle() - story + 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() + } } } 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 6fc0edbb..af66a58d 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 @@ -76,7 +76,7 @@ internal fun CardDetailDialog( onShareClick = { isShareSheetVisible = true }, ) }, - modifier = Modifier.captureTo(capture), + modifier = Modifier.captureTo(capture, isCapturing = isCapturing), topEndAction = { if (!isCapturing) CardCloseButton(onClick = onDismiss) }, ) } @@ -131,21 +131,15 @@ internal fun CardDetailDialog( /** * 카드를 캡처해 인스타그램 스토리로 넘기고 공유 결과를 돌려준다. * - * [setCapturing] 은 try/finally 로 되돌린다. 캡처가 예외나 시간 초과로 끊겨도 버튼과 닫기 - * 아이콘이 사라진 카드만 남는 상태를 만들지 않으려는 것이다. + * [setCapturing] 을 켜고 끄는 순서는 [ComposeCapture.captureWith] 가 스스로 지킨다. 캡처가 + * 예외나 시간 초과로 끊겨도 버튼과 닫기 아이콘이 사라진 카드만 남는 상태를 만들지 않는다. */ private suspend fun shareCardToStory( context: Context, capture: ComposeCapture, setCapturing: (Boolean) -> Unit, ): StoryShareResult { - val bitmap = try { - setCapturing(true) - capture.captureAfterNextDraw() - } finally { - setCapturing(false) - } - if (bitmap == null) return StoryShareResult.ImageUnavailable + val bitmap = capture.captureWith(setCapturing) ?: return StoryShareResult.ImageUnavailable return try { context.shareBitmapToInstagramStory(bitmap) From b94b304d7152d425e50b222fd17c7414bc295f07 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 10 Sep 2026 03:54:38 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refactor:=20=EC=95=84=EC=B9=B4=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EC=B9=B4=EB=93=9C=20=EA=B3=B5=EC=9C=A0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EB=B7=B0=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20UI=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20State=EB=A1=9C=20=EC=8A=B9?= =?UTF-8?q?=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인스타그램/카카오톡 공유 실패 시 Toast를 CardDetailDialog가 아닌 ArchiveDetailScreen이 SideEffect로 처리하도록 이동 - 공유 시트 표시 여부(isShareSheetVisible)를 컴포저블 로컬 상태에서 ArchiveDetailState로 승격해 ViewModel이 소유하도록 변경 - CardDetailDialog는 공유 결과 판단 없이 콜백만 호출하도록 단순화 --- .../feature/archive/ArchiveDetailScreen.kt | 30 ++++++++++++++ .../archive/ArchiveDetailSideEffect.kt | 8 ++++ .../feature/archive/ArchiveDetailState.kt | 1 + .../feature/archive/ArchiveDetailViewModel.kt | 18 +++++++++ .../archive/component/CardDetailDialog.kt | 40 +++++++------------ 5 files changed, 72 insertions(+), 25 deletions(-) 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 9d29aba1..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 @@ -33,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 @@ -62,6 +63,9 @@ fun ArchiveDetailScreen( val state by viewModel.collectAsState() val context = LocalContext.current 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) // 방금 버린 카드는 목록에 아직 없어 다시 받아야 한다. 파쇄한 카드는 서버에서 이미 지워진 // 뒤라, 남은 목록을 그대로 두고 그 한 장만 뺀다. @@ -80,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() } } @@ -105,6 +122,10 @@ fun ArchiveDetailScreen( onCardDiscard = viewModel::discardSelectedCard, onCardConversationClick = viewModel::viewSelectedConversation, onConversationCardDismiss = viewModel::dismissConversationCard, + onCardShareClick = viewModel::showShareSheet, + onCardShareSheetDismiss = viewModel::dismissShareSheet, + onCardInstagramShareFailed = viewModel::reportInstagramShareFailure, + onCardKakaoTalkShareFailed = viewModel::reportKakaoTalkShareFailure, ) } @@ -161,6 +182,10 @@ private fun ArchiveDetailOverlays( onCardDiscard: () -> Unit, onCardConversationClick: () -> Unit, onConversationCardDismiss: () -> Unit, + onCardShareClick: () -> Unit, + onCardShareSheetDismiss: () -> Unit, + onCardInstagramShareFailed: (StoryShareResult) -> Unit, + onCardKakaoTalkShareFailed: () -> Unit, ) { if (state.isMonthPickerVisible) { YearMonthPickerSheet( @@ -181,9 +206,14 @@ private fun ArchiveDetailOverlays( state.selectedCard?.let { card -> CardDetailDialog( card = card, + isShareSheetVisible = state.isShareSheetVisible, onDismiss = onCardDismiss, onDiscardClick = onCardDiscard, onViewConversationClick = onCardConversationClick, + onShareClick = onCardShareClick, + onShareSheetDismiss = onCardShareSheetDismiss, + onInstagramShareFailed = onCardInstagramShareFailed, + onKakaoTalkShareFailed = onCardKakaoTalkShareFailed, ) } 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..b91f6151 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 @@ -7,6 +7,7 @@ import com.gamss.android.domain.card.Card import com.gamss.android.domain.card.GetCardsByMonthAndEmotionUseCase import com.gamss.android.domain.card.MonthlyEmotionQuery import com.gamss.android.domain.conversation.GetConversationUseCase +import com.gamss.android.core.ui.share.StoryShareResult import com.gamss.android.domain.emotion.EmotionCharacter import dagger.hilt.android.lifecycle.HiltViewModel import org.orbitmvi.orbit.ContainerHost @@ -157,6 +158,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 af66a58d..64f85bf4 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 @@ -2,7 +2,6 @@ package com.gamss.android.feature.archive.component import android.content.Context import android.content.res.Configuration -import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -34,16 +33,18 @@ 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 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) val kakaoTalkText = stringResource( R.string.archive_card_share_kakaotalk_text, stringResource(card.character.cardTitleRes()), @@ -53,7 +54,8 @@ internal fun CardDetailDialog( // 공유 이미지에는 누를 수 없는 버튼과 닫기 아이콘을 남기지 않는다. 감추는 동안만 켜 두고, // 그 상태가 실제로 그려진 뒤에 캡처한다. var isCapturing by remember { mutableStateOf(false) } - var isShareSheetVisible by remember { mutableStateOf(false) } + // 인스타그램 공유는 캡처가 끝날 때까지 비동기로 이어지므로, 그 사이 재진입해 캡처를 중복 + // 시작하지 않도록 막는다. var isSharing by remember { mutableStateOf(false) } CardDialogScaffold(onDismiss = onDismiss) { @@ -73,7 +75,7 @@ internal fun CardDetailDialog( shareLabel = stringResource(R.string.archive_card_share), onPrimaryClick = onDiscardClick, onSecondaryClick = onViewConversationClick, - onShareClick = { isShareSheetVisible = true }, + onShareClick = onShareClick, ) }, modifier = Modifier.captureTo(capture, isCapturing = isCapturing), @@ -86,20 +88,13 @@ internal fun CardDetailDialog( if (isShareSheetVisible) { ShareTargetSheet( onKakaoTalkClick = { - isShareSheetVisible = false - // 시트가 닫히는 애니메이션 도중 행이 두 번 눌리는 걸 막는다. 카카오톡 호출은 - // 동기라 인스타그램처럼 코루틴으로 감쌀 필요는 없지만, 같은 isSharing 으로 - // 재진입을 막아 공유 실행 창을 하나로 유지한다. - if (!isSharing) { - isSharing = true - if (!context.shareTextToKakaoTalk(kakaoTalkText)) { - Toast.makeText(context, kakaoTalkUnavailableMessage, Toast.LENGTH_LONG).show() - } - isSharing = false + onShareSheetDismiss() + if (!context.shareTextToKakaoTalk(kakaoTalkText)) { + onKakaoTalkShareFailed() } }, onInstagramStoryClick = { - isShareSheetVisible = false + onShareSheetDismiss() if (!isSharing) { isSharing = true scope.launch { @@ -109,13 +104,8 @@ internal fun CardDetailDialog( capture = capture, setCapturing = { isCapturing = it }, ) - val errorMessage = when (result) { - StoryShareResult.InstagramUnavailable -> instagramUnavailableMessage - StoryShareResult.ImageUnavailable -> imageUnavailableMessage - StoryShareResult.Shared -> null - } - errorMessage?.let { message -> - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + if (result != StoryShareResult.Shared) { + onInstagramShareFailed(result) } } finally { isSharing = false @@ -123,7 +113,7 @@ internal fun CardDetailDialog( } } }, - onDismiss = { isShareSheetVisible = false }, + onDismiss = onShareSheetDismiss, ) } } From f895aec8dd8afeb594785041121da4ccab5e5809 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 10 Sep 2026 06:19:04 +0900 Subject: [PATCH 16/20] =?UTF-8?q?refactor:=20=20import=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/feature/archive/ArchiveDetailViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b91f6151..d4afb943 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,11 +3,11 @@ 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 import com.gamss.android.domain.conversation.GetConversationUseCase -import com.gamss.android.core.ui.share.StoryShareResult import com.gamss.android.domain.emotion.EmotionCharacter import dagger.hilt.android.lifecycle.HiltViewModel import org.orbitmvi.orbit.ContainerHost From 7675e913078fb42387ff5963e8f792f91ea4d3bb Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 15 Sep 2026 23:13:49 +0900 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20=EC=95=B1=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EC=8B=9C=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=BA=90=EC=8B=9C=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스토리 공유 성공 시점에는 캐시 정리 시점이 없어 다음 공유 전까지 파일이 남는 문제를 보완 - 앱 시작 시 clearStaleInstagramStoryShareCache 를 호출해 TTL 지난 캐시 파일을 삭제 --- .../kotlin/com/gamss/android/app/GamssApplication.kt | 2 ++ .../android/core/ui/share/InstagramStoryShare.kt | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt index 651fc1bc..62276802 100644 --- a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt +++ b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.os.Build import android.util.Log import androidx.core.content.getSystemService +import com.gamss.android.core.ui.share.clearStaleInstagramStoryShareCache import com.gamss.android.domain.config.InitializeRemoteConfigUseCase import com.gamss.android.domain.safety.RefreshRiskLexiconUseCase import dagger.hilt.android.HiltAndroidApp @@ -39,6 +40,7 @@ class GamssApplication : Application() { createNotificationChannel() applicationScope.launch { refreshRiskLexicon() } applicationScope.launch { initializeRemoteConfig() } + applicationScope.launch { clearStaleInstagramStoryShareCache() } } private fun createNotificationChannel() { 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 index 92c918eb..e28de351 100644 --- 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 @@ -84,6 +84,17 @@ suspend fun Context.shareBitmapToInstagramStory( } } +/** + * 지난 공유 이미지를 지운다. + * + * 앱을 시작할 때 한 번 이 함수를 불러 오래된 캐시가 다음 공유 전까지 남아있지 않게 한다. + */ +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 From ba5ae6423fb3bb097485a660cb16c3de4b918ac4 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 16 Sep 2026 00:31:12 +0900 Subject: [PATCH 18/20] =?UTF-8?q?fix:=20=EC=95=84=EC=B9=B4=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EC=B9=B4=EB=93=9C=20=EC=BA=A1=EC=B2=98=20=EC=A4=91?= =?UTF-8?q?=20=EC=9B=8C=EB=93=9C=EB=A7=88=ED=81=AC=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=EC=9D=B4=20=EB=85=B8=EC=B6=9C=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=8A=A4=ED=81=AC=EB=A6=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캡처 진행 중 카드 위에 반투명 오버레이를 덮어 액션 영역이 워드마크로 전환되는 과정이 화면에 보이지 않게 함 - 카드를 Box로 감싸 캡처 오버레이를 중첩 배치하도록 구조 변경 --- .../archive/component/CardDetailDialog.kt | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) 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 64f85bf4..ef1db5da 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 @@ -2,13 +2,17 @@ package com.gamss.android.feature.archive.component import android.content.Context import android.content.res.Configuration +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 @@ -59,28 +63,39 @@ internal fun CardDetailDialog( var isSharing by remember { mutableStateOf(false) } CardDialogScaffold(onDismiss = onDismiss) { - 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, + 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), ) - }, - modifier = Modifier.captureTo(capture, isCapturing = isCapturing), - topEndAction = { if (!isCapturing) CardCloseButton(onClick = onDismiss) }, - ) + } + } } // 시트는 카드와 다른 창에 뜨므로 캡처한 그림에 섞이지 않는다. 그래도 공유를 시작하면 @@ -138,6 +153,9 @@ private suspend fun shareCardToStory( } } +/** 캡처 구간 동안 카드를 가리는 반투명 스크림. */ +private val CaptureOverlayColor = Color.Black.copy(alpha = 0.32f) + @Preview(name = "Light", showBackground = true) @Suppress("UnusedPrivateMember") @Composable From 08db7bd7a17dd882d2580ccac593ec8562aa4f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Wed, 16 Sep 2026 08:46:56 +0900 Subject: [PATCH 19/20] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EA=B3=B5=EC=9C=A0=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EC=8B=9C=EC=A0=90=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앱 전역 초기화에서 수행하던 공유 캐시 정리를 Activity 재개 시점으로 이동 - 인스타그램 공유 후 앱으로 돌아올 때 오래된 공유 이미지가 정리되도록 처리 --- .../kotlin/com/gamss/android/app/GamssApplication.kt | 2 -- .../main/kotlin/com/gamss/android/app/MainActivity.kt | 11 +++++++++++ .../android/core/ui/share/InstagramStoryShare.kt | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt index 62276802..651fc1bc 100644 --- a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt +++ b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt @@ -6,7 +6,6 @@ import android.app.NotificationManager import android.os.Build import android.util.Log import androidx.core.content.getSystemService -import com.gamss.android.core.ui.share.clearStaleInstagramStoryShareCache import com.gamss.android.domain.config.InitializeRemoteConfigUseCase import com.gamss.android.domain.safety.RefreshRiskLexiconUseCase import dagger.hilt.android.HiltAndroidApp @@ -40,7 +39,6 @@ class GamssApplication : Application() { createNotificationChannel() applicationScope.launch { refreshRiskLexicon() } applicationScope.launch { initializeRemoteConfig() } - applicationScope.launch { clearStaleInstagramStoryShareCache() } } private fun createNotificationChannel() { 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/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 index e28de351..b45c910a 100644 --- 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 @@ -87,7 +87,8 @@ suspend fun Context.shareBitmapToInstagramStory( /** * 지난 공유 이미지를 지운다. * - * 앱을 시작할 때 한 번 이 함수를 불러 오래된 캐시가 다음 공유 전까지 남아있지 않게 한다. + * 화면이 다시 보일 때마다(앱 시작 포함) 이 함수를 불러 오래된 캐시가 다음 공유 전까지 + * 남아있지 않게 한다. */ fun Context.clearStaleInstagramStoryShareCache() { val dir = File(cacheDir, SHARE_CACHE_DIR) From 1e222d69f3d759f038d92804ee163e0ce37f63d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Wed, 16 Sep 2026 15:24:03 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공유 대상 선택 시 짧은 시간 내 반복 탭을 무시하도록 처리 - 인스타그램 스토리 공유 중 재진입 방지 상태를 명확히 분리 - 카드 닫기 및 삭제 흐름에서 공유 시트 상태도 함께 초기화 --- .../feature/archive/ArchiveDetailViewModel.kt | 5 ++-- .../archive/component/CardDetailDialog.kt | 27 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) 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 d4afb943..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 @@ -86,7 +86,7 @@ class ArchiveDetailViewModel @Inject constructor( } fun dismissCard() = intent { - reduce { state.copy(selectedCard = null) } + reduce { state.copy(selectedCard = null, isShareSheetVisible = false) } } /** @@ -95,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)) } @@ -142,6 +142,7 @@ class ArchiveDetailViewModel @Inject constructor( state.copy( isConversationLoading = false, selectedCard = null, + isShareSheetVisible = false, conversationCard = conversationCard, ) } 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 ef1db5da..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 @@ -2,6 +2,7 @@ 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 @@ -58,9 +59,18 @@ internal fun CardDetailDialog( // 공유 이미지에는 누를 수 없는 버튼과 닫기 아이콘을 남기지 않는다. 감추는 동안만 켜 두고, // 그 상태가 실제로 그려진 뒤에 캡처한다. var isCapturing by remember { mutableStateOf(false) } - // 인스타그램 공유는 캡처가 끝날 때까지 비동기로 이어지므로, 그 사이 재진입해 캡처를 중복 - // 시작하지 않도록 막는다. - var isSharing 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) { Box(contentAlignment = Alignment.Center) { @@ -104,14 +114,14 @@ internal fun CardDetailDialog( ShareTargetSheet( onKakaoTalkClick = { onShareSheetDismiss() - if (!context.shareTextToKakaoTalk(kakaoTalkText)) { + if (consumeShareTap() && !context.shareTextToKakaoTalk(kakaoTalkText)) { onKakaoTalkShareFailed() } }, onInstagramStoryClick = { onShareSheetDismiss() - if (!isSharing) { - isSharing = true + if (consumeShareTap() && !isSharingInstagramStory) { + isSharingInstagramStory = true scope.launch { try { val result = shareCardToStory( @@ -123,7 +133,7 @@ internal fun CardDetailDialog( onInstagramShareFailed(result) } } finally { - isSharing = false + isSharingInstagramStory = false } } } @@ -156,6 +166,9 @@ private suspend fun shareCardToStory( /** 캡처 구간 동안 카드를 가리는 반투명 스크림. */ 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