Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions app/src/main/java/com/killingpart/killingpoint/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.*
Expand All @@ -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
Expand Down Expand Up @@ -73,6 +79,9 @@ class MainActivity : ComponentActivity() {
var resolvedStartDestination by rememberSaveable {
mutableStateOf<String?>(null)
}
var showUpdateDialog by rememberSaveable {
mutableStateOf(false)
}

LaunchedEffect(Unit) {
loginViewModel.tryAutoLogin(context)
Expand Down Expand Up @@ -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"
Expand All @@ -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
}
}
}
Expand All @@ -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"

Expand Down Expand Up @@ -191,6 +206,16 @@ class MainActivity : ComponentActivity() {
) { Text("마지막 화면") }
}
}

if (showUpdateDialog && currentRoute?.startsWith("main") == true) {
UpdateRequiredDialog(
onDismiss = { showUpdateDialog = false },
onUpdateClick = {
showUpdateDialog = false
openPlayStore(context)
}
)
}
}
}
}
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> {
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<Long>) {
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<Long>): 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 로 교환
Expand Down Expand Up @@ -153,14 +158,15 @@ class AuthRepository(
}
}

suspend fun getUserInitSettings(
clientType: String = "ANDROID",
clientVersion: String = "1.0.0"
): Result<UserInitSettingsResponse> = withContext(Dispatchers.IO) {
suspend fun getUserInitSettings(): Result<UserInitSettingsResponse> = 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,7 @@ class KillingPointFirebaseMessagingService : FirebaseMessagingService() {

override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
AlarmReadStore.markLocalUnread(applicationContext)
createNotificationChannel()

val title = message.notification?.title
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -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)
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,10 +12,15 @@ import kotlinx.coroutines.launch

sealed interface AlarmUiState {
data object Loading : AlarmUiState
data class Success(val alarms: List<AlarmItem>) : AlarmUiState
data class Success(val alarms: List<AlarmUiItem>) : 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)
Expand All @@ -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 ?: "알림 목록 조회 실패")
Expand All @@ -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(
Expand Down