diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a12ba59..9800e69 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "com.killingpart.killingpoint"
minSdk = 29
targetSdk = 36
- versionCode = 40
- versionName = "2.3.1"
+ versionCode = 41
+ versionName = "2.3.2"
buildConfigField("String", "AMPLITUDE_API_KEY", "\"fcb84a98b48f87f85e7112a1587976fd\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -60,6 +60,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("com.kakao.sdk:v2-user:2.21.4")
+ implementation("com.kakao.sdk:v2-share:2.21.4")
implementation("io.coil-kt:coil-compose:2.4.0")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 00e303b..361eaf2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,6 +19,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(null)
}
+ var showUpdateDialog by rememberSaveable {
+ mutableStateOf(false)
+ }
var previousLoginState by remember {
mutableStateOf(null)
@@ -140,6 +165,7 @@ class MainActivity : ComponentActivity() {
val start = repo.getUserInitSettings()
.getOrNull()
?.let { init ->
+ showUpdateDialog = init.app.needsForceUpdate
when {
init.needsPolicyAgreement -> "onboarding_policy"
init.needsTagSetup -> "onboarding_name"
@@ -152,15 +178,18 @@ class MainActivity : ComponentActivity() {
is LoginUiState.Idle, is LoginUiState.Error -> {
resolvedStartDestination = "home"
+ showUpdateDialog = false
}
is LoginUiState.Success -> {
FcmTokenSync.syncCurrentToken(context)
resolvedStartDestination = "home"
+ showUpdateDialog = false
}
is LoginUiState.Loading -> {
resolvedStartDestination = null
+ showUpdateDialog = false
}
}
previousLoginState = loginState
@@ -183,6 +212,8 @@ class MainActivity : ComponentActivity() {
LaunchState.MAIN -> {
val navController = rememberNavController()
+ val currentBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = currentBackStackEntry?.destination?.route
val startDestination = resolvedStartDestination ?: "home"
@@ -251,6 +282,16 @@ class MainActivity : ComponentActivity() {
) { Text("마지막 화면") }
}
}
+
+ if (showUpdateDialog && currentRoute?.startsWith("main") == true) {
+ UpdateRequiredDialog(
+ onDismiss = { showUpdateDialog = false },
+ onUpdateClick = {
+ showUpdateDialog = false
+ openPlayStore(context)
+ }
+ )
+ }
}
}
}
@@ -275,3 +316,46 @@ class MainActivity : ComponentActivity() {
)
}
}
+
+@Composable
+private fun UpdateRequiredDialog(
+ onDismiss: () -> Unit,
+ onUpdateClick: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(text = "업데이트가 필요합니다.")
+ },
+ text = {
+ Text(text = "최신 버전으로 업데이트한 뒤 더 안정적으로 킬링파트를 이용해 주세요.")
+ },
+ confirmButton = {
+ TextButton(onClick = onUpdateClick) {
+ Text(text = "업데이트")
+ }
+ }
+ )
+}
+
+private fun openPlayStore(context: Context) {
+ val packageName = context.packageName
+ val marketIntent = Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("market://details?id=$packageName")
+ ).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val webIntent = Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("https://play.google.com/store/apps/details?id=$packageName")
+ ).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ try {
+ context.startActivity(marketIntent)
+ } catch (_: ActivityNotFoundException) {
+ context.startActivity(webIntent)
+ }
+}
diff --git a/app/src/main/java/com/killingpart/killingpoint/data/local/AlarmReadStore.kt b/app/src/main/java/com/killingpart/killingpoint/data/local/AlarmReadStore.kt
new file mode 100644
index 0000000..97cbf75
--- /dev/null
+++ b/app/src/main/java/com/killingpart/killingpoint/data/local/AlarmReadStore.kt
@@ -0,0 +1,131 @@
+package com.killingpart.killingpoint.data.local
+
+import android.content.Context
+
+object AlarmReadStore {
+ private const val PREF_NAME = "alarm_read_state"
+ // 텍스트 색상(흰색->회색)용: 사용자가 "개별로 탭한" 알림 ID만 저장
+ private const val KEY_READ_ALARM_IDS = "read_alarm_ids"
+ // 레드닷용: 알림 목록에 진입해 "화면에서 본" 알림 ID 저장 (탭 여부와 무관)
+ private const val KEY_SEEN_ALARM_IDS = "seen_alarm_ids"
+ // 로컬 삭제용: 서버 삭제 API가 없어 로컬에서 숨길 알림 ID 저장
+ private const val KEY_DELETED_ALARM_IDS = "deleted_alarm_ids"
+ private const val KEY_HAS_LOCAL_UNREAD = "has_local_unread"
+
+ // ---------- 텍스트 색상(개별 읽음) ----------
+
+ fun getReadAlarmIds(context: Context): Set {
+ return context.applicationContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .getStringSet(KEY_READ_ALARM_IDS, emptySet())
+ .orEmpty()
+ .mapNotNull { it.toLongOrNull() }
+ .toSet()
+ }
+
+ /** 개별 알림 하나를 읽음(회색) 처리한다. 알림을 탭했을 때만 호출한다. */
+ fun markAlarmRead(context: Context, alarmId: Long) {
+ val preferences = context.applicationContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ val updatedIds = preferences
+ .getStringSet(KEY_READ_ALARM_IDS, emptySet())
+ .orEmpty()
+ .toMutableSet()
+ .apply { add(alarmId.toString()) }
+
+ preferences.edit()
+ .putStringSet(KEY_READ_ALARM_IDS, updatedIds)
+ .apply()
+ }
+
+ // ---------- 레드닷(봤음) ----------
+
+ fun getSeenAlarmIds(context: Context): Set {
+ return context.applicationContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .getStringSet(KEY_SEEN_ALARM_IDS, emptySet())
+ .orEmpty()
+ .mapNotNull { it.toLongOrNull() }
+ .toSet()
+ }
+
+ /**
+ * 알림 목록에 진입해 화면에 보여진 알림들을 "봤음"으로 저장한다.
+ * 레드닷만 끄고, 텍스트 색상(개별 읽음)에는 영향을 주지 않는다.
+ */
+ fun markAlarmsSeen(context: Context, alarmIds: Collection) {
+ val appContext = context.applicationContext
+ val preferences = appContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ val updatedIds = preferences
+ .getStringSet(KEY_SEEN_ALARM_IDS, emptySet())
+ .orEmpty()
+ .toMutableSet()
+ .apply { addAll(alarmIds.map { it.toString() }) }
+
+ preferences.edit()
+ .putStringSet(KEY_SEEN_ALARM_IDS, updatedIds)
+ .putBoolean(KEY_HAS_LOCAL_UNREAD, false)
+ .apply()
+ }
+
+ fun hasUnread(context: Context, alarmIds: Collection): Boolean {
+ if (hasLocalUnread(context)) return true
+ if (alarmIds.isEmpty()) return false
+
+ val seenIds = getSeenAlarmIds(context)
+ return alarmIds.any { it !in seenIds }
+ }
+
+ // ---------- 로컬 삭제 ----------
+
+ fun getDeletedAlarmIds(context: Context): Set {
+ return context.applicationContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .getStringSet(KEY_DELETED_ALARM_IDS, emptySet())
+ .orEmpty()
+ .mapNotNull { it.toLongOrNull() }
+ .toSet()
+ }
+
+ /**
+ * 알림들을 로컬에서 삭제(숨김) 처리한다.
+ * 삭제된 알림은 목록에서 제외되고, 다시 나타나 레드닷을 켜지 않도록 seen 에도 포함시킨다.
+ */
+ fun markAlarmsDeleted(context: Context, alarmIds: Collection) {
+ if (alarmIds.isEmpty()) return
+
+ val preferences = context.applicationContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ val idStrings = alarmIds.map { it.toString() }
+
+ val updatedDeleted = preferences
+ .getStringSet(KEY_DELETED_ALARM_IDS, emptySet())
+ .orEmpty()
+ .toMutableSet()
+ .apply { addAll(idStrings) }
+ val updatedSeen = preferences
+ .getStringSet(KEY_SEEN_ALARM_IDS, emptySet())
+ .orEmpty()
+ .toMutableSet()
+ .apply { addAll(idStrings) }
+
+ preferences.edit()
+ .putStringSet(KEY_DELETED_ALARM_IDS, updatedDeleted)
+ .putStringSet(KEY_SEEN_ALARM_IDS, updatedSeen)
+ .apply()
+ }
+
+ fun markLocalUnread(context: Context) {
+ context.applicationContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putBoolean(KEY_HAS_LOCAL_UNREAD, true)
+ .apply()
+ }
+
+ fun hasLocalUnread(context: Context): Boolean {
+ return context.applicationContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .getBoolean(KEY_HAS_LOCAL_UNREAD, false)
+ }
+}
diff --git a/app/src/main/java/com/killingpart/killingpoint/data/remote/ApiService.kt b/app/src/main/java/com/killingpart/killingpoint/data/remote/ApiService.kt
index ea80377..22d7960 100644
--- a/app/src/main/java/com/killingpart/killingpoint/data/remote/ApiService.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/data/remote/ApiService.kt
@@ -65,8 +65,8 @@ interface ApiService {
@GET("users/init-settings")
suspend fun getUserInitSettings(
@Header("Authorization") accessToken: String,
- @Query("clientType") clientType: String,
- @Query("clientVersion") clientVersion: String
+ @Query("clientVersion") clientVersion: String,
+ @Query("clientType") clientType: String
): UserInitSettingsResponse
@POST("users/policy-agreement")
diff --git a/app/src/main/java/com/killingpart/killingpoint/data/repository/AuthRepository.kt b/app/src/main/java/com/killingpart/killingpoint/data/repository/AuthRepository.kt
index e218ae1..6cb4102 100644
--- a/app/src/main/java/com/killingpart/killingpoint/data/repository/AuthRepository.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/data/repository/AuthRepository.kt
@@ -2,6 +2,7 @@ package com.killingpart.killingpoint.data.repository
import android.R
import android.content.Context
+import com.killingpart.killingpoint.BuildConfig
import com.killingpart.killingpoint.data.local.TokenStore
import com.killingpart.killingpoint.data.model.KakaoAuthRequest
import com.killingpart.killingpoint.data.model.KakaoAuthResponse
@@ -54,6 +55,10 @@ class AuthRepository(
private val youtubeApi: ApiService = RetrofitClient.getYoutubeApi(),
private val tokenStore: TokenStore = TokenStore(context.applicationContext)
) {
+ private companion object {
+ const val CLIENT_TYPE = "ANDROID"
+ }
+
/**
* 카카오 accessToken을 받아서:
* 1) 우리 서버 /auth/kakao 로 교환
@@ -155,14 +160,15 @@ class AuthRepository(
}
}
- suspend fun getUserInitSettings(
- clientType: String = "ANDROID",
- clientVersion: String = "1.0.0"
- ): Result = withContext(Dispatchers.IO) {
+ suspend fun getUserInitSettings(): Result = withContext(Dispatchers.IO) {
runCatching {
val accessToken = getAccessToken()
?: throw IllegalStateException("액세스 토큰이 없습니다")
- api.getUserInitSettings("Bearer $accessToken", clientType, clientVersion)
+ api.getUserInitSettings(
+ accessToken = "Bearer $accessToken",
+ clientVersion = BuildConfig.VERSION_NAME,
+ clientType = CLIENT_TYPE
+ )
}.recoverCatching { e ->
if (e is HttpException) {
val code = e.code()
diff --git a/app/src/main/java/com/killingpart/killingpoint/notification/KillingPointFirebaseMessagingService.kt b/app/src/main/java/com/killingpart/killingpoint/notification/KillingPointFirebaseMessagingService.kt
index a78c7c1..2764e44 100644
--- a/app/src/main/java/com/killingpart/killingpoint/notification/KillingPointFirebaseMessagingService.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/notification/KillingPointFirebaseMessagingService.kt
@@ -16,6 +16,7 @@ import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.killingpart.killingpoint.MainActivity
import com.killingpart.killingpoint.R
+import com.killingpart.killingpoint.data.local.AlarmReadStore
import com.killingpart.killingpoint.data.repository.AuthRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -33,6 +34,7 @@ class KillingPointFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
+ AlarmReadStore.markLocalUnread(applicationContext)
createNotificationChannel()
// 같은 메시지가 두 번 배달되는 경우(FCM 재전송 or 토큰 중복) 무시
diff --git a/app/src/main/java/com/killingpart/killingpoint/ui/screen/DiaryDetailScreen/DiaryDetailScreen.kt b/app/src/main/java/com/killingpart/killingpoint/ui/screen/DiaryDetailScreen/DiaryDetailScreen.kt
index 2caf33e..b8bb028 100644
--- a/app/src/main/java/com/killingpart/killingpoint/ui/screen/DiaryDetailScreen/DiaryDetailScreen.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/ui/screen/DiaryDetailScreen/DiaryDetailScreen.kt
@@ -15,10 +15,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -27,6 +25,9 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -36,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
@@ -74,7 +76,9 @@ import java.net.URLDecoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import androidx.activity.compose.BackHandler
+import android.widget.Toast
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiaryDetailScreen(
navController: NavController,
@@ -109,6 +113,13 @@ fun DiaryDetailScreen(
var showDeleteDialog by remember { mutableStateOf(false) }
var isDeleting by remember { mutableStateOf(false) }
+ // 이미지 저장/공유 진행 상태
+ var isSaving by remember { mutableStateOf(false) }
+
+ // 공유 방식 선택 바텀시트
+ var showShareSheet by remember { mutableStateOf(false) }
+ val shareSheetState = rememberModalBottomSheetState()
+
val isOtherPersonDiary = diaryId != null &&
authorUsername.isNotEmpty() &&
authorTag.isNotEmpty()
@@ -165,6 +176,29 @@ fun DiaryDetailScreen(
createDate.split("T")[0]
}
+ // 공유 카드용 값
+ val shareTag = if (authorTag.isNotEmpty()) {
+ "@$authorTag"
+ } else {
+ when (val s = userState) {
+ is UserUiState.Success -> "@${s.userInfo.tag}"
+ else -> "@KILLINGPART"
+ }
+ }
+ val shareTotalDuration = (totalDuration ?: 0).coerceAtLeast(1)
+ val shareStartProgress = (startSeconds.toFloat() / shareTotalDuration).coerceIn(0f, 1f)
+ val shareEndProgress = (endSeconds.toFloat() / shareTotalDuration).coerceIn(0f, 1f)
+
+ // 공유 카드에 넣을 코멘트: 공개(PUBLIC) 일기만 내용 노출, 비공개(PRIVATE/KILLING_PART)는 "비공개"로 대체
+ val shareCardContent = run {
+ val diaryScope = try {
+ Scope.valueOf(scope.ifEmpty { "PRIVATE" })
+ } catch (e: Exception) {
+ Scope.PRIVATE
+ }
+ if (diaryScope == Scope.PUBLIC) currentContent else "비공개"
+ }
+
// 시스템 뒤로가기 처리 - 네비게이션 스택 확인
BackHandler {
val previousEntry = navController.previousBackStackEntry
@@ -345,32 +379,92 @@ fun DiaryDetailScreen(
)
}
- if (!isEditing) {
- if (diaryId != null && !isOtherPersonDiary) {
- Row {
- IconButton(
- onClick = { showDeleteDialog = true }
- ) {
- Icon(
- imageVector = Icons.Default.Delete,
- contentDescription = "삭제",
- tint = Color.White
- )
- }
- IconButton(
- onClick = { isEditing = true }
- ) {
- Icon(
- imageVector = Icons.Default.Edit,
- contentDescription = "편집",
- tint = Color.White
- )
- }
- }
- } else if (isOtherPersonDiary) {
- Spacer(modifier = Modifier.width(48.dp))
- } else {
- Spacer(modifier = Modifier.width(48.dp))
+ if (diaryId != null && !isOtherPersonDiary) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp)
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.storing),
+ contentDescription = "저장",
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .height(38.dp)
+ .aspectRatio(96f / 164f)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(enabled = !isSaving) {
+ coroutineScope.launch {
+ try {
+ isSaving = true
+ val activity = context.findActivity()
+ if (activity == null) {
+ Toast.makeText(context, "저장 실패: 화면을 찾을 수 없어요", Toast.LENGTH_SHORT).show()
+ return@launch
+ }
+ val artwork = DiaryShareImage.loadArtwork(context, albumImageUrl)
+ val bitmap = renderComposableToBitmap(activity) {
+ DiaryShareCard(
+ artwork = artwork,
+ musicTitle = musicTitle,
+ artist = artist,
+ content = currentContent,
+ dateText = formattedDate,
+ tagText = shareTag,
+ startText = "%d:%02d".format(startSeconds / 60, startSeconds % 60),
+ endText = "%d:%02d".format(endSeconds / 60, endSeconds % 60),
+ startProgress = shareStartProgress,
+ endProgress = shareEndProgress
+ )
+ }
+ DiaryShareImage.saveToGallery(
+ context = context,
+ bitmap = bitmap,
+ displayName = "killingpart_diary_${diaryId ?: 0}_${System.currentTimeMillis()}"
+ ).fold(
+ onSuccess = {
+ Toast.makeText(context, "이미지를 저장했어요", Toast.LENGTH_SHORT).show()
+ },
+ onFailure = {
+ Toast.makeText(context, "저장 실패: ${it.message ?: "알 수 없는 오류"}", Toast.LENGTH_SHORT).show()
+ }
+ )
+ } finally {
+ isSaving = false
+ }
+ }
+ }
+ )
+ Image(
+ painter = painterResource(id = R.drawable.sharing),
+ contentDescription = "공유",
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .height(38.dp)
+ .aspectRatio(96f / 164f)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(enabled = !isSaving) { showShareSheet = true }
+ )
+ Image(
+ painter = painterResource(id = R.drawable.fixing),
+ contentDescription = "수정",
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .height(38.dp)
+ .aspectRatio(96f / 164f)
+ .clip(RoundedCornerShape(8.dp))
+ .alpha(if (isEditing) 0.4f else 1f)
+ .clickable(enabled = !isEditing) { isEditing = true }
+ )
+ Image(
+ painter = painterResource(id = R.drawable.deleting),
+ contentDescription = "삭제",
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .height(38.dp)
+ .aspectRatio(96f / 164f)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { showDeleteDialog = true }
+ )
}
} else {
Spacer(modifier = Modifier.width(48.dp))
@@ -692,6 +786,142 @@ fun DiaryDetailScreen(
BottomBar(navController = navController)
}
+ if (showShareSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showShareSheet = false },
+ sheetState = shareSheetState,
+ containerColor = Color(0xFF1A1A1A)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 24.dp)
+ ) {
+ Text(
+ text = "공유하기",
+ color = Color.White,
+ fontFamily = PaperlogyFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "공유할 방식을 선택해 주세요.",
+ color = Color(0xFFAAAAAA),
+ fontFamily = PaperlogyFontFamily,
+ fontSize = 12.sp
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ ShareOptionItem(text = "공유") {
+ showShareSheet = false
+ coroutineScope.launch {
+ try {
+ isSaving = true
+ val activity = context.findActivity()
+ if (activity == null) {
+ Toast.makeText(context, "공유 실패: 화면을 찾을 수 없어요", Toast.LENGTH_SHORT).show()
+ return@launch
+ }
+ val artwork = DiaryShareImage.loadArtwork(context, albumImageUrl)
+ val bitmap = renderComposableToBitmap(activity) {
+ DiaryShareCard(
+ artwork = artwork,
+ musicTitle = musicTitle,
+ artist = artist,
+ content = shareCardContent,
+ dateText = formattedDate,
+ tagText = shareTag,
+ startText = "%d:%02d".format(startSeconds / 60, startSeconds % 60),
+ endText = "%d:%02d".format(endSeconds / 60, endSeconds % 60),
+ startProgress = shareStartProgress,
+ endProgress = shareEndProgress
+ )
+ }
+ val link = "https://killingpart.com/diaries/${diaryId ?: 0}"
+ DiaryShareImage.shareImageNative(context, bitmap, link).onFailure {
+ Toast.makeText(context, "공유 실패: ${it.message ?: "알 수 없는 오류"}", Toast.LENGTH_SHORT).show()
+ }
+ } finally {
+ isSaving = false
+ }
+ }
+ }
+ ShareOptionItem(text = "카카오톡 공유") {
+ showShareSheet = false
+ coroutineScope.launch {
+ try {
+ isSaving = true
+ val activity = context.findActivity()
+ if (activity == null) {
+ Toast.makeText(context, "공유 실패: 화면을 찾을 수 없어요", Toast.LENGTH_SHORT).show()
+ return@launch
+ }
+ val artwork = DiaryShareImage.loadArtwork(context, albumImageUrl)
+ val bitmap = renderComposableToBitmap(activity) {
+ DiaryShareCard(
+ artwork = artwork,
+ musicTitle = musicTitle,
+ artist = artist,
+ content = shareCardContent,
+ dateText = formattedDate,
+ tagText = shareTag,
+ startText = "%d:%02d".format(startSeconds / 60, startSeconds % 60),
+ endText = "%d:%02d".format(endSeconds / 60, endSeconds % 60),
+ startProgress = shareStartProgress,
+ endProgress = shareEndProgress
+ )
+ }
+ val link = "https://killingpart.com/diaries/${diaryId ?: 0}"
+ val title = if (artist.isNotBlank()) "$musicTitle - $artist" else musicTitle
+ val description = shareCardContent.ifBlank { "킬링파트에서 다이어리를 확인해 보세요." }
+ DiaryShareImage.shareKakao(context, bitmap, title, description, link, diaryId ?: 0L).onFailure {
+ Toast.makeText(context, "카카오톡 공유 실패: ${it.message ?: "알 수 없는 오류"}", Toast.LENGTH_SHORT).show()
+ }
+ } finally {
+ isSaving = false
+ }
+ }
+ }
+ ShareOptionItem(text = "인스타 스토리 공유") {
+ showShareSheet = false
+ coroutineScope.launch {
+ try {
+ isSaving = true
+ val activity = context.findActivity()
+ if (activity == null) {
+ Toast.makeText(context, "공유 실패: 화면을 찾을 수 없어요", Toast.LENGTH_SHORT).show()
+ return@launch
+ }
+ val artwork = DiaryShareImage.loadArtwork(context, albumImageUrl)
+ val bitmap = renderComposableToBitmap(activity) {
+ DiaryShareCard(
+ artwork = artwork,
+ musicTitle = musicTitle,
+ artist = artist,
+ content = shareCardContent,
+ dateText = formattedDate,
+ tagText = shareTag,
+ startText = "%d:%02d".format(startSeconds / 60, startSeconds % 60),
+ endText = "%d:%02d".format(endSeconds / 60, endSeconds % 60),
+ startProgress = shareStartProgress,
+ endProgress = shareEndProgress
+ )
+ }
+ val link = "https://killingpart.com/diaries/${diaryId ?: 0}"
+ val fbAppId = context.getString(R.string.facebook_app_id)
+ DiaryShareImage.shareInstagramStory(context, bitmap, link, fbAppId).onFailure {
+ Toast.makeText(context, "인스타 스토리 공유 실패: ${it.message ?: "알 수 없는 오류"}", Toast.LENGTH_SHORT).show()
+ }
+ } finally {
+ isSaving = false
+ }
+ }
+ }
+ }
+ }
+ }
+
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = {
@@ -856,6 +1086,22 @@ private fun parseTimeToSeconds(timeStr: String): Int {
}
}
+@Composable
+private fun ShareOptionItem(text: String, onClick: () -> Unit) {
+ Text(
+ text = text,
+ color = Color.White,
+ fontFamily = PaperlogyFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 15.sp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp))
+ .clickable { onClick() }
+ .padding(vertical = 16.dp, horizontal = 4.dp)
+ )
+}
+
@Preview
@Composable
fun DiaryDetailScreenPreview() {
diff --git a/app/src/main/java/com/killingpart/killingpoint/ui/screen/DiaryDetailScreen/DiaryShareCard.kt b/app/src/main/java/com/killingpart/killingpoint/ui/screen/DiaryDetailScreen/DiaryShareCard.kt
new file mode 100644
index 0000000..ffd8ae8
--- /dev/null
+++ b/app/src/main/java/com/killingpart/killingpoint/ui/screen/DiaryDetailScreen/DiaryShareCard.kt
@@ -0,0 +1,558 @@
+package com.killingpart.killingpoint.ui.screen.DiaryDetailScreen
+
+import android.app.Activity
+import android.content.ContentValues
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.net.Uri
+import android.os.Environment
+import android.provider.MediaStore
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.content.FileProvider
+import com.kakao.sdk.share.ShareClient
+import com.kakao.sdk.template.model.Button
+import com.kakao.sdk.template.model.Content
+import com.kakao.sdk.template.model.FeedTemplate
+import com.kakao.sdk.template.model.Link
+import kotlinx.coroutines.android.awaitFrame
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.io.File
+import java.io.FileOutputStream
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.BiasAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.material3.Text
+import coil.imageLoader
+import coil.request.ImageRequest
+import com.killingpart.killingpoint.R
+import com.killingpart.killingpoint.ui.theme.PaperlogyFontFamily
+import com.killingpart.killingpoint.ui.theme.mainGreen
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private val CardWidth = 360.dp
+private val CardHeight = 640.dp
+
+@Composable
+fun DiaryShareCard(
+ artwork: ImageBitmap?,
+ musicTitle: String,
+ artist: String,
+ content: String,
+ dateText: String,
+ tagText: String,
+ startText: String,
+ endText: String,
+ startProgress: Float,
+ endProgress: Float,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .requiredSize(CardWidth, CardHeight)
+ .background(Color(0xFF1D1E20))
+ ) {
+ // AppBackground 와 동일한 어두운 원 배경
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ drawCircle(
+ color = Color(0xFF060606),
+ radius = size.minDimension * 0.85f,
+ center = Offset(size.width * 0.1f, size.height * 0.37f)
+ )
+ drawCircle(
+ color = Color(0xFF060606),
+ radius = size.minDimension * 1.5f,
+ center = Offset(size.width * 1.1f, size.height * 1.2f)
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 21.dp)
+ .padding(top = 38.dp),
+ verticalArrangement = Arrangement.spacedBy(18.dp)
+ ) {
+ // 트랙 카드
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(292.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .background(Color.Black.copy(alpha = 0.72f))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier.size(width = 200.dp, height = 150.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ // 앨범 커버 뒤 CD (오른쪽으로 살짝 빼꼼)
+ Image(
+ painter = painterResource(id = R.drawable.cd),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .size(150.dp)
+ .offset(x = 40.dp)
+ )
+ // 앨범 커버 (앞)
+ Box(
+ modifier = Modifier
+ .size(150.dp)
+ .offset(x = (-14).dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0xFF2A2A2C)),
+ contentAlignment = Alignment.Center
+ ) {
+ if (artwork != null) {
+ Image(
+ bitmap = artwork,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = musicTitle,
+ color = Color.White,
+ fontFamily = PaperlogyFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp,
+ maxLines = 2,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 24.dp)
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = artist,
+ color = Color.White.copy(alpha = 0.82f),
+ fontFamily = PaperlogyFontFamily,
+ fontSize = 13.sp,
+ maxLines = 1
+ )
+
+ Spacer(modifier = Modifier.height(18.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 36.dp)
+ ) {
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(6.dp)
+ ) {
+ val y = size.height / 2
+ drawLine(
+ color = Color.White.copy(alpha = 0.42f),
+ start = Offset(0f, y),
+ end = Offset(size.width, y),
+ strokeWidth = 2.5.dp.toPx(),
+ cap = StrokeCap.Round
+ )
+ val sx = size.width * startProgress.coerceIn(0f, 1f)
+ val ex = size.width * endProgress.coerceIn(0f, 1f)
+ if (ex > sx) {
+ drawLine(
+ color = mainGreen,
+ start = Offset(sx, y),
+ end = Offset(ex, y),
+ strokeWidth = 6.dp.toPx(),
+ cap = StrokeCap.Round
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = startText,
+ color = Color.White.copy(alpha = 0.62f),
+ fontFamily = PaperlogyFontFamily,
+ fontSize = 10.sp,
+ modifier = Modifier.align(
+ BiasAlignment(2f * startProgress.coerceIn(0f, 1f) - 1f, 0f)
+ )
+ )
+ Text(
+ text = endText,
+ color = Color.White.copy(alpha = 0.62f),
+ fontFamily = PaperlogyFontFamily,
+ fontSize = 10.sp,
+ modifier = Modifier.align(
+ BiasAlignment(2f * endProgress.coerceIn(0f, 1f) - 1f, 0f)
+ )
+ )
+ }
+ }
+ }
+ }
+
+ // 코멘트 카드
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(238.dp)
+ .clip(RoundedCornerShape(13.dp))
+ .background(Color.White.copy(alpha = 0.10f))
+ ) {
+ Text(
+ text = if (content.isBlank()) "작성된 코멘트가 없어요." else content,
+ color = Color.White.copy(alpha = 0.92f),
+ fontFamily = PaperlogyFontFamily,
+ fontSize = 13.sp,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 22.dp)
+ .padding(top = 24.dp, bottom = 74.dp)
+ )
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .padding(start = 22.dp, end = 18.dp, bottom = 18.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Column {
+ Text(
+ text = dateText,
+ color = Color.White.copy(alpha = 0.52f),
+ fontFamily = PaperlogyFontFamily,
+ fontSize = 10.sp
+ )
+ Spacer(modifier = Modifier.height(5.dp))
+ Text(
+ text = tagText,
+ color = Color.White.copy(alpha = 0.70f),
+ fontFamily = PaperlogyFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Image(
+ painter = painterResource(id = R.drawable.kp_logo),
+ contentDescription = null,
+ modifier = Modifier
+ .size(46.dp)
+ .clip(RoundedCornerShape(10.dp))
+ )
+ }
+ }
+ }
+ }
+}
+
+object DiaryShareImage {
+
+ /** Coil 로 앨범 아트를 미리 불러온다 (캡처 전에 이미지가 준비되도록). */
+ suspend fun loadArtwork(context: Context, url: String): ImageBitmap? {
+ if (url.isBlank()) return null
+ return try {
+ val request = ImageRequest.Builder(context)
+ .data(url)
+ .allowHardware(false) // 캡처(software canvas)에 그릴 수 있도록
+ .build()
+ val result = context.imageLoader.execute(request)
+ (result.drawable as? BitmapDrawable)?.bitmap?.asImageBitmap()
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /** 비트맵을 갤러리(Pictures/KillingPart)에 저장. minSdk 29+ 라 권한 불필요. */
+ suspend fun saveToGallery(
+ context: Context,
+ bitmap: Bitmap,
+ displayName: String
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val resolver = context.contentResolver
+ val values = ContentValues().apply {
+ put(MediaStore.Images.Media.DISPLAY_NAME, "$displayName.png")
+ put(MediaStore.Images.Media.MIME_TYPE, "image/png")
+ put(
+ MediaStore.Images.Media.RELATIVE_PATH,
+ Environment.DIRECTORY_PICTURES + "/KillingPart"
+ )
+ put(MediaStore.Images.Media.IS_PENDING, 1)
+ }
+
+ val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
+ ?: return@withContext Result.failure(IllegalStateException("저장 위치를 만들지 못했어요."))
+
+ resolver.openOutputStream(uri).use { out ->
+ if (out == null || !bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) {
+ return@withContext Result.failure(IllegalStateException("이미지를 저장하지 못했어요."))
+ }
+ }
+
+ values.clear()
+ values.put(MediaStore.Images.Media.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+
+ Result.success(uri)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * 카드 비트맵을 캐시에 저장한 뒤 FileProvider URI 로 네이티브 공유 시트를 띄운다.
+ * 이미지 + 딥링크 URL(텍스트)을 함께 공유한다.
+ */
+ suspend fun shareImageNative(
+ context: Context,
+ bitmap: Bitmap,
+ linkUrl: String
+ ): Result {
+ return try {
+ val uri = withContext(Dispatchers.IO) {
+ val dir = File(context.cacheDir, "shared_images").apply { mkdirs() }
+ val file = File(dir, "diary_share_${bitmap.hashCode()}.png")
+ FileOutputStream(file).use { out ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
+ }
+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ }
+
+ val sendIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "image/*"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_TEXT, linkUrl)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(sendIntent, "공유하기"))
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * 카드 이미지를 인스타그램 스토리 배경으로 공유한다.
+ * Facebook(Meta) App ID 가 필요하며, 인스타그램 앱이 설치돼 있어야 한다.
+ */
+ suspend fun shareInstagramStory(
+ context: Context,
+ bitmap: Bitmap,
+ linkUrl: String,
+ facebookAppId: String
+ ): Result {
+ return try {
+ if (facebookAppId.isBlank()) {
+ return Result.failure(IllegalStateException("인스타 스토리 공유를 위해 Facebook App ID 설정이 필요해요."))
+ }
+
+ val uri = withContext(Dispatchers.IO) {
+ val dir = File(context.cacheDir, "shared_images").apply { mkdirs() }
+ val file = File(dir, "insta_story_${bitmap.hashCode()}.png")
+ FileOutputStream(file).use { out ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
+ }
+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ }
+
+ val intent = Intent("com.instagram.share.ADD_TO_STORY").apply {
+ setDataAndType(uri, "image/png")
+ putExtra("source_application", facebookAppId)
+ putExtra("content_url", linkUrl)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.grantUriPermission("com.instagram.android", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+
+ if (intent.resolveActivity(context.packageManager) == null) {
+ return Result.failure(IllegalStateException("인스타그램 앱을 열 수 없어요. 설치 여부를 확인해 주세요."))
+ }
+ context.startActivity(intent)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * 카카오 SDK FeedTemplate 로 카톡 리치 카드(이미지+제목+설명+버튼)를 공유한다.
+ * 카드 이미지는 카카오 이미지 서버에 업로드하므로 별도 백엔드가 필요 없다.
+ */
+ suspend fun shareKakao(
+ context: Context,
+ bitmap: Bitmap,
+ title: String,
+ description: String,
+ linkUrl: String,
+ diaryId: Long
+ ): Result {
+ return try {
+ if (!ShareClient.instance.isKakaoTalkSharingAvailable(context)) {
+ return Result.failure(IllegalStateException("카카오톡이 설치되어 있지 않아요."))
+ }
+
+ val file = withContext(Dispatchers.IO) {
+ val dir = File(context.cacheDir, "shared_images").apply { mkdirs() }
+ val f = File(dir, "kakao_share_${bitmap.hashCode()}.png")
+ FileOutputStream(f).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
+ f
+ }
+
+ // 1) 카드 이미지를 카카오 서버에 업로드
+ val imageUrl = suspendCancellableCoroutine { cont ->
+ ShareClient.instance.uploadImage(file) { result, error ->
+ when {
+ error != null -> cont.resumeWithException(error)
+ result != null -> cont.resume(result.infos.original.url)
+ else -> cont.resumeWithException(IllegalStateException("이미지 업로드에 실패했어요."))
+ }
+ }
+ }
+
+ // 2) FeedTemplate 구성 후 공유
+ // androidExecutionParams: 카드 탭 시 앱 설치자는 kakao{앱키}://kakaolink?route=diary&diaryId=... 로 앱이 열림
+ val executionParams = if (diaryId > 0) {
+ mapOf("route" to "diary", "diaryId" to diaryId.toString())
+ } else {
+ emptyMap()
+ }
+ val link = Link(
+ webUrl = linkUrl,
+ mobileWebUrl = linkUrl,
+ androidExecutionParams = executionParams,
+ iosExecutionParams = executionParams
+ )
+ val template = FeedTemplate(
+ content = Content(
+ title = title,
+ imageUrl = imageUrl,
+ link = link,
+ description = description
+ ),
+ buttons = listOf(
+ Button(title = "킬링파트 보러가기", link = link)
+ )
+ )
+
+ suspendCancellableCoroutine { cont ->
+ ShareClient.instance.shareDefault(context, template) { sharingResult, error ->
+ when {
+ error != null -> cont.resumeWithException(error)
+ sharingResult != null -> {
+ context.startActivity(sharingResult.intent)
+ cont.resume(Unit)
+ }
+ else -> cont.resumeWithException(IllegalStateException("카카오톡 공유에 실패했어요."))
+ }
+ }
+ }
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
+
+/** ContextWrapper 를 풀어 Activity 를 찾는다. */
+fun Context.findActivity(): Activity? {
+ var ctx: Context = this
+ while (ctx is ContextWrapper) {
+ if (ctx is Activity) return ctx
+ ctx = ctx.baseContext
+ }
+ return null
+}
+
+/**
+ * Compose 콘텐츠를 화면과 무관하게 오프스크린 ComposeView 로 렌더링해 Bitmap 으로 반환한다.
+ * view.draw(canvas) 는 동기 소프트웨어 렌더링이라 유튜브 영상 재생/정지 상태와 무관하게 동작한다.
+ */
+suspend fun renderComposableToBitmap(
+ activity: Activity,
+ content: @androidx.compose.runtime.Composable () -> Unit
+): Bitmap {
+ val root = activity.window.decorView as ViewGroup
+ val composeView = ComposeView(activity).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
+ visibility = View.INVISIBLE
+ setContent(content)
+ }
+ root.addView(
+ composeView,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ return try {
+ // 컴포지션/레이아웃이 끝나도록 몇 프레임 대기 (Choreographer 기반이라 화면 상태와 무관)
+ awaitFrame()
+ awaitFrame()
+
+ val unspecified = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ composeView.measure(unspecified, unspecified)
+ val width = composeView.measuredWidth.coerceAtLeast(1)
+ val height = composeView.measuredHeight.coerceAtLeast(1)
+ composeView.layout(0, 0, width, height)
+
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ composeView.draw(Canvas(bitmap))
+ bitmap
+ } finally {
+ root.removeView(composeView)
+ }
+}
diff --git a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SearchScreen/SearchScreen.kt b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SearchScreen/SearchScreen.kt
index 552c6a8..d4a9afb 100644
--- a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SearchScreen/SearchScreen.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SearchScreen/SearchScreen.kt
@@ -1,7 +1,19 @@
package com.killingpart.killingpoint.ui.screen.SearchScreen
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import com.killingpart.killingpoint.R
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -59,6 +71,17 @@ fun SearchScreen(navController: NavController) {
var isLoadingLikes by remember { mutableStateOf(false) }
var likesError by remember { mutableStateOf(null) }
+ // 보관함 저장 팝업 (탭할 때마다 tick 증가 -> 타이머 재시작)
+ var storePopupVisible by remember { mutableStateOf(false) }
+ var storePopupTick by remember { mutableStateOf(0) }
+ LaunchedEffect(storePopupTick) {
+ if (storePopupTick > 0) {
+ storePopupVisible = true
+ delay(1000)
+ storePopupVisible = false
+ }
+ }
+
LaunchedEffect(Unit) {
EngagementAnalytics.onMainTabScreenVisible(EngagementAnalytics.MainTab.EXPLORE)
searchViewModel.loadRandomDiaries(context)
@@ -97,7 +120,15 @@ fun SearchScreen(navController: NavController) {
}
}
- val snapFlingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
+ // 한 번의 스와이프에 한 아이템만 이동하도록: 플링의 approach 이동을 없애 가장 가까운 아이템으로만 스냅
+ val snapFlingBehavior = rememberSnapFlingBehavior(
+ remember(listState) {
+ val base = SnapLayoutInfoProvider(listState)
+ object : SnapLayoutInfoProvider by base {
+ override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f
+ }
+ }
+ )
LaunchedEffect(currentItemIndex.value, searchState) {
val state = searchState as? SearchUiState.Success ?: return@LaunchedEffect
@@ -285,6 +316,11 @@ fun SearchScreen(navController: NavController) {
val currentDiary = currentState.diaries.find { it.diaryId == diaryId }
val currentIsStored = currentDiary?.isStored ?: false
+ // 보관(저장)하는 경우에만 팝업 노출
+ if (!currentIsStored) {
+ storePopupTick++
+ }
+
val updatedDiaries = currentState.diaries.map { diary ->
if (diary.diaryId == diaryId) {
diary.copy(isStored = !currentIsStored)
@@ -425,5 +461,51 @@ fun SearchScreen(navController: NavController) {
BottomBar(navController)
}
+
+ // 보관함 저장 팝업 (일정 시간 후 자동으로 사라짐)
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .zIndex(20f),
+ contentAlignment = Alignment.Center
+ ) {
+ StoreSavedPopup(visible = storePopupVisible)
+ }
+ }
+}
+
+@Composable
+private fun StoreSavedPopup(
+ visible: Boolean,
+ modifier: Modifier = Modifier
+) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn() + scaleIn(initialScale = 0.85f),
+ exit = fadeOut(),
+ modifier = modifier
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(Color.Black.copy(alpha = 0.78f))
+ .border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(20.dp))
+ .padding(horizontal = 24.dp, vertical = 16.dp)
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.is_stored),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp)
+ )
+ Text(
+ text = "보관함에 저장되었어요",
+ color = Color.White.copy(alpha = 0.95f),
+ fontFamily = PaperlogyFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 13.sp
+ )
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/AlarmListScreen.kt b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/AlarmListScreen.kt
index b507bf1..c8ec932 100644
--- a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/AlarmListScreen.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/AlarmListScreen.kt
@@ -1,6 +1,8 @@
package com.killingpart.killingpoint.ui.screen.SocialScreen
import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -14,9 +16,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.background
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -33,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
@@ -58,6 +63,8 @@ import kotlinx.coroutines.launch
fun AlarmListScreen(navController: NavController) {
val alarmViewModel: AlarmViewModel = viewModel()
val alarmState by alarmViewModel.state.collectAsState()
+ val isSelectionMode by alarmViewModel.isSelectionMode.collectAsState()
+ val selectedIds by alarmViewModel.selectedIds.collectAsState()
val context = androidx.compose.ui.platform.LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val repo = remember { AuthRepository(context) }
@@ -122,6 +129,48 @@ fun AlarmListScreen(navController: NavController) {
fontSize = 18.sp
)
}
+
+ Text(
+ text = if (isSelectionMode) "취소" else "선택",
+ color = Color.White,
+ fontFamily = PaperlogyFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { alarmViewModel.setSelectionMode(!isSelectionMode) }
+ .padding(horizontal = 8.dp, vertical = 8.dp)
+ )
+ }
+
+ if (isSelectionMode) {
+ val currentAlarms = (alarmState as? AlarmUiState.Success)?.alarms.orEmpty()
+ val allSelected = currentAlarms.isNotEmpty() && selectedIds.size == currentAlarms.size
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ SelectionChip(
+ text = if (allSelected) "전체 해제" else "전체 선택",
+ onClick = { alarmViewModel.toggleSelectAll() }
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ SelectionChip(
+ text = "선택 삭제",
+ onClick = { alarmViewModel.deleteSelected(context) },
+ enabled = selectedIds.isNotEmpty(),
+ highlighted = true
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ SelectionChip(
+ text = "모두 삭제",
+ onClick = { alarmViewModel.deleteAll(context) },
+ enabled = currentAlarms.isNotEmpty()
+ )
+ }
}
when (val state = alarmState) {
@@ -152,10 +201,13 @@ fun AlarmListScreen(navController: NavController) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
- .padding(top = 36.dp),
+ .padding(top = if (isSelectionMode) 16.dp else 36.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
- itemsIndexed(state.alarms, key = { _, alarm -> alarm.alarmId }) { index, alarm ->
+ itemsIndexed(state.alarms, key = { _, item -> item.alarm.alarmId }) { index, item ->
+ val alarm = item.alarm
+ val isSelected = alarm.alarmId in selectedIds
+ val textColor = if (item.isRead) Color(0xFFA4A4A6) else Color.White
val isNavigable = remember(alarm.alarmId, alarm.type, alarm.deepLink) {
AlarmDeepLink.isNavigable(alarm.type, alarm.deepLink)
}
@@ -163,37 +215,48 @@ fun AlarmListScreen(navController: NavController) {
Row(
modifier = Modifier
.fillMaxWidth()
- .clickable(enabled = isNavigable && !opening) {
- opening = true
- coroutineScope.launch {
- try {
- handleAlarmNavigation(
- navController = navController,
- type = alarm.type,
- deepLink = alarm.deepLink,
- repo = repo,
- onError = { msg ->
- Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
- }
- )
- } finally {
- opening = false
+ .clickable(enabled = isSelectionMode || !opening) {
+ if (isSelectionMode) {
+ alarmViewModel.toggleSelection(alarm.alarmId)
+ return@clickable
+ }
+ // 탭하면 이동 가능 여부와 무관하게 항상 읽음(회색) 처리
+ alarmViewModel.markAlarmRead(context, alarm.alarmId)
+ // 이동은 딥링크가 유효한 알림에서만
+ if (isNavigable) {
+ opening = true
+ coroutineScope.launch {
+ try {
+ handleAlarmNavigation(
+ navController = navController,
+ type = alarm.type,
+ deepLink = alarm.deepLink,
+ repo = repo,
+ onError = { msg ->
+ Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
+ }
+ )
+ } finally {
+ opening = false
+ }
}
}
}
.padding(vertical = 16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
+ if (isSelectionMode) {
+ SelectionRadio(isSelected = isSelected)
+ }
Text(
text = alarm.content,
- color = Color.White,
+ color = textColor,
fontFamily = PaperlogyFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
- Spacer(modifier = Modifier.size(12.dp))
Text(
text = formatAlarmDate(alarm.createDate),
color = Color(0xFFA4A4A6),
@@ -220,6 +283,63 @@ fun AlarmListScreen(navController: NavController) {
}
}
+@Composable
+private fun SelectionRadio(isSelected: Boolean) {
+ Box(
+ modifier = Modifier
+ .size(12.dp)
+ .clip(CircleShape)
+ .then(
+ if (isSelected) {
+ Modifier.background(mainGreen)
+ } else {
+ Modifier.border(1.dp, Color(0xFF6B6B6D), CircleShape)
+ }
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = Color.Black,
+ modifier = Modifier.size(12.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun SelectionChip(
+ text: String,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ highlighted: Boolean = false
+) {
+ val backgroundColor = if (highlighted) {
+ mainGreen.copy(alpha = if (enabled) 1f else 0.35f)
+ } else {
+ Color.White.copy(alpha = 0.12f)
+ }
+ val textColor = if (highlighted) Color.Black else Color.White.copy(alpha = 0.9f)
+
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(10.dp))
+ .background(backgroundColor)
+ .clickable(enabled = enabled) { onClick() }
+ .padding(horizontal = 12.dp, vertical = 8.dp)
+ ) {
+ Text(
+ text = text,
+ color = textColor,
+ fontFamily = PaperlogyFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp
+ )
+ }
+}
+
private fun formatAlarmDate(raw: String?): String {
if (raw.isNullOrBlank()) return ""
return when {
@@ -231,4 +351,3 @@ private fun formatAlarmDate(raw: String?): String {
else -> raw
}
}
-
diff --git a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/FeedScreen.kt b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/FeedScreen.kt
index c313f86..b82a5ea 100644
--- a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/FeedScreen.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/FeedScreen.kt
@@ -1,5 +1,6 @@
package com.killingpart.killingpoint.ui.screen.SocialScreen
+import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
@@ -71,7 +72,15 @@ fun FeedScreen(navController: NavController) {
}
}
- val snapFlingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
+ // 한 번의 스와이프에 한 아이템만 이동하도록: 플링의 approach 이동을 없애 가장 가까운 아이템으로만 스냅
+ val snapFlingBehavior = rememberSnapFlingBehavior(
+ remember(listState) {
+ val base = SnapLayoutInfoProvider(listState)
+ object : SnapLayoutInfoProvider by base {
+ override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f
+ }
+ }
+ )
LaunchedEffect(likesDiaryId) {
val targetId = likesDiaryId ?: return@LaunchedEffect
diff --git a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/SocialScreen.kt b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/SocialScreen.kt
index 737dfae..7beff91 100644
--- a/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/SocialScreen.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/ui/screen/SocialScreen/SocialScreen.kt
@@ -3,6 +3,7 @@ package com.killingpart.killingpoint.ui.screen.SocialScreen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
@@ -125,16 +126,19 @@ fun SocialScreen(
contentAlignment = Alignment.Center
) {
Image(
- painter = painterResource(
- id = if (hasUnread) {
- R.drawable.ic_noti_true_without_bg
- } else {
- R.drawable.ic_bell
- }
- ),
+ painter = painterResource(id = R.drawable.ic_bell),
contentDescription = "알림 목록 진입",
- modifier = Modifier.size(if (hasUnread) 24.dp else 18.dp)
+ modifier = Modifier.size(18.dp)
)
+ if (hasUnread) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .offset(x = 8.dp, y = (-8).dp)
+ .size(7.dp)
+ .background(Color(0xFFFF3B30), CircleShape)
+ )
+ }
}
}
diff --git a/app/src/main/java/com/killingpart/killingpoint/ui/viewmodel/AlarmViewModel.kt b/app/src/main/java/com/killingpart/killingpoint/ui/viewmodel/AlarmViewModel.kt
index 8c4feaf..1d36a64 100644
--- a/app/src/main/java/com/killingpart/killingpoint/ui/viewmodel/AlarmViewModel.kt
+++ b/app/src/main/java/com/killingpart/killingpoint/ui/viewmodel/AlarmViewModel.kt
@@ -3,6 +3,7 @@ package com.killingpart.killingpoint.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.killingpart.killingpoint.data.local.AlarmReadStore
import com.killingpart.killingpoint.data.model.AlarmItem
import com.killingpart.killingpoint.data.repository.AuthRepository
import kotlinx.coroutines.flow.MutableStateFlow
@@ -11,10 +12,15 @@ import kotlinx.coroutines.launch
sealed interface AlarmUiState {
data object Loading : AlarmUiState
- data class Success(val alarms: List) : AlarmUiState
+ data class Success(val alarms: List) : AlarmUiState
data class Error(val message: String) : AlarmUiState
}
+data class AlarmUiItem(
+ val alarm: AlarmItem,
+ val isRead: Boolean
+)
+
class AlarmViewModel(
private val repoFactory: (Context) -> AuthRepository = { ctx ->
AuthRepository(ctx)
@@ -26,13 +32,32 @@ class AlarmViewModel(
private val _hasUnread = MutableStateFlow(false)
val hasUnread: StateFlow = _hasUnread
+ // 선택(삭제) 모드
+ private val _isSelectionMode = MutableStateFlow(false)
+ val isSelectionMode: StateFlow = _isSelectionMode
+
+ private val _selectedIds = MutableStateFlow>(emptySet())
+ val selectedIds: StateFlow> = _selectedIds
+
fun loadAlarms(context: Context, size: Int = 20) {
_state.value = AlarmUiState.Loading
val repo = repoFactory(context)
viewModelScope.launch {
loadAllAlarmPages(repo, size)
.onSuccess { alarms ->
- _state.value = AlarmUiState.Success(alarms)
+ val deletedIds = AlarmReadStore.getDeletedAlarmIds(context)
+ val visibleAlarms = alarms.filterNot { it.alarmId in deletedIds }
+ val readIds = AlarmReadStore.getReadAlarmIds(context)
+ val uiItems = visibleAlarms.map { alarm ->
+ AlarmUiItem(
+ alarm = alarm,
+ isRead = alarm.alarmId in readIds
+ )
+ }
+ _state.value = AlarmUiState.Success(uiItems)
+ // 목록에 보여진 알림은 "봤음"으로만 저장 -> 레드닷만 끄고 텍스트 색은 그대로 유지
+ AlarmReadStore.markAlarmsSeen(context, visibleAlarms.map { it.alarmId })
+ _hasUnread.value = false
}
.onFailure { e ->
_state.value = AlarmUiState.Error(e.message ?: "알림 목록 조회 실패")
@@ -40,9 +65,84 @@ class AlarmViewModel(
}
}
- fun refreshAlarmFlag(context: Context) {
- // TODO: 백엔드 미확인 알림(hasUnread) API 연동 전까지는 항상 false 유지
+ fun setSelectionMode(enabled: Boolean) {
+ _isSelectionMode.value = enabled
+ if (!enabled) _selectedIds.value = emptySet()
+ }
+
+ fun toggleSelection(alarmId: Long) {
+ val current = _selectedIds.value
+ _selectedIds.value = if (alarmId in current) current - alarmId else current + alarmId
+ }
+
+ /** 전체 선택 <-> 전체 해제 토글 */
+ fun toggleSelectAll() {
+ val allIds = (_state.value as? AlarmUiState.Success)
+ ?.alarms
+ ?.map { it.alarm.alarmId }
+ ?.toSet()
+ .orEmpty()
+ _selectedIds.value = if (allIds.isNotEmpty() && _selectedIds.value.size == allIds.size) {
+ emptySet()
+ } else {
+ allIds
+ }
+ }
+
+ fun deleteSelected(context: Context) {
+ val ids = _selectedIds.value
+ if (ids.isEmpty()) return
+ AlarmReadStore.markAlarmsDeleted(context, ids)
+ val current = _state.value
+ if (current is AlarmUiState.Success) {
+ _state.value = AlarmUiState.Success(
+ current.alarms.filterNot { it.alarm.alarmId in ids }
+ )
+ }
+ setSelectionMode(false)
+ }
+
+ fun deleteAll(context: Context) {
+ val current = _state.value as? AlarmUiState.Success ?: return
+ val allIds = current.alarms.map { it.alarm.alarmId }.toSet()
+ if (allIds.isEmpty()) return
+ AlarmReadStore.markAlarmsDeleted(context, allIds)
+ _state.value = AlarmUiState.Success(emptyList())
_hasUnread.value = false
+ setSelectionMode(false)
+ }
+
+ /** 개별 알림을 탭했을 때 호출: 해당 알림만 읽음(회색) 처리하고 화면에 즉시 반영한다. */
+ fun markAlarmRead(context: Context, alarmId: Long) {
+ AlarmReadStore.markAlarmRead(context, alarmId)
+ val current = _state.value
+ if (current is AlarmUiState.Success) {
+ _state.value = AlarmUiState.Success(
+ current.alarms.map { item ->
+ if (item.alarm.alarmId == alarmId && !item.isRead) {
+ item.copy(isRead = true)
+ } else {
+ item
+ }
+ }
+ )
+ }
+ }
+
+ fun refreshAlarmFlag(context: Context) {
+ val repo = repoFactory(context)
+ viewModelScope.launch {
+ repo.getAlarms(page = 0, size = 20)
+ .onSuccess { response ->
+ _hasUnread.value = AlarmReadStore.hasUnread(
+ context,
+ response.content.map { it.alarmId }
+ )
+ }
+ .onFailure {
+ _hasUnread.value = AlarmReadStore.hasLocalUnread(context)
+ }
+ }
}
private suspend fun loadAllAlarmPages(
diff --git a/app/src/main/res/drawable/deleting.png b/app/src/main/res/drawable/deleting.png
new file mode 100644
index 0000000..17e1a40
Binary files /dev/null and b/app/src/main/res/drawable/deleting.png differ
diff --git a/app/src/main/res/drawable/fixing.png b/app/src/main/res/drawable/fixing.png
new file mode 100644
index 0000000..2d5764d
Binary files /dev/null and b/app/src/main/res/drawable/fixing.png differ
diff --git a/app/src/main/res/drawable/kp_logo.png b/app/src/main/res/drawable/kp_logo.png
new file mode 100644
index 0000000..e967823
Binary files /dev/null and b/app/src/main/res/drawable/kp_logo.png differ
diff --git a/app/src/main/res/drawable/sharing.png b/app/src/main/res/drawable/sharing.png
new file mode 100644
index 0000000..0b4e3a8
Binary files /dev/null and b/app/src/main/res/drawable/sharing.png differ
diff --git a/app/src/main/res/drawable/storing.png b/app/src/main/res/drawable/storing.png
new file mode 100644
index 0000000..65c1fbe
Binary files /dev/null and b/app/src/main/res/drawable/storing.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 30b29b7..14a1c16 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,5 @@
killingPart
e555c7ff865c9318e1672996f4481430
+ 1586673939479375
\ No newline at end of file
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 0000000..d4b1436
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+