From 54f458b29b494bb5d874653fe5a4962324e99de8 Mon Sep 17 00:00:00 2001 From: latteeea Date: Tue, 12 May 2026 11:58:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=B5=9C=EC=86=8C=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EA=B0=95=EC=A0=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../killingpart/killingpoint/MainActivity.kt | 68 +++++++++++++++++++ .../killingpoint/data/remote/ApiService.kt | 4 +- .../data/repository/AuthRepository.kt | 7 +- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/killingpart/killingpoint/MainActivity.kt b/app/src/main/java/com/killingpart/killingpoint/MainActivity.kt index 5317c7d..9c15642 100644 --- a/app/src/main/java/com/killingpart/killingpoint/MainActivity.kt +++ b/app/src/main/java/com/killingpart/killingpoint/MainActivity.kt @@ -1,9 +1,13 @@ package com.killingpart.killingpoint import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.graphics.Color as AndroidColor +import android.net.Uri import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity @@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.* @@ -27,6 +32,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.kakao.sdk.common.KakaoSdk import com.killingpart.killingpoint.BuildConfig @@ -73,6 +79,9 @@ class MainActivity : ComponentActivity() { var resolvedStartDestination by rememberSaveable { mutableStateOf(null) } + var showUpdateDialog by rememberSaveable { + mutableStateOf(false) + } LaunchedEffect(Unit) { loginViewModel.tryAutoLogin(context) @@ -100,6 +109,7 @@ class MainActivity : ComponentActivity() { val start = repo.getUserInitSettings() .getOrNull() ?.let { init -> + showUpdateDialog = !init.app.needsForceUpdate when { init.needsPolicyAgreement -> "onboarding_policy" init.needsTagSetup -> "onboarding_name" @@ -112,15 +122,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 } } } @@ -142,6 +155,8 @@ class MainActivity : ComponentActivity() { LaunchState.MAIN -> { val navController = rememberNavController() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentBackStackEntry?.destination?.route val startDestination = resolvedStartDestination ?: "home" @@ -191,6 +206,16 @@ class MainActivity : ComponentActivity() { ) { Text("마지막 화면") } } } + + if (showUpdateDialog && currentRoute?.startsWith("main") == true) { + UpdateRequiredDialog( + onDismiss = { showUpdateDialog = false }, + onUpdateClick = { + showUpdateDialog = false + openPlayStore(context) + } + ) + } } } } @@ -215,3 +240,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/remote/ApiService.kt b/app/src/main/java/com/killingpart/killingpoint/data/remote/ApiService.kt index 30b358e..9130ef1 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 @@ -63,9 +63,7 @@ interface ApiService { @GET("users/init-settings") suspend fun getUserInitSettings( - @Header("Authorization") accessToken: String, - @Query("clientType") clientType: String, - @Query("clientVersion") clientVersion: String + @Header("Authorization") accessToken: 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 4a97ea3..154217a 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 @@ -153,14 +153,11 @@ 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("Bearer $accessToken") }.recoverCatching { e -> if (e is HttpException) { val code = e.code() From da3b31dac798b36c574ed24202b44997a2fbcb8f Mon Sep 17 00:00:00 2001 From: latteeea Date: Tue, 12 May 2026 12:23:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=BF=BC=EB=A6=AC=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=EC=97=90=20clientVersion,=20clientT?= =?UTF-8?q?ype=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../killingpoint/data/remote/ApiService.kt | 4 +++- .../killingpoint/data/repository/AuthRepository.kt | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) 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 9130ef1..356a2da 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 @@ -63,7 +63,9 @@ interface ApiService { @GET("users/init-settings") suspend fun getUserInitSettings( - @Header("Authorization") accessToken: String + @Header("Authorization") accessToken: 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 154217a..51756e5 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 @@ -52,6 +53,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 로 교환 @@ -157,7 +162,11 @@ class AuthRepository( runCatching { val accessToken = getAccessToken() ?: throw IllegalStateException("액세스 토큰이 없습니다") - api.getUserInitSettings("Bearer $accessToken") + api.getUserInitSettings( + accessToken = "Bearer $accessToken", + clientVersion = BuildConfig.VERSION_NAME, + clientType = CLIENT_TYPE + ) }.recoverCatching { e -> if (e is HttpException) { val code = e.code() From dc977bcbe323d29720842343703f8a88f050551e Mon Sep 17 00:00:00 2001 From: latteeea Date: Tue, 12 May 2026 12:33:49 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20AlarmReadStore?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BD=EC=9D=80=20=EC=95=8C=EB=A6=BC=20id=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84=20But=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=97=90=20=EC=83=88=20=EC=95=8C=EB=A6=BC=EC=9D=B4=20?= =?UTF-8?q?=EC=83=9D=EA=B2=BC=EB=8A=94=EC=A7=80=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=BB=AC=EB=A7=8C=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=EB=8A=94=20=EC=95=8C=20=EC=88=98=20=EC=97=86=EC=96=B4=EC=84=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=B3=B4=EA=B4=80=ED=95=A8=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=ED=98=84=EC=9E=AC=EC=9D=98=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20->=20AlarmId=20=EC=A0=80=EC=9E=A5=20->=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=EC=97=90=20=EC=9D=BD=EC=9D=80=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=9D=80=20=ED=9A=8C=EC=83=89,=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=20=EB=93=A4=EC=96=B4=EC=98=A8=20=EC=95=8C=EB=A6=BC=EC=9D=80=20?= =?UTF-8?q?=ED=9D=B0=EC=83=89=20=EC=B2=98=EB=A6=AC=20=3D>=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20api,=20=EC=9D=BD=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EC=95=8C=EB=A6=BC=20=EC=A1=B4=EC=9E=AC/?= =?UTF-8?q?=EA=B0=9C=EC=88=98=20=EB=B0=98=ED=99=98=20api=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=A0=20=EA=B2=83=EC=9C=BC=EB=A1=9C=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../killingpoint/data/local/AlarmReadStore.kt | 59 +++++++++++++++++++ .../KillingPointFirebaseMessagingService.kt | 2 + .../ui/screen/SocialScreen/AlarmListScreen.kt | 7 ++- .../ui/screen/SocialScreen/SocialScreen.kt | 20 ++++--- .../ui/viewmodel/AlarmViewModel.kt | 34 +++++++++-- 5 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/killingpart/killingpoint/data/local/AlarmReadStore.kt 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..eae3928 --- /dev/null +++ b/app/src/main/java/com/killingpart/killingpoint/data/local/AlarmReadStore.kt @@ -0,0 +1,59 @@ +package com.killingpart.killingpoint.data.local + +import android.content.Context + +object AlarmReadStore { + private const val PREF_NAME = "alarm_read_state" + private const val KEY_READ_ALARM_IDS = "read_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 markAlarmsRead(context: Context, alarmIds: Collection) { + if (alarmIds.isEmpty()) return + + val appContext = context.applicationContext + val preferences = appContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val updatedIds = preferences + .getStringSet(KEY_READ_ALARM_IDS, emptySet()) + .orEmpty() + .toMutableSet() + .apply { + addAll(alarmIds.map { it.toString() }) + } + + preferences.edit() + .putStringSet(KEY_READ_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 readIds = getReadAlarmIds(context) + return alarmIds.any { it !in readIds } + } + + 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/notification/KillingPointFirebaseMessagingService.kt b/app/src/main/java/com/killingpart/killingpoint/notification/KillingPointFirebaseMessagingService.kt index fc4785b..0d477c1 100644 --- a/app/src/main/java/com/killingpart/killingpoint/notification/KillingPointFirebaseMessagingService.kt +++ b/app/src/main/java/com/killingpart/killingpoint/notification/KillingPointFirebaseMessagingService.kt @@ -14,6 +14,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 @@ -30,6 +31,7 @@ class KillingPointFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) + AlarmReadStore.markLocalUnread(applicationContext) createNotificationChannel() val title = message.notification?.title 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 cf83957..38e70d0 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 @@ -15,7 +15,6 @@ 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.lazy.items import androidx.compose.foundation.background import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -157,7 +156,9 @@ fun AlarmListScreen(navController: NavController) { .padding(top = 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 textColor = if (item.isRead) Color(0xFFA4A4A6) else Color.White val diaryId = parseDiaryIdFromDeepLink(alarm.deepLink) Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -188,7 +189,7 @@ fun AlarmListScreen(navController: NavController) { ) { Text( text = alarm.content, - color = Color.White, + color = textColor, fontFamily = PaperlogyFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, 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 44d9d60..5f23b40 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.* @@ -114,16 +115,19 @@ fun SocialScreen(navController: NavController, initialTab: String = "feed") { 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..7ba667d 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) @@ -32,7 +38,16 @@ class AlarmViewModel( viewModelScope.launch { loadAllAlarmPages(repo, size) .onSuccess { alarms -> - _state.value = AlarmUiState.Success(alarms) + val readIds = AlarmReadStore.getReadAlarmIds(context) + val uiItems = alarms.map { alarm -> + AlarmUiItem( + alarm = alarm, + isRead = alarm.alarmId in readIds + ) + } + _state.value = AlarmUiState.Success(uiItems) + AlarmReadStore.markAlarmsRead(context, alarms.map { it.alarmId }) + _hasUnread.value = false } .onFailure { e -> _state.value = AlarmUiState.Error(e.message ?: "알림 목록 조회 실패") @@ -41,8 +56,19 @@ class AlarmViewModel( } fun refreshAlarmFlag(context: Context) { - // TODO: 백엔드 미확인 알림(hasUnread) API 연동 전까지는 항상 false 유지 - _hasUnread.value = false + 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(