Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<activity
android:name=".presentation.splash.SplashActivity"
android:exported="true"
android:theme="@style/Theme.Festabook">
android:theme="@style/Theme.Festabook.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,93 +2,102 @@ package com.daedan.festabook.presentation.splash

import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.daedan.festabook.FestaBookApp
import com.daedan.festabook.R
import com.daedan.festabook.presentation.explore.ExploreActivity
import com.daedan.festabook.presentation.main.MainActivity
import com.daedan.festabook.presentation.splash.component.NetworkErrorDialog
import com.daedan.festabook.presentation.splash.component.UpdateDialog
import com.daedan.festabook.presentation.theme.FestabookTheme
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.launch

class SplashActivity : AppCompatActivity() {
private val viewModel: SplashViewModel by viewModels()
private val launcher by lazy {

private val updateResultLauncher =
registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
if (result.resultCode == RESULT_OK) {
setupObserver()
viewModel.handleVersionCheckResult(Result.success(false))
} else {
exitDialog().show()
viewModel.handleVersionCheckResult(Result.failure(Exception("Update failed")))
}
}
}

@Inject
override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory

@Inject
private lateinit var appVersionManagerFactory: AppVersionManager.Factory

private val appVersionManager by lazy { appVersionManagerFactory.create(launcher) }
private val appVersionManager by lazy { appVersionManagerFactory.create(updateResultLauncher) }

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition {
viewModel.isValidationComplete.value != true
}
enableEdgeToEdge()
super.onCreate(savedInstanceState)
(application as FestaBookApp).festaBookGraph.inject(this)
setContentView(R.layout.activity_splash)
checkIsAppUpdateAvailable {
setupObserver()
}
}

private fun checkIsAppUpdateAvailable(onSuccess: () -> Unit) {
if (!isNetworkConnected()) {
exitDialog().show()
return
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)

splashScreen.setKeepOnScreenCondition {
viewModel.uiState.value is SplashUiState.Loading
}

lifecycleScope.launch {
appVersionManager
.getIsAppUpdateAvailable()
.onSuccess { isUpdateAvailable ->
if (isUpdateAvailable) {
updateDialog {
appVersionManager.updateApp()
}.show()
} else {
onSuccess()
}
}.onFailure {
exitDialog().show()
enableEdgeToEdge()

setContent {
FestabookTheme {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

LaunchedEffect(Unit) {
// 앱 실행 시 즉시 앱 버전 업데이트의 필요 유무 확인
val result = appVersionManager.getIsAppUpdateAvailable()
viewModel.handleVersionCheckResult(result)
}
}
}

private fun setupObserver() {
viewModel.navigationState.observe(this) { state ->
when (state) {
is NavigationState.NavigateToExplore -> {
// ExploreActivity로 이동
val intent = Intent(this@SplashActivity, ExploreActivity::class.java)
startActivity(intent)
finish()
LaunchedEffect(uiState) {
when (val state = uiState) {
is SplashUiState.NavigateToExplore -> {
startActivity(Intent(this@SplashActivity, ExploreActivity::class.java))
finish()
}

is SplashUiState.NavigateToMain -> {
val intent =
Intent(this@SplashActivity, MainActivity::class.java).apply {
putExtra("festivalId", state.festivalId)
}
startActivity(intent)
finish()
}

else -> {}
}
}

is NavigationState.NavigateToMain -> {
// MainActivity로 이동
val intent = Intent(this@SplashActivity, MainActivity::class.java)
startActivity(intent)
finish()
when (uiState) {
is SplashUiState.ShowUpdateDialog -> {
UpdateDialog(
onConfirm = { appVersionManager.updateApp() },
)
}

is SplashUiState.ShowNetworkErrorDialog -> {
NetworkErrorDialog(
onConfirm = { finish() },
)
}

else -> {}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.daedan.festabook.presentation.splash

sealed interface SplashUiState {
data object Loading : SplashUiState

data object ShowUpdateDialog : SplashUiState

data object ShowNetworkErrorDialog : SplashUiState

data class NavigateToMain(
val festivalId: Long,
) : SplashUiState

data object NavigateToExplore : SplashUiState
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.daedan.festabook.presentation.splash

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource
import com.daedan.festabook.di.viewmodel.ViewModelKey
import com.daedan.festabook.presentation.common.SingleLiveData
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber

@ContributesIntoMap(AppScope::class)
Expand All @@ -17,25 +17,30 @@ import timber.log.Timber
class SplashViewModel(
private val festivalLocalDataSource: FestivalLocalDataSource,
) : ViewModel() {
private val _navigationState = SingleLiveData<NavigationState>()
val navigationState: LiveData<NavigationState> = _navigationState
private val _uiState = MutableStateFlow<SplashUiState>(SplashUiState.Loading)
val uiState: StateFlow<SplashUiState> = _uiState.asStateFlow()

private val _isValidationComplete = MutableLiveData(false)
val isValidationComplete: LiveData<Boolean> = _isValidationComplete

init {
checkFestivalId()
fun handleVersionCheckResult(result: Result<Boolean>) {
result
.onSuccess { isUpdateAvailable ->
if (isUpdateAvailable) {
_uiState.value = SplashUiState.ShowUpdateDialog
} else {
checkFestivalId()
}
}.onFailure {
_uiState.value = SplashUiState.ShowNetworkErrorDialog
}
}

private fun checkFestivalId() {
val festivalId = festivalLocalDataSource.getFestivalId()
Timber.d("festival ID : $festivalId")
Timber.d("현재 접속중인 festival ID : $festivalId")

if (festivalId == null) {
_navigationState.setValue(NavigationState.NavigateToExplore)
_uiState.value = SplashUiState.NavigateToExplore
} else {
_navigationState.setValue(NavigationState.NavigateToMain(festivalId))
_uiState.value = SplashUiState.NavigateToMain(festivalId)
}
Comment on lines 41 to 51
Copy link

@coderabbitai coderabbitai bot Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# FestivalLocalDataSource와 getFestivalId 구현 확인
rg -n --type=kt -C3 'class\s+FestivalLocalDataSource|interface\s+FestivalLocalDataSource'

Repository: festabook/android

Length of output: 87


🏁 Script executed:

#!/bin/bash
# getFestivalId 메서드 구현 확인
rg -n --type=kt -C5 'fun\s+getFestivalId\s*\('

Repository: festabook/android

Length of output: 87


🏁 Script executed:

#!/bin/bash
# FestivalLocalDataSource 파일 위치 찾기
fd -t f FestivalLocalDataSource

Repository: festabook/android

Length of output: 240


🏁 Script executed:

cat -n app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSource.kt

Repository: festabook/android

Length of output: 322


🏁 Script executed:

cat -n app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt

Repository: festabook/android

Length of output: 1647


🏁 Script executed:

cat -n app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt

Repository: festabook/android

Length of output: 1994


SharedPreferences I/O로 인한 메인 스레드 블로킹 위험을 반드시 해결하세요.

getFestivalId()는 SharedPreferences의 동기 연산(getLong())을 사용하므로 메인 스레드를 블로킹합니다. 특히 첫 실행 시 디스크에서 읽기가 발생할 수 있어 스플래시 화면에서 UI 지연이 발생할 수 있습니다. 아래와 같이 IO 디스패처로 이동해 주세요.

🔧 수정 예시
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
 ...
-    private fun checkFestivalId() {
-        val festivalId = festivalLocalDataSource.getFestivalId()
-        Timber.d("현재 접속중인 festival ID : $festivalId")
-
-        if (festivalId == null) {
-            _uiState.value = SplashUiState.NavigateToExplore
-        } else {
-            _uiState.value = SplashUiState.NavigateToMain(festivalId)
-        }
-    }
+    private fun checkFestivalId() {
+        viewModelScope.launch(Dispatchers.IO) {
+            val festivalId = festivalLocalDataSource.getFestivalId()
+            Timber.d("현재 접속중인 festival ID : $festivalId")
+            _uiState.value =
+                if (festivalId == null) SplashUiState.NavigateToExplore
+                else SplashUiState.NavigateToMain(festivalId)
+        }
+    }
🤖 Prompt for AI Agents
In
`@app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt`
around lines 36 - 44, checkFestivalId currently calls
festivalLocalDataSource.getFestivalId() on the main thread which can block UI;
move the SharedPreferences read onto an IO dispatcher: wrap the call in a
coroutine (e.g., viewModelScope.launch(Dispatchers.IO) or using
withContext(Dispatchers.IO)) inside SplashViewModel.checkFestivalId, read
festivalId on IO, then post the result back to the main thread before updating
_uiState (ensure you set _uiState.value = SplashUiState.NavigateToExplore or
SplashUiState.NavigateToMain(festivalId) from the main thread after the IO
read).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토끼야 날카로웠다?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@parkjiminnnn 감사합니다! 😊 스플래시 화면에서 UI 블로킹이 발생하지 않도록 SharedPreferences 읽기를 IO 디스패처로 옮기시면 더 부드러운 사용자 경험을 제공하실 수 있을 거예요.

_isValidationComplete.value = true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.daedan.festabook.presentation.splash.component

import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.daedan.festabook.R
import com.daedan.festabook.presentation.theme.FestabookColor
import com.daedan.festabook.presentation.theme.FestabookTheme
import com.daedan.festabook.presentation.theme.FestabookTypography
import com.daedan.festabook.presentation.theme.festabookShapes

@Composable
fun UpdateDialog(onConfirm: () -> Unit) {
SplashInfoDialog(
title = stringResource(id = R.string.update_notice_title),
message = stringResource(id = R.string.update_notice_message),
buttonText = stringResource(id = R.string.update_notice_confirm),
iconResId = R.drawable.ic_alarm,
confirmButtonColor = FestabookColor.accentBlue,
onConfirm = onConfirm,
)
}

@Composable
fun NetworkErrorDialog(onConfirm: () -> Unit) {
SplashInfoDialog(
title = stringResource(id = R.string.update_failed_title),
message = stringResource(id = R.string.update_failed_message),
buttonText = stringResource(id = R.string.update_failed_confirm),
confirmButtonColor = FestabookColor.gray400,
onConfirm = onConfirm,
)
}

@Composable
private fun SplashInfoDialog(
title: String,
message: String,
buttonText: String,
confirmButtonColor: Color,
@DrawableRes iconResId: Int? = null,
onConfirm: () -> Unit,
) {
Dialog(onDismissRequest = {}) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DialogProperties(
        dismissOnBackPress = false,
        dismissOnClickOutside = false
    )

다음 코드를 추가해서 안전하게 다이어로그가 안닫히게 하는건 어떠신가용?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그 부분은 생각하지 못했네요! 바로 적용했습니다~
feat(Splash): 스플래시 다이얼로그 외부 클릭 및 뒤로가기 방지

Column(
modifier =
Modifier
.background(
color = FestabookColor.white,
shape = festabookShapes.radius4,
).padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
iconResId?.let {
Image(
painter = painterResource(id = it),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.height(16.dp))
}

Text(
text = title,
style = FestabookTypography.displaySmall,
textAlign = TextAlign.Center,
color = FestabookColor.gray800,
)

Spacer(modifier = Modifier.height(16.dp))

Text(
text = message,
style = FestabookTypography.bodyMedium,
textAlign = TextAlign.Center,
color = FestabookColor.gray800,
)

Spacer(modifier = Modifier.height(24.dp))

Button(
onClick = onConfirm,
modifier =
Modifier
.fillMaxWidth(),
colors =
ButtonDefaults.buttonColors(
containerColor = confirmButtonColor,
contentColor = FestabookColor.white,
),
shape = festabookShapes.radiusFull,
) {
Text(text = buttonText)
}
}
}
}

@Preview
@Composable
private fun UpdateDialogPreview() {
FestabookTheme {
UpdateDialog(onConfirm = {})
}
}

@Preview
@Composable
private fun NetworkErrorDialogPreview() {
FestabookTheme {
NetworkErrorDialog(onConfirm = {})
}
}
Binary file added app/src/main/res/drawable/logo_festabook_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,10 @@

<!--앱 업데이트-->
<string name="update_notice_title">신규 버전 출시 안내</string>
<string name="update_notice_message">새로운 버전이 출시되었습니다.\n 더 나은 사용을 위해 앱을 업데이트해 주세요</string>
<string name="update_notice_message">새로운 버전이 출시되었어요.\n 더 나은 사용을 위해 앱을 업데이트해 주세요.</string>
<string name="update_notice_confirm">업데이트</string>
<string name="update_failed_title">서버 통신 실패</string>
<string name="update_failed_message">앱 서버에 접속할 수 없습니다. 다시 시도해주세요</string>
<string name="update_failed_message">앱 서버에 접속할 수 없습니다. 다시 시도해주세요.</string>
Comment on lines +151 to +154
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

줄바꿈 뒤 선행 공백으로 들여쓰기 발생 가능
update_notice_message의 \n 뒤 공백이 의도치 않은 들여쓰기를 만들 수 있어요. 의도한 여백이 아니라면 공백을 제거해 주세요.

✂️ 제안 수정안
-    <string name="update_notice_message">새로운 버전이 출시되었어요.\n 더 나은 사용을 위해 앱을 업데이트해 주세요.</string>
+    <string name="update_notice_message">새로운 버전이 출시되었어요.\n더 나은 사용을 위해 앱을 업데이트해 주세요.</string>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<string name="update_notice_message">새로운 버전이 출시되었어요.\n 더 나은 사용을 위해 앱을 업데이트해 주세요.</string>
<string name="update_notice_confirm">업데이트</string>
<string name="update_failed_title">서버 통신 실패</string>
<string name="update_failed_message">앱 서버에 접속할 수 없습니다. 다시 시도해주세요</string>
<string name="update_failed_message">앱 서버에 접속할 수 없습니다. 다시 시도해주세요.</string>
<string name="update_notice_message">새로운 버전이 출시되었어요.\n더 나은 사용을 위해 앱을 업데이트해 주세요.</string>
<string name="update_notice_confirm">업데이트</string>
<string name="update_failed_title">서버 통신 실패</string>
<string name="update_failed_message">앱 서버에 접속할 수 없습니다. 다시 시도해주세요.</string>
🤖 Prompt for AI Agents
In `@app/src/main/res/values/strings.xml` around lines 151 - 154, The string
update_notice_message contains "\n " (a newline followed by a space) which can
create an unintended indent; update the value for update_notice_message to
remove the space after the newline so it uses "\n" (or replace the newline+space
with an explicit non-breaking space or desired spacing) to ensure no accidental
leading whitespace is rendered.

<string name="update_failed_confirm">종료</string>
<string name="explore_btn_backToMain_content_desc">홈화면으로 돌아가기</string>
<string name="explore_toolbar_title">축제 검색</string>
Expand Down
Loading