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 @@ + + + +