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