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/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/data/remote/ApiService.kt b/app/src/main/java/com/killingpart/killingpoint/data/remote/ApiService.kt index 30b358e..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 @@ -64,8 +64,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 4a97ea3..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 로 교환 @@ -153,14 +158,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 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(