From 2219c74545a5d98a3980b821de5709740624e14d Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:21:16 +0900 Subject: [PATCH 01/54] =?UTF-8?q?Feat]:=20ExploreScreen=20CMP=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Feat]: ExploreScreen CMP 마이그레이션 --- .../com/daedan/festabook/MainActivity.kt | 16 +- .../drawable/ic_arrow_back.xml | 2 +- .../composeResources/drawable/ic_close.xml | 2 +- .../presentation/explore/ExploreSideEffect.kt | 9 + .../presentation/explore/ExploreUiState.kt | 7 + .../presentation/explore/ExploreViewModel.kt | 95 ++++++++ .../presentation/explore/SearchUiState.kt | 25 ++ .../explore/component/ExploreBackHeader.kt | 51 ++++ .../explore/component/ExploreResultItem.kt | 52 ++++ .../explore/component/ExploreScreen.kt | 225 ++++++++++++++++++ .../explore/component/ExploreSearchBar.kt | 144 +++++++++++ .../explore/component/ExploreSearchContent.kt | 66 +++++ .../component/ExploreSearchResultList.kt | 88 +++++++ .../explore/model/SearchResultUiModel.kt | 16 ++ 14 files changed, 784 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreSideEffect.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchBar.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt index 7234d065..fbb1fa07 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt @@ -9,8 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil3.imageLoader -import com.daedan.festabook.presentation.platform.rememberAppVersionManager -import com.daedan.festabook.presentation.splash.component.SplashScreen +import com.daedan.festabook.presentation.explore.component.ExploreScreen import com.daedan.festabook.presentation.theme.FestabookTheme import com.skydoves.landscapist.coil3.LocalCoilImageLoader import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory @@ -32,17 +31,10 @@ class MainActivity : ComponentActivity() { LocalCoilImageLoader provides imageLoader, ) { Scaffold { innerPadding -> - SplashScreen( - viewModel = viewModel(factory = metroVmf), - appVersionManager = - rememberAppVersionManager( - appGraph = (application as FestabookApp).festabookAppGraph, - onUpdateFailure = {}, - onUpdateSuccess = {}, - ), + ExploreScreen( onNavigateToMain = {}, - onNavigateToExplore = {}, - onFinishApp = { finish() }, + onBackClick = {}, + viewModel = viewModel(factory = metroVmf), ) } } diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_arrow_back.xml b/composeApp/src/commonMain/composeResources/drawable/ic_arrow_back.xml index 0feaf8ca..119cc7b8 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_arrow_back.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_arrow_back.xml @@ -1,5 +1,5 @@ - + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_close.xml b/composeApp/src/commonMain/composeResources/drawable/ic_close.xml index f8ca0c64..75a5a72b 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_close.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_close.xml @@ -1,5 +1,5 @@ - + diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreSideEffect.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreSideEffect.kt new file mode 100644 index 00000000..27f0eb8b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreSideEffect.kt @@ -0,0 +1,9 @@ +package com.daedan.festabook.presentation.explore + +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel + +sealed interface ExploreSideEffect { + data class NavigateToMain( + val searchResult: SearchResultUiModel, + ) : ExploreSideEffect +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt new file mode 100644 index 00000000..31254fed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.presentation.explore + +data class ExploreUiState( + val query: String = "", + val searchState: SearchUiState = SearchUiState.Idle, + val hasFestivalId: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt new file mode 100644 index 00000000..c1ee7cb4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt @@ -0,0 +1,95 @@ +package com.daedan.festabook.presentation.explore + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.repository.ExploreRepository +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.explore.model.toUiModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@OptIn(FlowPreview::class) +@ContributesIntoMap(AppScope::class) +@ViewModelKey(ExploreViewModel::class) +@Inject +class ExploreViewModel( + private val exploreRepository: ExploreRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ExploreUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val sideEffect = _sideEffect.asSharedFlow() + + init { + checkFestivalId() + observeSearchQuery() + } + + private fun checkFestivalId() { + viewModelScope.launch { + val festivalId = exploreRepository.getFestivalId() +// Timber.d("festival ID : $festivalId") + if (festivalId != null) { + _uiState.update { it.copy(hasFestivalId = true) } + } + } + } + + private fun observeSearchQuery() { + viewModelScope.launch { + _uiState + .map { it.query } + .distinctUntilChanged() + .debounce(300L) + .collectLatest { query -> + if (query.isBlank()) { + _uiState.update { it.copy(searchState = SearchUiState.Idle) } + return@collectLatest + } + + _uiState.update { it.copy(searchState = SearchUiState.Loading) } + + exploreRepository + .search(query) + .onSuccess { universitiesFound -> +// Timber.d("검색 성공 - received: $universitiesFound") + val uiModels = universitiesFound.map { it.toUiModel() } + _uiState.update { + it.copy(searchState = SearchUiState.Success(universitiesFound = uiModels)) + } + }.onFailure { throwable -> +// Timber.d(throwable, "검색 실패") + _uiState.update { + it.copy(searchState = SearchUiState.Error(throwable)) + } + } + } + } + } + + fun onUniversitySelected(university: SearchResultUiModel) { + viewModelScope.launch { + exploreRepository.saveFestivalId(university.festivalId) + _sideEffect.emit(ExploreSideEffect.NavigateToMain(university)) + } + } + + fun onTextInputChanged(query: String) { + _uiState.update { it.copy(query = query) } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt new file mode 100644 index 00000000..86955e40 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.explore + +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel + +sealed interface SearchUiState { + val isEmptyResult: Boolean get() = false + val isFailure: Boolean get() = this is Error + val shouldShowErrorUi: Boolean get() = isFailure || isEmptyResult + + data object Idle : SearchUiState + + data object Loading : SearchUiState + + data class Success( + val universitiesFound: List = emptyList(), +// val selectedUniversity: University? = null, + ) : SearchUiState { + override val isEmptyResult: Boolean + get() = universitiesFound.isEmpty() + } + + data class Error( + val throwable: Throwable, + ) : SearchUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt new file mode 100644 index 00000000..39de1c04 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt @@ -0,0 +1,51 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.unit.dp +import com.daedan.festabook.presentation.theme.FestabookTheme +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.explore_back +import festabookkmp.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun ExploreBackHeader( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .height(56.dp), + contentAlignment = Alignment.CenterStart, + ) { + IconButton( + onClick = onBackClick, + ) { + Icon( + imageVector = vectorResource(Res.drawable.ic_arrow_back), + contentDescription = stringResource(Res.string.explore_back), + tint = Color.Unspecified, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreBackHeaderPreview() { + FestabookTheme { + ExploreBackHeader(onBackClick = {}) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt new file mode 100644 index 00000000..1c57abab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt @@ -0,0 +1,52 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun ExploreResultItem( + university: SearchResultUiModel, + onItemClick: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .clickable { onItemClick(university) } + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = university.universityName, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray800, + ) + Text( + text = university.festivalName, + style = FestabookTypography.bodySmall, + color = FestabookColor.gray600, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreResultItemPreview() { + FestabookTheme { + ExploreResultItem( + university = SearchResultUiModel(1, "서울시립대학교", "2024 대동제"), + onItemClick = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt new file mode 100644 index 00000000..58df6dda --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt @@ -0,0 +1,225 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.presentation.explore.ExploreSideEffect +import com.daedan.festabook.presentation.explore.ExploreViewModel +import com.daedan.festabook.presentation.explore.SearchUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.explore_festabook_logo +import festabookkmp.composeapp.generated.resources.logo_title +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun ExploreScreen( + onNavigateToMain: (SearchResultUiModel) -> Unit, + onBackClick: () -> Unit, + viewModel: ExploreViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val keyboardController = LocalSoftwareKeyboardController.current + + val latestNavigateToMain by rememberUpdatedState(onNavigateToMain) + val latestKeyboardController by rememberUpdatedState(keyboardController) + + LaunchedEffect(viewModel) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is ExploreSideEffect.NavigateToMain -> { + latestKeyboardController?.hide() + latestNavigateToMain(effect.searchResult) + } + } + } + } + + if (uiState.hasFestivalId) { + ExploreSearchScreen( + query = uiState.query, + searchState = uiState.searchState, + onQueryChange = viewModel::onTextInputChanged, + onUniversitySelect = viewModel::onUniversitySelected, + onBackClick = onBackClick, + ) + } else { + ExploreLandingScreen( + query = uiState.query, + searchState = uiState.searchState, + onQueryChange = viewModel::onTextInputChanged, + onUniversitySelect = viewModel::onUniversitySelected, + ) + } +} + +@Composable +fun ExploreSearchScreen( + query: String, + searchState: SearchUiState, + onQueryChange: (String) -> Unit, + onUniversitySelect: (SearchResultUiModel) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = Color.White, + topBar = { + ExploreBackHeader( + onBackClick = onBackClick, + modifier = Modifier.statusBarsPadding(), + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(20.dp), + ) { + ExploreSearchContent( + query = query, + searchState = searchState, + onQueryChange = onQueryChange, + onUniversitySelect = onUniversitySelect, + ) + } + } +} + +@Composable +fun ExploreLandingScreen( + query: String, + searchState: SearchUiState, + onQueryChange: (String) -> Unit, + onUniversitySelect: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val isError = searchState.shouldShowErrorUi + + val isSearchMode = query.isNotBlank() + + Scaffold( + modifier = modifier, + containerColor = Color.White, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .imePadding() + .clickable( + onClick = { + keyboardController?.hide() + }, + indication = null, + interactionSource = null, + ), + ) { + Column( + modifier = + Modifier + .padding(top = 16.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer( + modifier = + Modifier + .weight(0.3f), + ) + + Image( + painter = painterResource(Res.drawable.logo_title), + contentDescription = stringResource(Res.string.explore_festabook_logo), + modifier = Modifier.height(24.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + ExploreSearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = { keyboardController?.hide() }, + isError = isError, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + AnimatedContent( + targetState = isSearchMode, + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(tween(200)), + initialContentExit = fadeOut(tween(200)), + ) + }, + ) { searching -> + if (searching) { + ExploreSearchResultList( + searchState = searchState, + onUniversitySelect = onUniversitySelect, + ) + } + } + + Spacer(modifier = Modifier.weight(0.7f)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchScreenPreview() { + FestabookTheme { + ExploreSearchScreen( + query = "서울", + searchState = SearchUiState.Idle, + onQueryChange = {}, + onUniversitySelect = {}, + onBackClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreLandingScreenPreview() { + FestabookTheme { + ExploreLandingScreen( + query = "ㅇㄹㅇ", + searchState = SearchUiState.Idle, + onQueryChange = {}, + onUniversitySelect = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchBar.kt new file mode 100644 index 00000000..03ba213d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchBar.kt @@ -0,0 +1,144 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +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 +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.explore_no_search_result_text +import festabookkmp.composeapp.generated.resources.explore_search_hint_text +import festabookkmp.composeapp.generated.resources.ic_close +import festabookkmp.composeapp.generated.resources.ic_search +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun ExploreSearchBar( + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + isError: Boolean, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = + modifier + .fillMaxWidth(), + placeholder = { + Text( + text = stringResource(Res.string.explore_search_hint_text), + style = FestabookTypography.titleMedium, + color = FestabookColor.gray400, + ) + }, + supportingText = + if (isError) { + { + Text( + stringResource(Res.string.explore_no_search_result_text), + color = FestabookColor.error, + ) + } + } else { + null + }, + singleLine = true, + textStyle = + FestabookTypography.titleMedium.copy( + color = FestabookColor.gray800, + ), + shape = festabookShapes.radiusFull, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = FestabookColor.gray800, + unfocusedBorderColor = FestabookColor.gray400, + errorBorderColor = FestabookColor.error, + cursorColor = FestabookColor.gray800, + errorCursorColor = FestabookColor.error, + disabledBorderColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + ), + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + painter = painterResource(Res.drawable.ic_close), + contentDescription = "Clear text", + tint = Color.Unspecified, + ) + } + } else { + IconButton(onClick = { onSearch(query) }) { + Icon( + painter = painterResource(Res.drawable.ic_search), + contentDescription = "Search", + tint = Color.Unspecified, + ) + } + } + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Search, + showKeyboardOnFocus = true, + ), + keyboardActions = + KeyboardActions( + onSearch = { + onSearch(query) + keyboardController?.hide() + }, + ), + isError = isError, + ) +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchBarPreview() { + FestabookTheme { + ExploreSearchBar( + query = "", + onQueryChange = {}, + isError = false, + modifier = Modifier.padding(16.dp), + onSearch = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchBarErrorPreview() { + FestabookTheme { + ExploreSearchBar( + query = "서울시립대학교", + onQueryChange = {}, + isError = true, + modifier = Modifier.padding(16.dp), + onSearch = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt new file mode 100644 index 00000000..e18b5c5f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt @@ -0,0 +1,66 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.SearchUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun ExploreSearchContent( + query: String, + searchState: SearchUiState, + onQueryChange: (String) -> Unit, + onUniversitySelect: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val isError = searchState.shouldShowErrorUi + + Column( + modifier = + modifier + .fillMaxSize(), + ) { + Box(modifier = Modifier.padding(bottom = 16.dp)) { + ExploreSearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = { keyboardController?.hide() }, + isError = isError, + ) + } + + ExploreSearchResultList( + searchState = searchState, + onUniversitySelect = onUniversitySelect, + modifier = Modifier.weight(1f), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchContentPreview() { + FestabookTheme { + ExploreSearchContent( + query = "서울", + searchState = + SearchUiState.Success( + listOf( + SearchResultUiModel(1, "서울시립대학교", "2024 대동제"), + SearchResultUiModel(2, "서울대학교", "2024 봄축제"), + ), + ), + onQueryChange = {}, + onUniversitySelect = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt new file mode 100644 index 00000000..8d954a2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt @@ -0,0 +1,88 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.SearchUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun ExploreSearchResultList( + searchState: SearchUiState, + onUniversitySelect: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + when (searchState) { + is SearchUiState.Loading -> { + item { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.padding(top = 20.dp), + color = FestabookColor.accentBlue, + ) + } + } + } + + is SearchUiState.Success -> { + items(searchState.universitiesFound) { university -> + ExploreResultItem( + university = university, + onItemClick = onUniversitySelect, + ) + } + } + + is SearchUiState.Error -> {} + + is SearchUiState.Idle -> {} + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchResultListLoadingPreview() { + FestabookTheme { + ExploreSearchResultList( + searchState = SearchUiState.Loading, + onUniversitySelect = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchResultListSuccessPreview() { + val fakeUniversities = + listOf( + SearchResultUiModel(1, "서울대학교", "대동제"), + SearchResultUiModel(2, "서울시립대학교", "대동제"), + ) + FestabookTheme { + ExploreSearchResultList( + searchState = + SearchUiState.Success( + universitiesFound = fakeUniversities, + ), + onUniversitySelect = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt new file mode 100644 index 00000000..7113ea52 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.presentation.explore.model + +import com.daedan.festabook.domain.model.FestivalSearchItem + +data class SearchResultUiModel( + val festivalId: Long, + val universityName: String, + val festivalName: String, +) + +fun FestivalSearchItem.toUiModel(): SearchResultUiModel = + SearchResultUiModel( + festivalId = festivalId, + universityName = organizationName, + festivalName = festivalName.replace("\n", " "), + ) From 492a480a35f8a8cd177893160c3a0314dcb2bdf5 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:57:54 +0900 Subject: [PATCH 02/54] =?UTF-8?q?[Feat]=20FestabookScreen=20=EB=B0=8F=20Co?= =?UTF-8?q?mpose=20Navigation=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 네비게이션 시스템 구축 및 `Compose Navigation` 마이그레이션 * **feat: 앱 네비게이션 구조 및 라우팅 정의** * `FestabookRoute`와 `MainTabRoute`를 통해 앱의 전체 화면 경로(Splash, Explore, Main 등)를 `Serializable`하게 정의했습니다. * `FestabookMainTab` 열거형을 추가하여 홈, 일정, 지도, 소식, 설정 등 하단 탭 메뉴의 아이콘, 라벨 및 라우트를 통합 관리합니다. * `FestabookNavigator` 클래스를 구현하여 `NavHostController`를 래핑하고 탭 전환 및 화면 이동 로직을 캡슐화했습니다. * **feat: 화면별 네비게이션 그래프 분리 및 구현** * `splashNavGraph`: 스플래시 화면 진입 및 앱 버전 체크 결과에 따른 분기 로직을 포함합니다. * `exploreNavGraph`: 둘러보기 화면 진입 및 메인 화면 이동 로직을 포함합니다. * `FestabookScreen`: `NavHost`를 설정하고 각 기능별 네비게이션 그래프를 조립하는 메인 컨테이너를 구현했습니다. * **feat: 플랫폼 공통 `AppGraph` 접근 로직 추가** * `expect/actual fun rememberAppGraph()`를 정의하여 Android와 iOS 각 플랫폼의 의존성 주입 객체(`FestabookAppGraph`)에 Compose 상에서 접근할 수 있도록 했습니다. * **Android**: `LocalContext`를 통해 `applicationContext`의 `appGraph`를 참조합니다. * **iOS**: `createGraph()`를 사용하여 의존성 그래프를 생성하고 기억합니다. * **build: 프로젝트 의존성 추가** * `build.gradle.kts` 및 `libs.versions.toml`에 JetBrains Compose Navigation (`navigation-compose`) 의존성을 추가했습니다. * refactor: 전역 테마 설정을 통한 의존성 주입 구조 개선 및 공통 화면 통합 * **refactor: `FestabookTheme` 내 전역 의존성 및 이미지 로더 설정 통합** * `CompositionLocalProvider`를 통해 `LocalMetroViewModelFactory`와 `LocalCoilImageLoader`를 테마 수준에서 제공하도록 수정했습니다. * `setSingletonImageLoaderFactory`를 사용하여 `crossfade`가 활성화된 Coil `ImageLoader` 설정을 추가했습니다. * **refactor: 플랫폼별(Android/iOS) 진입점 로직 간소화** * **Android**: `MainActivity`에서 개별적으로 설정하던 `CompositionLocalProvider`와 `Scaffold`를 제거하고, 통합 진입점인 `FestabookScreen`을 호출하도록 변경했습니다. * **iOS**: `MainViewController`에서 불필요한 이미지 로더 및 뷰모델 팩토리 주입 로직을 제거했습니다. * **refactor: `FestabookScreen` 구조 개선** * `appGraph`를 파라미터로 주입받도록 변경하고, `SplashViewModel` 초기화 시 `metroViewModelFactory`를 사용하도록 수정했습니다. * **feat: `CoilImage` 컴포넌트 기능 개선** * 이미지를 불러오지 못할 경우를 대비해 `fallback` 이미지(`Res.drawable.img_fallback`) 설정을 추가했습니다. * refactor: `LocalMetroViewModelFactory`를 활용한 ViewModel 주입 방식 개선 * **refactor: `ExploreNavigation` 및 `SplashNavigation` 내 의존성 주입 로직 수정** * `exploreNavGraph` 및 `splashNavGraph` 함수에서 더 이상 필요하지 않은 `FestabookAppGraph` 파라미터를 제거했습니다. * `viewModel` 생성 시 `appGraph.metroViewModelFactory`를 직접 전달하는 대신, `LocalMetroViewModelFactory.current`를 참조하여 팩토리를 주입받도록 변경했습니다. * **refactor: `FestabookScreen` 내 네비게이션 호출부 최적화** * 네비게이션 그래프 구성 시 `appGraph` 인자를 넘겨주던 코드를 제거하여 인터페이스를 간소화했습니다. * chore: 미사용 `FestabookAppGraph` 임포트 제거 * **chore: 네비게이션 관련 파일 내 불필요한 임포트 삭제** * `ExploreNavigation.kt` 및 `SplashNavigation.kt`에서 더 이상 사용되지 않는 `FestabookAppGraph` 임포트를 제거했습니다. * chore: `composeApp/build.gradle.kts` 내 미사용 내비게이션 의존성 제거 * **chore: `composeApp/build.gradle.kts` 내 미사용 의존성 삭제** * `commonMain` 소스셋의 의존성 설정에서 더 이상 사용되지 않는 `libs.compose.navigation`을 제거했습니다. --- composeApp/build.gradle.kts | 1 + .../com/daedan/festabook/MainActivity.kt | 23 +----- .../daedan/festabook/di/AndroidAppGraph.kt | 4 + .../platform/RememberAppGraph.android.kt | 13 +++ .../festabook/presentation/FestabookScreen.kt | 66 +++++++++++++++ .../common/component/CoilImage.kt | 4 + .../explore/navigation/ExploreNavigation.kt | 22 +++++ .../presentation/main/FestabookMainTab.kt | 69 ++++++++++++++++ .../presentation/main/FestabookNavigator.kt | 81 +++++++++++++++++++ .../presentation/main/FestabookRoute.kt | 41 ++++++++++ .../presentation/platform/RememberAppGraph.kt | 7 ++ .../splash/navigation/SplashNavigation.kt | 27 +++++++ .../presentation/theme/FestabookTheme.kt | 19 +++++ .../daedan/festabook/MainViewController.kt | 37 +-------- .../platform/RememberAppGraph.ios.kt | 10 +++ gradle/libs.versions.toml | 2 + 16 files changed, 374 insertions(+), 52 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookMainTab.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/splash/navigation/SplashNavigation.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ad6b6772..f0d757f7 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -87,6 +87,7 @@ kotlin { } commonMain.dependencies { implementation(libs.compose.navigationevent) + implementation(libs.compose.navigation) implementation(libs.coil.compose) implementation(libs.landscapist.coil3) implementation(libs.landscapist.placeholder) diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt index fbb1fa07..d1d8b863 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt @@ -4,15 +4,9 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.lifecycle.viewmodel.compose.viewModel -import coil3.imageLoader -import com.daedan.festabook.presentation.explore.component.ExploreScreen +import com.daedan.festabook.presentation.FestabookScreen import com.daedan.festabook.presentation.theme.FestabookTheme -import com.skydoves.landscapist.coil3.LocalCoilImageLoader -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory import org.jetbrains.compose.ui.tooling.preview.Preview class MainActivity : ComponentActivity() { @@ -26,18 +20,9 @@ class MainActivity : ComponentActivity() { setContent { FestabookTheme { - CompositionLocalProvider( - LocalMetroViewModelFactory provides metroVmf, - LocalCoilImageLoader provides imageLoader, - ) { - Scaffold { innerPadding -> - ExploreScreen( - onNavigateToMain = {}, - onBackClick = {}, - viewModel = viewModel(factory = metroVmf), - ) - } - } + FestabookScreen( + onAppFinish = ::finish, + ) } } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt index 4e3a7b4b..96f1ba00 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt @@ -1,6 +1,8 @@ package com.daedan.festabook.di import android.app.Application +import android.content.Context +import com.daedan.festabook.FestabookApp import com.daedan.festabook.presentation.splash.platform.AppVersionManager import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -30,3 +32,5 @@ interface AndroidAppGraph : FestabookAppGraph { @SingleIn(AppScope::class) fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } + +val Context.appGraph get() = (applicationContext as FestabookApp).festabookAppGraph diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt new file mode 100644 index 00000000..f07f4322 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt @@ -0,0 +1,13 @@ +package com.daedan.festabook.presentation.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.daedan.festabook.di.FestabookAppGraph +import com.daedan.festabook.di.appGraph + +@Composable +actual fun rememberAppGraph(): FestabookAppGraph { + val context = LocalContext.current + return remember(context) { context.appGraph } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt new file mode 100644 index 00000000..3856116e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt @@ -0,0 +1,66 @@ +package com.daedan.festabook.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.navOptions +import com.daedan.festabook.di.FestabookAppGraph +import com.daedan.festabook.presentation.explore.navigation.exploreNavGraph +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.rememberFestabookNavigator +import com.daedan.festabook.presentation.platform.rememberAppGraph +import com.daedan.festabook.presentation.platform.rememberAppVersionManager +import com.daedan.festabook.presentation.platform.rememberLocationSource +import com.daedan.festabook.presentation.splash.SplashViewModel +import com.daedan.festabook.presentation.splash.navigation.splashNavGraph + +@Composable +fun FestabookScreen( + onAppFinish: () -> Unit, + modifier: Modifier = Modifier, + appGraph: FestabookAppGraph = rememberAppGraph(), + splashViewModel: SplashViewModel = viewModel(factory = appGraph.metroViewModelFactory), +) { + val locationSource = rememberLocationSource() + val festabookNavigator = rememberFestabookNavigator() + + val appVersionManager = + rememberAppVersionManager( + appGraph = appGraph, + onUpdateSuccess = { splashViewModel.handleVersionCheckResult(Result.success(false)) }, + onUpdateFailure = { splashViewModel.handleVersionCheckResult(Result.failure(Exception("Update failed"))) }, + ) + + NavHost( + modifier = modifier, + startDestination = festabookNavigator.startRoute, + navController = festabookNavigator.navController, + ) { + splashNavGraph( + appVersionManager = appVersionManager, + onNavigateToExplore = { + festabookNavigator.navigate( + FestabookRoute.Explore, + navOptions { + popUpTo(FestabookRoute.Splash) { + inclusive = true + } + }, + ) + }, + onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main) }, + onFinishApp = onAppFinish, + ) + exploreNavGraph( + onBackClick = { festabookNavigator.popBackStack() }, + onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main) }, + ) +// mainNavGraph( +// appGraph = appGraph, +// onAppFinish = onAppFinish, +// locationSource = locationSource, +// festabookNavigator = festabookNavigator, +// ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/CoilImage.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/CoilImage.kt index f9edf332..674c396a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/CoilImage.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/CoilImage.kt @@ -9,6 +9,9 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.daedan.festabook.presentation.common.convertImageUrl +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.img_fallback +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -30,6 +33,7 @@ fun CoilImage( contentScale = contentScale, placeholder = ColorPainter(Color.LightGray), modifier = modifier, + fallback = painterResource(Res.drawable.img_fallback), ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt new file mode 100644 index 00000000..1cfca105 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.explore.navigation + +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.explore.component.ExploreScreen +import com.daedan.festabook.presentation.main.FestabookRoute +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory + +fun NavGraphBuilder.exploreNavGraph( + onBackClick: () -> Unit, + onNavigateToMain: () -> Unit, +) { + composable { + val viewModelFactory = LocalMetroViewModelFactory.current + ExploreScreen( + viewModel = viewModel(factory = viewModelFactory), + onBackClick = onBackClick, + onNavigateToMain = { onNavigateToMain() }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookMainTab.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookMainTab.kt new file mode 100644 index 00000000..8abd0d86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookMainTab.kt @@ -0,0 +1,69 @@ +package com.daedan.festabook.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import com.daedan.festabook.presentation.theme.FestabookColor +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.ic_home +import festabookkmp.composeapp.generated.resources.ic_map +import festabookkmp.composeapp.generated.resources.ic_news +import festabookkmp.composeapp.generated.resources.ic_schedule +import festabookkmp.composeapp.generated.resources.ic_setting +import festabookkmp.composeapp.generated.resources.menu_home_title +import festabookkmp.composeapp.generated.resources.menu_map_title +import festabookkmp.composeapp.generated.resources.menu_news_title +import festabookkmp.composeapp.generated.resources.menu_schedule_title +import festabookkmp.composeapp.generated.resources.menu_setting_title +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource + +enum class FestabookMainTab( + val iconResId: DrawableResource, + val labelResId: StringResource, + val route: MainTabRoute, +) { + HOME( + iconResId = Res.drawable.ic_home, + labelResId = Res.string.menu_home_title, + route = MainTabRoute.Home, + ), + SCHEDULE( + iconResId = Res.drawable.ic_schedule, + labelResId = Res.string.menu_schedule_title, + route = MainTabRoute.Schedule, + ), + PLACE_MAP( + iconResId = Res.drawable.ic_map, + labelResId = Res.string.menu_map_title, + route = MainTabRoute.PlaceMap, + ), + NEWS( + iconResId = Res.drawable.ic_news, + labelResId = Res.string.menu_news_title, + route = MainTabRoute.News, + ), + SETTING( + iconResId = Res.drawable.ic_setting, + labelResId = Res.string.menu_setting_title, + route = MainTabRoute.Setting, + ), + ; + + companion object Defaults { + @Composable + fun find(predicate: @Composable (FestabookRoute) -> Boolean) = + entries.find { + predicate(it.route) + } + + val selectedColor + @Composable + @ReadOnlyComposable + get() = FestabookColor.black + + val unselectedColor + @Composable + @ReadOnlyComposable + get() = FestabookColor.gray400 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt new file mode 100644 index 00000000..9dc83e96 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt @@ -0,0 +1,81 @@ +package com.daedan.festabook.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions + +class FestabookNavigator( + val navController: NavHostController, + val startRoute: FestabookRoute = FestabookRoute.Splash, +) { + private val currentDestination + @Composable + get() = + navController + .visibleEntries + .collectAsState() + .value + .lastOrNull { it.destination.route != null } + ?.destination + val currentTab + @Composable + get() = + FestabookMainTab.find { + currentDestination?.hasRoute(it::class) ?: false + } + + val shouldShowBottomBar + @Composable + get() = + FestabookMainTab.find { + currentDestination?.hasRoute(it::class) ?: false + } != null + + fun navigateToMainTab(tab: FestabookMainTab) { + navController.navigate( + tab.route, + navOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + }, + ) + } + + fun navigate( + route: FestabookRoute, + navOptions: NavOptions? = null, + ) { + navController.navigate( + route, + navOptions, + ) + } + + fun popBackStack() { + navController.popBackStack() + } + + fun popBackStack( + route: FestabookRoute, + inclusive: Boolean = false, + ) { + navController.popBackStack(route, inclusive) + } +} + +@Composable +fun rememberFestabookNavigator(startRoute: FestabookRoute = FestabookRoute.Splash): FestabookNavigator { + val navController = rememberNavController() + return remember { + FestabookNavigator(navController = navController, startRoute = startRoute) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt new file mode 100644 index 00000000..1ced5839 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook.presentation.main + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import kotlinx.serialization.Serializable + +@Serializable +sealed interface FestabookRoute { + @Serializable + data object Splash : FestabookRoute + + @Serializable + data class PlaceDetail( + val placeUiModel: PlaceUiModel? = null, + val placeDetailUiModel: PlaceDetailUiModel? = null, + ) : FestabookRoute + + @Serializable + data object Explore : FestabookRoute + + @Serializable + data object Main : FestabookRoute +} + +@Serializable +sealed interface MainTabRoute : FestabookRoute { + @Serializable + data object Home : MainTabRoute + + @Serializable + data object Schedule : MainTabRoute + + @Serializable + data object PlaceMap : MainTabRoute + + @Serializable + data object News : MainTabRoute + + @Serializable + data object Setting : MainTabRoute +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.kt new file mode 100644 index 00000000..c94a30ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.presentation.platform + +import androidx.compose.runtime.Composable +import com.daedan.festabook.di.FestabookAppGraph + +@Composable +expect fun rememberAppGraph(): FestabookAppGraph diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/splash/navigation/SplashNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/splash/navigation/SplashNavigation.kt new file mode 100644 index 00000000..286f421f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/splash/navigation/SplashNavigation.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.splash.navigation + +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.splash.component.SplashScreen +import com.daedan.festabook.presentation.splash.platform.AppVersionManager +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory + +fun NavGraphBuilder.splashNavGraph( + appVersionManager: AppVersionManager, + onNavigateToExplore: () -> Unit, + onNavigateToMain: (Long) -> Unit, + onFinishApp: () -> Unit, +) { + composable { + val viewModelFactory = LocalMetroViewModelFactory.current + SplashScreen( + viewModel = viewModel(factory = viewModelFactory), + appVersionManager = appVersionManager, + onNavigateToExplore = onNavigateToExplore, + onNavigateToMain = onNavigateToMain, + onFinishApp = onFinishApp, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/theme/FestabookTheme.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/theme/FestabookTheme.kt index 69353cf5..c718432d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/theme/FestabookTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/theme/FestabookTheme.kt @@ -4,6 +4,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.compose.LocalPlatformContext +import coil3.compose.setSingletonImageLoaderFactory +import coil3.request.crossfade +import com.daedan.festabook.presentation.platform.rememberAppGraph +import com.skydoves.landscapist.coil3.LocalCoilImageLoader +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory private val LightColorScheme @Composable @@ -18,12 +26,23 @@ fun FestabookTheme(content: @Composable () -> Unit) { val shapes = FestabookShapes() val color = FestabookColorPalette() val typography = FestabookTypographies + val appGraph = rememberAppGraph() + val platformContext = LocalPlatformContext.current + + setSingletonImageLoaderFactory { context -> + ImageLoader + .Builder(context) + .crossfade(true) + .build() + } CompositionLocalProvider( LocalSpacing provides spacing, LocalShapes provides shapes, LocalColor provides color, LocalTypography provides typography, + LocalMetroViewModelFactory provides appGraph.metroViewModelFactory, + LocalCoilImageLoader provides SingletonImageLoader.get(platformContext), ) { MaterialTheme( colorScheme = LightColorScheme, diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt index baa1b4e2..fd5aa565 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt @@ -1,45 +1,16 @@ package com.daedan.festabook -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.window.ComposeUIViewController -import androidx.lifecycle.viewmodel.compose.viewModel -import coil3.SingletonImageLoader -import coil3.compose.LocalPlatformContext -import com.daedan.festabook.di.IosAppGraph -import com.daedan.festabook.presentation.platform.rememberAppVersionManager -import com.daedan.festabook.presentation.splash.component.SplashScreen +import com.daedan.festabook.presentation.FestabookScreen import com.daedan.festabook.presentation.theme.FestabookTheme -import com.skydoves.landscapist.coil3.LocalCoilImageLoader -import dev.zacsweers.metro.createGraph -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory import platform.posix.exit -private val festabookAppGraph = createGraph() -private val metroVmf = festabookAppGraph.metroViewModelFactory - @Suppress("ktlint:standard:function-naming") fun MainViewController() = ComposeUIViewController { FestabookTheme { - CompositionLocalProvider( - LocalMetroViewModelFactory provides metroVmf, - LocalCoilImageLoader provides SingletonImageLoader.get(LocalPlatformContext.current), - ) { - Scaffold { innerPadding -> - SplashScreen( - viewModel = viewModel(factory = metroVmf), - appVersionManager = - rememberAppVersionManager( - appGraph = festabookAppGraph, - onUpdateFailure = {}, - onUpdateSuccess = {}, - ), - onNavigateToMain = {}, - onNavigateToExplore = {}, - onFinishApp = { exit(0) }, - ) - } - } + FestabookScreen( + onAppFinish = { exit(0) }, + ) } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt new file mode 100644 index 00000000..e1e3a0ab --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.presentation.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.daedan.festabook.di.FestabookAppGraph +import com.daedan.festabook.di.IosAppGraph +import dev.zacsweers.metro.createGraph + +@Composable +actual fun rememberAppGraph(): FestabookAppGraph = remember { createGraph() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a0f0267f..c20c2c18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ androidx-testExt = "1.3.0" composeMultiplatform = "1.9.3" datastorePreferences = "1.2.0" compottie = "2.0.3" +navigationCompose = "2.9.2" coil = "3.3.0" junit = "4.13.2" kotlin = "2.3.10" @@ -43,6 +44,7 @@ junit = { module = "junit:junit", version.ref = "junit" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } +compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } compose-navigationevent = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "composeNavigationEvent" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtx" } From 2165f1646587c00726cfba08288ae03a1dec8c9a Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:51:20 +0900 Subject: [PATCH 03/54] =?UTF-8?q?[Feat]=20FirebaseAnalytics,=20Crashtics?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#8?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Firebase Crashlytics 도입 * **feat: iOS 플랫폼 Firebase SDK 연동 및 초기화** * `Podfile` 및 `Podfile.lock`에 `FirebaseCrashlytics`와 `FirebaseAnalytics` 의존성을 추가했습니다. * `AppDelegate.swift`를 추가하여 앱 실행 시 `FirebaseApp.configure()`가 호출되도록 구현하고, `iOSApp.swift`에서 `UIApplicationDelegateAdaptor`를 통해 이를 연결했습니다. * `iosApp.xcodeproj`에 빌드 구성(Debug/Release)에 따라 적절한 `GoogleService-Info.plist`를 복사하는 셸 스크립트와 Crashlytics 초기화 스크립트를 추가했습니다. * **feat: Android 플랫폼 Firebase SDK 연동** * `composeApp/build.gradle.kts`에 `firebase-bom`, `firebase-crashlytics-ndk`, `firebase-analytics` 의존성을 추가했습니다. * `libs.versions.toml`에 Firebase 관련 라이브러리 및 플러그인 버전을 정의했습니다. * **feat: BuildKonfig 설정 확장** * `BUILD_FLAVOR` 및 `APP_BUNDLE_ID` 필드를 추가하여 빌드 환경에 따른 앱 식별자 관리를 자동화했습니다. * `local.properties`로부터 `APP_BUNDLE_ID` 및 `APP_BUNDLE_ID_DEV` 값을 읽어오도록 구성했습니다. * **chore: iOS 프로젝트 설정 및 보안 관리** * `.gitignore`에 환경별 `GoogleService-Info.plist` 파일을 추가하여 민감 정보 유출을 방지했습니다. * `Info.plist` 내의 위치 권한 설명(`NSLocationWhenInUseUsageDescription`)을 Xcode 프로젝트 설정(`INFOPLIST_KEY`)으로 이동하여 관리 효율성을 높였습니다. * 디버그 심볼 생성을 위해 `DEBUG_INFORMATION_FORMAT`을 `dwarf-with-dsym`으로 설정했습니다. * feat: `FirebaseCrashlytics` 추상화 및 플랫폼별 구현 추가 * **feat: `FirebaseCrashlytics` 공통 인터페이스 및 플랫폼별 구현** * `commonMain`: 예외 기록(`recordException`) 및 미전송 리포트 전송(`sendUnsentReports`) 기능을 포함하는 `expect object FirebaseCrashlytics`를 정의했습니다. * **Android**: Firebase Crashlytics SDK를 연동하여 `actual` 객체를 구현했습니다. * **iOS**: CocoaPods의 `FIRCrashlytics`를 연동하고, Kotlin `Throwable`을 `NSError`로 변환하여 스택 트레이스를 포함하도록 구현했습니다. * **refactor: `AppVersionManager.ios.kt` 내 설정 로직 개선** * 하드코딩된 `APP_BUNDLE_ID`를 제거하고 `BuildKonfig.APP_BUNDLE_ID`를 사용하도록 수정했습니다. * 빌드 버전이 `dev`인 경우 업데이트 확인 과정을 건너뛰고 즉시 성공(`false`)을 반환하도록 로직을 추가했습니다. * feat: `FirebaseAnalytics` 추상화 및 플랫폼별 구현 추가 * **feat: `FirebaseAnalytics` 공통 인터페이스 및 플랫폼별 구현** * `commonMain`: 이벤트 로그 기록(`logEvent`), 앱 인스턴스 ID 조회(`getAppInstanceId`), 세션 ID 조회(`getSessionId`) 기능을 포함하는 `expect object FirebaseAnalytics`를 정의했습니다. * **Android**: `com.google.firebase.analytics`를 사용하여 `actual` 객체를 구현했으며, `await()`을 통해 비동기 데이터를 처리하도록 구성했습니다. * **iOS**: CocoaPods의 `FIRAnalytics`를 연동하여 `actual` 객체를 구현했습니다. `getSessionId`의 경우 completion handler를 `suspendCancellableCoroutine`으로 래핑하여 비동기 처리를 구현했습니다. * chore: iOS 앱 표시 이름(CFBundleDisplayName) 설정 * **chore: 빌드 구성별 앱 표시 이름 추가** * `iosApp.xcodeproj` 프로젝트 설정에 `INFOPLIST_KEY_CFBundleDisplayName`을 추가하여 설치된 앱의 이름을 정의했습니다. * 디버그 빌드 환경에서는 `"festabook(Debug)"`, 릴리즈 빌드 환경에서는 `"festabook"`으로 표시되도록 구분하여 설정했습니다. * chore: CI 워크플로우 내 앱 번들 ID 환경 변수 추가 * **ci: `.github/workflows/cmp-ci.yml` 수정** * `local.properties` 파일 생성 단계에 `APP_BUNDLE_ID` 및 `APP_BUNDLE_ID_DEV` 설정을 추가하여 GitHub Secrets로부터 해당 값을 주입받도록 구성했습니다. * chore: Google Services 플러그인 추가 * **chore: Google Services 의존성 및 플러그인 설정** * `libs.versions.toml`에 `googleServices` 버전을 정의하고 `google-gms-services` 플러그인을 추가했습니다. * 루트 `build.gradle.kts`에 `google-gms-services` 플러그인을 정의했습니다. * `composeApp/build.gradle.kts`에 `google-gms-services` 플러그인을 적용했습니다. * chore: `composeApp/build.gradle.kts` 내 `buildFlavor` 기본값 설정 * **chore: `buildFlavor` 프로퍼티 처리 로직 개선** * `project.properties["buildkonfig.flavor"]` 값이 없을 경우(null) 기본값으로 `"dev"`를 사용하도록 수정하여 빌드 설정의 안정성을 높였습니다. * chore: `FirebaseCrashlytics.ios.kt` 내 `asNSError` 가시성 수정 * **chore: `asNSError` 확장 함수 가시성 변경** * `FirebaseCrashlytics.ios.kt` 파일에서 사용되는 `Throwable.asNSError()` 확장 함수의 가시성을 `private`으로 변경하여 외부 노출을 제한했습니다. * feat: iOS 플랫폼 `FirebaseAnalytics` 최적화 * **refactor: `logEvent` 파라미터 캐스팅 로직 최적화** * `logEvent` 메서드 내에서 `Map`의 키를 개별적으로 변환하던 로직을 `as? Map`를 사용한 직접 캐스팅으로 변경하고, 불필요한 경고를 억제하기 위해 `@Suppress("UNCHECKED_CAST")` 어노테이션을 추가했습니다. * **feat: `getSessionId` 내 에러 핸들링 로직 추가** * `sessionIDWithCompletion` 콜백에서 에러가 발생할 경우, `RuntimeException`을 통해 실패 상태를 반환하도록 예외 처리 로직을 보완했습니다. * feat: Android `FirebaseAnalytics` 파라미터 타입 처리 개선 * **feat: Android 플랫폼 내 Firebase Analytics 이벤트 파라미터 타입 처리 로직 수정** * `FirebaseAnalytics.android.kt`의 `logEvent` 메서드에서 이벤트 파라미터를 처리할 때, 모든 값을 일괄적으로 `String`으로 변환하던 방식을 개선했습니다. * `Long`, `Double`, `String` 타입에 대해 각 타입별 전용 메서드를 호출하도록 분기 처리를 추가하여 Firebase SDK에 올바른 데이터 타입이 전달되도록 수정했습니다. --- .github/workflows/cmp-ci.yml | 2 + .gitignore | 3 + build.gradle.kts | 1 + composeApp/build.gradle.kts | 21 +++ .../logging/FirebaseAnalytics.android.kt | 30 ++++ .../logging/FirebaseCrashlytics.android.kt | 15 ++ .../festabook/logging/FirebaseAnalytics.kt | 12 ++ .../festabook/logging/FirebaseCrashlytics.kt | 7 + .../logging/FirebaseAnalytics.ios.kt | 35 ++++ .../logging/FirebaseCrashlytics.ios.kt | 35 ++++ .../splash/platform/AppVersionManager.ios.kt | 6 +- gradle/libs.versions.toml | 12 +- iosApp/Podfile | 2 + iosApp/Podfile.lock | 149 +++++++++++++++++- iosApp/iosApp.xcodeproj/project.pbxproj | 57 ++++++- iosApp/iosApp/AppDelegate.swift | 14 ++ iosApp/iosApp/Info.plist | 22 ++- iosApp/iosApp/iOSApp.swift | 8 +- 18 files changed, 406 insertions(+), 25 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.ios.kt create mode 100644 iosApp/iosApp/AppDelegate.swift diff --git a/.github/workflows/cmp-ci.yml b/.github/workflows/cmp-ci.yml index 9c99ced7..9d3ceff5 100644 --- a/.github/workflows/cmp-ci.yml +++ b/.github/workflows/cmp-ci.yml @@ -46,6 +46,8 @@ jobs: echo "IMAGE_BASE_URL=${{ secrets.IMAGE_BASE_URL }}" >> local.properties echo "NAVER_MAP_CLIENT_ID=${{ secrets.NAVER_MAP_CLIENT_ID }}" >> local.properties echo "NAVER_MAP_STYLE_ID=${{ secrets.NAVER_MAP_STYLE_ID }}" >> local.properties + echo "APP_BUNDLE_ID=${{ secrets.APP_BUNDLE_ID }}" >> local.properties + echo "APP_BUNDLE_ID_DEV=${{ secrets.APP_BUNDLE_ID_DEV }}" >> local.properties - name: Load Google Service file env: diff --git a/.gitignore b/.gitignore index 513ef5e1..8b8d7dbf 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ local.properties *.keystore google-services.json GoogleService-Info.plist +GoogleService-Info-DEBUG.plist +GoogleService-Info-RELEASE.plist + /iosApp/Configuration/Debug.xcconfig /iosApp/Configuration/Release.xcconfig \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 179a1b61..017ae186 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,4 +9,5 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) alias(libs.plugins.kotlinCocoapods) apply false + alias(libs.plugins.google.gms.services) apply false } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f0d757f7..4b16cbaa 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,6 +1,7 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING import com.google.devtools.ksp.gradle.KspAATask import dev.mokkery.gradle.ApplicationRule +import org.gradle.kotlin.dsl.implementation import org.jetbrains.compose.internal.utils.getLocalProperty import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jlleitschuh.gradle.ktlint.tasks.KtLintCheckTask @@ -31,6 +32,15 @@ private val naverMapStyleId = private val naverMapClientId = getLocalProperty("NAVER_MAP_CLIENT_ID") ?: error("NAVER_MAP_CLIENT_ID가 local.properties에 없음") +private val appBundleId = + getLocalProperty("APP_BUNDLE_ID") ?: error("APP_BUNDLE_ID가 local.properties에 없음") + +private val appBundleIdDev = + getLocalProperty("APP_BUNDLE_ID_DEV") ?: error("APP_BUNDLE_ID_DEV가 local.properties에 없음") + +private val buildFlavor = + project.properties["buildkonfig.flavor"]?.toString() ?: "dev" + plugins { alias(libs.plugins.kotlinMultiplatform) @@ -45,6 +55,8 @@ plugins { alias(libs.plugins.mokkery) alias(libs.plugins.ktlint) alias(libs.plugins.kotlinCocoapods) + alias(libs.plugins.firebaseCrashlytcis) + alias(libs.plugins.google.gms.services) } kotlin { @@ -57,6 +69,8 @@ kotlin { ios.deploymentTarget = "17.0" pod("NMapsMap") + pod("FirebaseCrashlytics") + pod("FirebaseAnalytics") } androidTarget { compilerOptions { @@ -84,6 +98,9 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.appcompat) + implementation(project.dependencies.platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics.ndk) + implementation(libs.firebase.analytics) } commonMain.dependencies { implementation(libs.compose.navigationevent) @@ -124,17 +141,21 @@ buildkonfig { packageName = "com.daedan.festabook" defaultConfigs { + buildConfigField(STRING, "BUILD_FLAVOR", buildFlavor) buildConfigField(STRING, "NAVER_MAP_STYLE_ID", naverMapStyleId) buildConfigField(STRING, "NAVER_MAP_CLIENT_ID", naverMapClientId) buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) + buildConfigField(STRING, "APP_BUNDLE_ID", appBundleIdDev) } defaultConfigs("release") { + buildConfigField(STRING, "BUILD_FLAVOR", buildFlavor) buildConfigField(STRING, "NAVER_MAP_STYLE_ID", naverMapStyleId) buildConfigField(STRING, "NAVER_MAP_CLIENT_ID", naverMapClientId) buildConfigField(STRING, "FESTABOOK_URL", baseUrl) buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) + buildConfigField(STRING, "APP_BUNDLE_ID", appBundleId) } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt new file mode 100644 index 00000000..832d25bd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt @@ -0,0 +1,30 @@ +package com.daedan.festabook.logging + +import com.google.firebase.Firebase +import com.google.firebase.analytics.analytics +import com.google.firebase.analytics.logEvent +import kotlinx.coroutines.tasks.await + +actual object FirebaseAnalytics { + private val platform = Firebase.analytics + + actual fun logEvent( + name: String, + params: Map?, + ) { + platform.logEvent(name) { + params?.forEach { + when (val v = it.value) { + is Long -> param(it.key, v) + is Double -> param(it.key, v) + is String -> param(it.key, v) + else -> param(it.key, v.toString()) + } + } + } + } + + actual suspend fun getAppInstanceId(): String = platform.appInstanceId.await() + + actual suspend fun getSessionId(): Long = platform.sessionId.await() +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.android.kt new file mode 100644 index 00000000..13de49af --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.android.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.logging + +import com.google.firebase.crashlytics.FirebaseCrashlytics as PlatformFirebaseCrashlytics + +actual object FirebaseCrashlytics { + private val platform: PlatformFirebaseCrashlytics = PlatformFirebaseCrashlytics.getInstance() + + actual fun recordException(throwable: Throwable) { + platform.recordException(throwable) + } + + actual fun sendUnsentReports() { + platform.sendUnsentReports() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt new file mode 100644 index 00000000..e2fc6d08 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt @@ -0,0 +1,12 @@ +package com.daedan.festabook.logging + +expect object FirebaseAnalytics { + fun logEvent( + name: String, + params: Map? = null, + ) + + suspend fun getAppInstanceId(): String + + suspend fun getSessionId(): Long +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.kt new file mode 100644 index 00000000..b35979f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.logging + +expect object FirebaseCrashlytics { + fun recordException(throwable: Throwable) + + fun sendUnsentReports() +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt new file mode 100644 index 00000000..13ef9b36 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt @@ -0,0 +1,35 @@ +package com.daedan.festabook.logging + +import cocoapods.FirebaseAnalytics.FIRAnalytics +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine + +@OptIn(ExperimentalForeignApi::class) +actual object FirebaseAnalytics { + private val platform = FIRAnalytics + private const val KEY_UNINITIALIZED_USER_ID = "undefined" + + @Suppress("UNCHECKED_CAST") + actual fun logEvent( + name: String, + params: Map?, + ) { + platform.logEventWithName( + name, + params as? Map, + ) + } + + actual suspend fun getAppInstanceId(): String = platform.appInstanceID() ?: KEY_UNINITIALIZED_USER_ID + + actual suspend fun getSessionId(): Long = + suspendCancellableCoroutine { cont -> + platform.sessionIDWithCompletion { id, error -> + if (error != null) { + cont.resumeWith(Result.failure(RuntimeException(error.localizedDescription))) + } else { + cont.resumeWith(Result.success(id)) + } + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.ios.kt new file mode 100644 index 00000000..b9270b3d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseCrashlytics.ios.kt @@ -0,0 +1,35 @@ +package com.daedan.festabook.logging + +import cocoapods.FirebaseCrashlytics.FIRCrashlytics +import com.daedan.festabook.BuildKonfig +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSError +import platform.Foundation.NSLocalizedDescriptionKey + +@OptIn(ExperimentalForeignApi::class) +actual object FirebaseCrashlytics { + private val platform = FIRCrashlytics.crashlytics() + + actual fun recordException(throwable: Throwable) { + platform.recordError(throwable.asNSError()) + } + + actual fun sendUnsentReports() { + platform.sendUnsentReports() + } +} + +private fun Throwable.asNSError(): NSError { + val userInfo = mutableMapOf() + + message?.let { + userInfo[NSLocalizedDescriptionKey] = it + } + + userInfo["KotlinStackTrace"] = stackTraceToString() + return NSError( + domain = BuildKonfig.APP_BUNDLE_ID, + code = 0, + userInfo = userInfo, + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/splash/platform/AppVersionManager.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/splash/platform/AppVersionManager.ios.kt index 90f9114d..7827a575 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/splash/platform/AppVersionManager.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/splash/platform/AppVersionManager.ios.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.splash.platform +import com.daedan.festabook.BuildKonfig import com.daedan.festabook.domain.repository.AppVersionRepository import dev.zacsweers.metro.Inject import platform.Foundation.NSBundle @@ -15,7 +16,8 @@ actual class AppVersionManager( NSBundle.mainBundle.infoDictionary?.get("CFBundleShortVersionString") as? String actual suspend fun getIsAppUpdateAvailable(): Result { - val latestVersion = appVersionRepository.getLatestVersion(APP_BUNDLE_ID) + if (BuildKonfig.BUILD_FLAVOR == "dev") return Result.success(false) + val latestVersion = appVersionRepository.getLatestVersion(BuildKonfig.APP_BUNDLE_ID) return latestVersion.map { latest -> isNewerVersion(latest, currentAppVersion) } @@ -62,7 +64,5 @@ actual class AppVersionManager( private const val APP_STORE_URL = "itms-apps://itunes.apple.com/app/apple-store/id/$APPLE_ID" private const val APP_STORE_WEB_URL = "https://apps.apple.com/app/id/$APPLE_ID" - - private const val APP_BUNDLE_ID = "eoehdeksruf.festabook" } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c20c2c18..4b171b8b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,9 @@ landscapistCoil3 = "2.9.5" mapSdk = "3.22.1" playServicesLocation = "21.3.0" composeNavigationEvent = "1.0.1" +crashlytics = "3.0.6" +firebaseBom = "34.0.0" +googleServices = "4.4.4" [libraries] androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" } @@ -69,6 +72,11 @@ compottie = { module = "io.github.alexzhirkevich:compottie", version.ref = "comp ktlint = { module = "io.nlopez.compose.rules:ktlint", version.ref = "ktlintVersion" } metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } + + map-sdk = { module = "com.naver.maps:map-sdk", version.ref = "mapSdk" } @@ -85,4 +93,6 @@ ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" } metro = { id = "dev.zacsweers.metro", version.ref = "metro" } buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } -mokkery = { id = "dev.mokkery", version.ref = "mokkery" } \ No newline at end of file +mokkery = { id = "dev.mokkery", version.ref = "mokkery" } +firebaseCrashlytcis = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics"} +google-gms-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } \ No newline at end of file diff --git a/iosApp/Podfile b/iosApp/Podfile index 488e1f82..4def610d 100644 --- a/iosApp/Podfile +++ b/iosApp/Podfile @@ -9,5 +9,7 @@ target 'iosApp' do # Pods for iosApp pod 'NMapsMap' + pod 'FirebaseCrashlytics' + pod 'FirebaseAnalytics' end diff --git a/iosApp/Podfile.lock b/iosApp/Podfile.lock index d655974b..e16180ad 100644 --- a/iosApp/Podfile.lock +++ b/iosApp/Podfile.lock @@ -1,20 +1,167 @@ PODS: + - FirebaseAnalytics (12.7.0): + - FirebaseAnalytics/Default (= 12.7.0) + - FirebaseCore (~> 12.7.0) + - FirebaseInstallations (~> 12.7.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseAnalytics/Default (12.7.0): + - FirebaseCore (~> 12.7.0) + - FirebaseInstallations (~> 12.7.0) + - GoogleAppMeasurement/Default (= 12.7.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseCore (12.7.0): + - FirebaseCoreInternal (~> 12.7.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (12.7.0): + - FirebaseCore (~> 12.7.0) + - FirebaseCoreInternal (12.7.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseCrashlytics (12.7.0): + - FirebaseCore (~> 12.7.0) + - FirebaseInstallations (~> 12.7.0) + - FirebaseRemoteConfigInterop (~> 12.7.0) + - FirebaseSessions (~> 12.7.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - FirebaseInstallations (12.7.0): + - FirebaseCore (~> 12.7.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseRemoteConfigInterop (12.7.0) + - FirebaseSessions (12.7.0): + - FirebaseCore (~> 12.7.0) + - FirebaseCoreExtension (~> 12.7.0) + - FirebaseInstallations (~> 12.7.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - PromisesSwift (~> 2.1) + - GoogleAdsOnDeviceConversion (3.2.0): + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Core (12.7.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Default (12.7.0): + - GoogleAdsOnDeviceConversion (~> 3.2.0) + - GoogleAppMeasurement/Core (= 12.7.0) + - GoogleAppMeasurement/IdentitySupport (= 12.7.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/IdentitySupport (12.7.0): + - GoogleAppMeasurement/Core (= 12.7.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - NMapsGeometry (1.0.2) - NMapsMap (3.23.1): - NMapsGeometry + - PromisesObjC (2.4.0) + - PromisesSwift (2.4.0): + - PromisesObjC (= 2.4.0) DEPENDENCIES: + - FirebaseAnalytics + - FirebaseCrashlytics - NMapsMap SPEC REPOS: trunk: + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - FirebaseCrashlytics + - FirebaseInstallations + - FirebaseRemoteConfigInterop + - FirebaseSessions + - GoogleAdsOnDeviceConversion + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleUtilities + - nanopb - NMapsGeometry - NMapsMap + - PromisesObjC + - PromisesSwift SPEC CHECKSUMS: + FirebaseAnalytics: e423129866107aeacf6010a280b9c9829f9ea50d + FirebaseCore: c7b57863ce0859281a66d16ca36d665c45d332b5 + FirebaseCoreExtension: 731a5744970bcd73ad95ab2a2b8a1f46353e86dd + FirebaseCoreInternal: 571a2dd8c975410966199623351db3a3265c874d + FirebaseCrashlytics: 9ce9ce4bb179e31ab91a937d0a73d302a9752d1b + FirebaseInstallations: 6d05424a046b68ca146b4de4376f05b4e9262fc3 + FirebaseRemoteConfigInterop: 0bae7d21510b3a0a79af727961c4973aac585add + FirebaseSessions: bc59e7d0ba90f96f40325fd1defbdec86ba26f20 + GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f + GoogleAppMeasurement: 8f59e08efecfa1bcd57f0a56fab07d079366268c + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NMapsGeometry: 4e02554fa9880ef02ed96b075dc84355d6352479 NMapsMap: b4272a60dcbb8bccb206c632a133cae27a160031 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 -PODFILE CHECKSUM: ff32aad0fb2ca36057f4e02d63988ef75ac8eeb7 +PODFILE CHECKSUM: 6a5bb667335a398cc3b70f78c63e6562bba25452 COCOAPODS: 1.16.2 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 631fb2dd..243229c1 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -38,8 +38,6 @@ }; 8E6C8D378651BF8144E66D20 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Configuration; sourceTree = ""; }; @@ -71,7 +69,6 @@ B8BCC787F9096E9D0A4FA367 /* Pods-iosApp.debug.xcconfig */, 715130B6FFE2A7B0E186B7B0 /* Pods-iosApp.release.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -107,6 +104,8 @@ 3ED20EC5BFA21FC0E7D100C7 /* Frameworks */, A504BDED7EC474099DAD9F64 /* Resources */, 20E523B2847A296CA11BAE04 /* [CP] Embed Pods Frameworks */, + 67E4A4402F6D7E2800C3FBD6 /* Copy Google Service plist */, + 67E4A4352F6D707D00C3FBD6 /* Inititialize Firebase Crashlytics */, ); buildRules = ( ); @@ -173,15 +172,60 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 67E4A4352F6D707D00C3FBD6 /* Inititialize Firebase Crashlytics */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\n", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}\n", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\n", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist\n", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)\n", + ); + name = "Inititialize Firebase Crashlytics"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; + }; + 67E4A4402F6D7E2800C3FBD6 /* Copy Google Service plist */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Google Service plist"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nDEBUG_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-DEBUG.plist\"\nRELEASE_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-RELEASE.plist\"\n\n# 2. 앱이 실제로 참조하게 될 목적지 경로\nDESTINATION_PATH=\"${SRCROOT}/iosApp/GoogleService-Info.plist\"\n\n# 3. 빌드 구성(Configuration)에 따라 복사 실행\nif [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n echo \"복사 중: Debug용 Firebase 설정...\"\n cp \"${DEBUG_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nelse\n echo \"복사 중: Release용 Firebase 설정...\"\n cp \"${RELEASE_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nfi\n"; + }; C08EE85BEF61373E7C695D8C /* Compile Kotlin Framework */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -245,11 +289,14 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = "${TEAM_ID}"; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -260,6 +307,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = eoehdeksruf.festabook.debug; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -338,6 +386,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = festabook; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -348,6 +398,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = eoehdeksruf.festabook; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift new file mode 100644 index 00000000..0a05496a --- /dev/null +++ b/iosApp/iosApp/AppDelegate.swift @@ -0,0 +1,14 @@ +import FirebaseCore +import ComposeApp +import NMapsMap + +class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil + ) -> Bool { + FirebaseApp.configure() + NMFAuthManager.shared().ncpKeyId = NativeBuildKonfig.shared.NAVER_MAP_CLIENT_ID + return true + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index c17b2a98..807e4cd2 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,14 +1,12 @@ - + + - - CADisableMinimumFrameDurationOnPhone - - NSLocationWhenInUseUsageDescription - 현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다. - LSApplicationQueriesSchemes - - itms-apps - - + + CADisableMinimumFrameDurationOnPhone + + LSApplicationQueriesSchemes + + itms-apps + + diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 3e795af8..d2d4ae69 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,12 +1,10 @@ import SwiftUI -import ComposeApp -import NMapsMap +import FirebaseCore @main struct iOSApp: App { - init() { - NMFAuthManager.shared().ncpKeyId = NativeBuildKonfig.shared.NAVER_MAP_CLIENT_ID - } + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + var body: some Scene { WindowGroup { ContentView() From 7db4f8a019dbfd1fe002df0e872e5ee948475246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sat, 28 Mar 2026 21:03:42 +0900 Subject: [PATCH 04/54] =?UTF-8?q?[Feat]=20=EC=84=A4=EC=A0=95=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20View=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [feat]: FestabookTopAppBar에 상태바 패딩 추가 * [Build]: 앱 버전 관리 중앙화 및 자동화 * [Build]: gradle.properties에 APP_VERSION_NAME, APP_VERSION_CODE 추가 * [Build]: Android 빌드 설정(versionCode, versionName)에 Gradle 프로퍼티 적용 * [Build]: iOS Info.plist의 버전을 자동 업데이트하는 Gradle 태스크 추가 * [Build]: BuildConfig에 APP_VERSION_NAME 필드 추가 * [Chore]: ic_arrow_forward_right.xml 리소스 수정 * 벡터 드로어블의 색상 값을 하드코딩된 hex 값(#FFFFFFFF)으로 변경 * XML 파일 포맷 및 인덴트 정리 * [Build]: iosApp Info.plist에 CFBundleName 추가 * [Build]: Android 알림 권한 추가 * [Feat]: ContextFactory 추가 * common: 플랫폼별 컨텍스트 접근을 위한 expect 클래스 정의 * android: AssistedInject를 이용한 ContextFactory 구현 추가 * ios: NSBundle 및 UIApplication을 이용한 ContextFactory 구현 추가 * [Feat]: NotificationPermissionManager 추가 * [Feat]: commonMain에 NotificationPermissionManager expect 클래스 정의 * [Feat]: androidMain에 TIRAMISU 대응 및 Rationale 다이얼로그를 포함한 NotificationPermissionManager actual 구현 추가 * [Feat]: iosMain에 NotificationPermissionManager actual 구현 추가 (스텁) * [Feat]: 알림 권한 처리를 위한 플랫폼별 Launcher 및 Manager 추가 * commonMain에 `rememberNotificationPermissionManager` 및 `expect` 함수 정의 * androidMain에 `ActivityResultLauncher`를 이용한 권한 요청 구현 * iosMain에 `UNUserNotificationCenter`를 이용한 권한 요청 구현 * [Feat]: SettingViewModel 추가 * [Feat] DI 그래프 내 NotificationPermissionManager 및 ContextFactory 추가 * [Feat]: FestabookAppGraph 및 AndroidAppGraph에 팩토리 속성 추가 * [Refactor]: FestabookApp 내 그래프 변수명 변경 (festabookAppGraph -> androidAppGraph) * [Feat]: 설정(Setting) 화면 및 홈(Home) ViewModel 추가 * [Feat]: `SettingScreen` 및 `SettingRoute` 구현 (알림 설정, 앱 정보, 서비스 약관 링크 포함) * [Feat]: `HomeViewModel` 추가 및 `FestivalUiState`, `LineupUiState`를 통한 상태 관리 구현 * [Feat]: `LineupItemUiModel` 등 도메인 모델의 UI 변환 로직 추가 * [Refactor]: Android/iOS `MainActivity` 및 `MainViewController`에서 `SettingRoute`를 초기 화면으로 설정 및 DI 의존성 주입 수정 * [Build]: 설정 화면 네비게이션 구성을 위한 `SettingNavigation` 파일 추가 (주석 처리) * [Feat]: iOS ContextFactory 의존성 주입 적용 * feat: ContextFactory에 @Inject 어노테이션 추가 * feat: IosAppGraph 인터페이스에 contextFactory 멤버 추가 * refactor: MainViewController에서 ContextFactory를 직접 생성하는 대신 주입된 인스턴스를 사용하도록 수정 * [Refactor]: ContextFactory 메서드 네이밍 수정 및 로직 개선 * [Refactor]: `ContextFactory`의 메서드명을 `get-`에서 `create-` 형식으로 변경하고 Android 측 구현의 타입 안정성 개선 * [Chore]: `NotificationPermissionManager` 및 `RememberPermissionLauncher`에서 개선된 `ContextFactory` 프로퍼티를 사용하도록 수정 * [Style]: `SettingScreen` 내 불필요한 `Unit` 반환 코드 정리 * [Refactor]: ContextFactory 메서드 네이밍 수정 및 로직 개선 * [Refactor]: `ContextFactory`의 메서드명을 `get-`에서 `create-` 형식으로 변경하고 Android 측 구현의 타입 안정성 개선 * [Chore]: `NotificationPermissionManager` 및 `RememberPermissionLauncher`에서 개선된 `ContextFactory` 프로퍼티를 사용하도록 수정 * [Style]: `SettingScreen` 내 불필요한 `Unit` 반환 코드 정리 * [Refactor]: 플랫폼별 소스 파일 이름에 .android 및 .ios 접미사 추가 * [Refactor]: 알림 허용 여부 저장 로직 개선 * [Domain]: FestivalNotificationRepository 내 메서드 명칭 변경(set -> save) 및 Result 타입 적용 * [Data]: FestivalNotificationRepositoryImpl 내 예외 처리 및 Result 반환 로직 추가 * [Presentation]: SettingViewModel에서 알림 설정 저장 결과에 따른 UI 업데이트 및 에러 처리 로직 통합 * [Refactor]: 불필요한 Greeting 및 Platform 코드 삭제 * [Build]: iosX64 타겟 추가 * [Refactor] ContextFactory 제거 * [Refactor]: 사용되지 않는 `ContextFactory` 클래스 및 플랫폼별(android, ios) 구현체 삭제 * [Refactor]: Dependency Graph(`AndroidAppGraph`, `IosAppGraph`)에서 `ContextFactory` 관련 코드 제거 * [Feat]: NotificationPermissionManager expect/actual 구현 추가 * [Refactor]: rememberNotificationPermissionManager를 플랫폼별 actual 함수로 분리 * [Chore]: ContextFactory 의존성 제거 및 shouldShowRationale 인터페이스 수정 * [Feat]: NotificationPermissionManager 리팩토링 및 권한 상태 관리 개선 * [Common]: `NotificationPermissionManager`에서 UI 로직(다이얼로그)을 제거하고 `checkPermission`, `requestPermission`으로 기능 분리 * [Common]: 권한 상태를 표현하는 `PermissionState` 도입에 따른 인터페이스 수정 * [Android]: `ContextFactory` 대신 `Context`를 직접 주입받도록 수정하고, 기존의 `AlertDialog` 로직 제거 * [iOS]: `suspendCancellableCoroutine`을 사용하여 비동기 권한 체크(`checkPermission`) 구현 및 UI 로직 제거 * [Feat]: RememberPermissionLauncher 추가 * [Feat]: 공통 모듈에 권한 요청 및 Rationale 확인을 위한 `expect` 함수 정의 * [Refactor]: `shouldShowRationale`의 매개변수 타입을 `ContextFactory`에서 `Any?`로 변경하여 의존성 제거 * [Android]: `activity`를 `ComponentActivity`로 캐스팅하여 `shouldShowRequestPermissionRationale` 호출하도록 수정 * [Refactor]: Platform 관련 boilerplate 코드 삭제 * androidMain 및 iosMain의 Platform 구현부 삭제 (Platform.android.kt, Platform.ios.kt) * [Feat]: 알림 권한 처리 로직 개선 및 다이얼로그 추가 * [Feat]: `PermissionState` enum 추가 및 알림 권한 상태 체크 로직 구현 * [Feat]: `NotificationPermissionDialog` 컴포넌트 추가 * [Refactor]: `SettingScreen`에서 권한 상태에 따른 분기 처리 로직 수정 * [Refactor]: `NotificationPermissionManager`의 불필요한 `contextFactory` 의존성 제거 및 호출 방식 변경 * [Style]: SettingViewModel 코드 포맷 수정 * [Refactor]: NotificationPermissionDialog 확인 및 취소 콜백 분리 * NotificationPermissionDialog에 `onConfirm` 및 `onDismiss` 콜백 파라미터 추가 * AlertDialog의 확인/취소 버튼이 각각의 콜백을 호출하도록 로직 수정 * `cancelText` 변수명을 `dismissText`로 변경 * [Fix]: Android 알림 권한 로직 수정 및 다이얼로그 동작 개선 * [Fix]: Android 13(TIRAMISU) 미만 버전에서 알림 권한을 항상 허용 상태로 반환하도록 수정 * [Refactor]: `NotificationPermissionManager`에서 사용하지 않는 콜백 파라미터(`onPermissionGranted`, `onPermissionDenied`) 제거 * [Feat]: `SettingScreen`에서 권한 거부 시 다이얼로그를 닫을 수 있는 `onDismiss` 로직 추가 및 권한 상태 처리 세분화 * [Refactor]: PermissionState에서 NEED_REQUEST 제거 및 알림 권한 로직 수정 * PermissionState enum에서 `NEED_REQUEST`를 제거하고 관련 로직을 `DENIED`로 통합 * iOS `NotificationPermissionManager`에서 사용되지 않는 콜백(`onPermissionGranted`, `onPermissionDenied`) 및 불필요한 상태 체크 제거 * Android `NotificationPermissionManager`에서 기본 상태를 `DENIED`로 변경 * `SettingScreen`에서 `NEED_REQUEST` 처리 로직을 `DENIED` 케이스로 이동 * [Refactor]: NotificationPermissionManager 관련 파일 패키지 위치 변경 * presentation.platform 패키지에 위치하던 알림 권한 관련 클래스들을 presentation.setting.component.platform 패키지로 이동 * 파일 이동에 따른 MainActivity, MainViewController 및 관련 파일들의 import 경로 수정 * [Feat]: 권한 거부 시 설정 이동 스낵바 구현 및 플랫폼별 연동 * [Feat]: 플랫폼별 앱 설정 화면 이동을 위한 `rememberOpenAppSettings` 추가 (Android, iOS) * [Feat]: `SnackbarManager`에 권한 거부 알림 및 설정 이동 액션 기능 추가 * [Refactor]: `MainActivity` 및 `MainViewController`에 스낵바 호스트 설정 및 권한 거부 이벤트 연결 * [Chore]: `SettingScreen` 내 불필요한 로그 삭제 * [Feat]: 알림 권한 다이얼로그 UI 수정 * NotificationPermissionDialog 취소 버튼 제거 및 스타일 적용 * NotificationPermissionDialog 미리보기(Preview) 추가 * [Feat]: 알림 권한 허용 시 알림 ID 저장 로직 추가 * [Refactor]: FestivalNotificationRepositoryImpl 예외 메시지 추가 * [Refactor]: 알림 권한 허용 시 알림 ID 저장 로직 이동 * SettingScreen에서 직접 호출하던 saveNotificationId 로직 제거 * Android/iOS 플랫폼별 onPermissionGrant 콜백 내에서 SettingViewModel을 통해 알림 ID를 저장하도록 변경 * refactor: 개발 환경용 BuildKonfig 필드를 commonConfigs로 이동 * `composeApp/build.gradle.kts`: `targetConfigs` 섹션에 정의되어 있던 개발용 서버 URL(`FESTABOOK_URL`, `FESTABOOK_IMAGE_URL`) 및 앱 번들 ID(`APP_BUNDLE_ID`) 설정을 `commonConfigs`로 이동하여 플랫폼 공통으로 관리하도록 수정했습니다. * refactor: Metro 라이브러리 업데이트에 따른 뷰모델 주입 방식 개선 및 미사용 코드 정리 * **refactor: 뷰모델 주입 방식 변경 및 `CompositionLocalProvider` 제거** * `MainActivity`(Android) 및 `MainViewController`(iOS)에서 `LocalMetroViewModelFactory`를 통해 `metroVmf`를 명시적으로 공급하던 `CompositionLocalProvider` 로직을 제거했습니다. * `FestabookScreen`에서 `androidx.lifecycle.viewmodel.compose.viewModel` 대신 `dev.zacsweers.metrox.viewmodel.metroViewModel`을 사용하도록 수정하여 의존성 주입 방식을 단순화했습니다. * 더 이상 사용되지 않는 `metroVmf` 변수와 관련 임포트를 제거했습니다. * **refactor: AndroidAppGraph 접근 프로퍼티 이름 수정** * `AndroidAppGraph.kt`에서 `Context.appGraph` 확장 프로퍼티가 참조하는 필드명을 `festabookAppGraph`에서 `androidAppGraph`로 변경했습니다. * **style: iOS `MainViewController` 코드 포맷팅 정리** * 변수 선언부 및 클로저 내 들여쓰기를 정리하여 가독성을 개선했습니다. * refactor: Android NotificationPermissionManager 버전 분기 로직 개선 및 가독성 향상 * **refactor: API 레벨 체크 로직 공통화** * API 33(Tiramisu) 이상 여부를 확인하는 `runIfAtLeastTiramisu` 헬퍼 함수를 추가하여 조건부 실행 로직을 단순화했습니다. * `checkPermission` 및 `requestPermission` 메서드에서 위 헬퍼 함수를 사용하도록 구조를 개선했습니다. * **chore: 불필요한 코드 정리** * 로직 개선에 따라 사용하지 않게 된 `androidx.annotation.RequiresApi` 임포트 및 어노테이션을 제거했습니다. * fix: iOS `rememberPermissionLauncher` 내 콜백의 메인 스레드 실행 보장 및 안정성 개선 * `UNUserNotificationCenter.requestAuthorizationWithOptions`의 결과 콜백을 `Dispatchers.Main`에서 실행하도록 수정하여 UI 업데이트 안정성을 확보했습니다. * `remember`와 `rememberUpdatedState`를 사용하여 권한 요청 람다를 메모이제이션하고, 최신 상태의 `onResult` 콜발을 참조하도록 개선했습니다. * fix: iOS Info.plist 파일 구조 및 포맷 수정 * `Info.plist` 내 잘못 구분되어 있던 `` 태그를 하나로 병합하여 파일 구조를 올바르게 수정했습니다. * `CFBundleShortVersionString`, `CFBundleVersion` 등 주요 키의 들여쓰기를 수정하고 파일 전반의 공백 문자를 정리했습니다. * refactor: composeApp build.gradle.kts 내 중복된 buildConfigField 제거 * `build.gradle.kts`: 중복으로 정의되어 있던 `FESTABOOK_URL` 및 `FESTABOOK_IMAGE_URL` 빌드 설정 필드를 제거했습니다. * fix: SettingViewModel 내 알림 설정 성공 이벤트 발생 시점 수정 * `saveFestivalNotification` 호출 전 즉시 발생하던 `_success` 이벤트를 API 요청이 성공한 시점(`onSuccess`)에 발생하도록 수정하여 처리 결과와의 일관성을 확보했습니다. * chore: iOS 프로젝트 설정 및 Info.plist 포맷 수정 * `Info.plist`: `CFBundleShortVersionString` 및 `CFBundleVersion` 항목의 들여쓰기를 수정했습니다. * `project.pbxproj`: `Configuration` 그룹에 `exceptions` 필드를 추가하고, `Embed Pods Frameworks` 빌드 단계에서 불필요한 빈 경로 필드(`inputPaths`, `outputPaths`)를 제거했습니다. --- composeApp/build.gradle.kts | 71 +++- .../src/androidMain/AndroidManifest.xml | 2 + .../com/daedan/festabook/FestabookApp.kt | 2 +- .../com/daedan/festabook/MainActivity.kt | 49 ++- .../com/daedan/festabook/Platform.android.kt | 9 - .../daedan/festabook/di/AndroidAppGraph.kt | 2 +- .../NotificationPermissionManager.android.kt | 53 +++ .../platform/RememberAppGraph.android.kt | 4 +- ...erNotificationPermissionManager.android.kt | 29 ++ .../RememberOpenAppSettings.android.kt | 22 + .../RememberPermissionLauncher.android.kt | 25 ++ .../drawable/ic_arrow_forward_right.xml | 18 +- .../kotlin/com/daedan/festabook/Greeting.kt | 7 - .../kotlin/com/daedan/festabook/Platform.kt | 7 - .../FestivalNotificationRepositoryImpl.kt | 17 +- .../daedan/festabook/di/FestabookAppGraph.kt | 3 + .../FestivalNotificationRepository.kt | 2 +- .../festabook/presentation/FestabookScreen.kt | 4 +- .../NotificationPermissionManager.kt | 9 + .../festabook/presentation/PermissionState.kt | 7 + .../common/component/FestabookTopAppBar.kt | 7 +- .../presentation/common/component/SnackBar.kt | 32 +- .../presentation/home/FestivalUiState.kt | 15 + .../presentation/home/HomeViewModel.kt | 71 ++++ .../home/LineUpItemOfDayUiModel.kt | 37 ++ .../presentation/home/LineupItemUiModel.kt | 19 + .../presentation/home/LineupUiState.kt | 13 + .../presentation/setting/SettingViewModel.kt | 116 ++++++ .../component/NotificationPermissionDialog.kt | 45 ++ .../setting/component/SettingScreen.kt | 389 ++++++++++++++++++ .../RememberNotificationPermissionManager.kt | 11 + .../platform/RememberOpenAppSettings.kt | 6 + .../platform/RememberPermissionLauncher.kt | 11 + .../setting/navigation/SettingNavigation.kt | 29 ++ .../daedan/festabook/MainViewController.kt | 50 ++- .../com/daedan/festabook/Platform.ios.kt | 10 - .../NotificationPermissionManager.ios.kt | 45 ++ ...memberNotificationPermissionManager.ios.kt | 22 + .../platform/RememberOpenAppSettings.ios.kt | 16 + .../RememberPermissionLauncher.ios.kt | 40 ++ gradle.properties | 6 +- iosApp/iosApp.xcodeproj/project.pbxproj | 4 - iosApp/iosApp/Info.plist | 32 +- 43 files changed, 1282 insertions(+), 86 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.android.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/Greeting.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/PermissionState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineUpItemOfDayUiModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupItemUiModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/NotificationPermissionDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt delete mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 4b16cbaa..3f7fa296 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,7 +1,6 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING import com.google.devtools.ksp.gradle.KspAATask import dev.mokkery.gradle.ApplicationRule -import org.gradle.kotlin.dsl.implementation import org.jetbrains.compose.internal.utils.getLocalProperty import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jlleitschuh.gradle.ktlint.tasks.KtLintCheckTask @@ -32,6 +31,11 @@ private val naverMapStyleId = private val naverMapClientId = getLocalProperty("NAVER_MAP_CLIENT_ID") ?: error("NAVER_MAP_CLIENT_ID가 local.properties에 없음") +private val appVersionName = providers.gradleProperty("APP_VERSION_NAME").orNull + ?: error("APP_VERSION_NAME가 gradle.properties에 없음") +private val appVersionCode = providers.gradleProperty("APP_VERSION_CODE").orNull + ?: error("APP_VERSION_CODE가 gradle.properties에 없음") + private val appBundleId = getLocalProperty("APP_BUNDLE_ID") ?: error("APP_BUNDLE_ID가 local.properties에 없음") @@ -79,6 +83,7 @@ kotlin { } listOf( + iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> @@ -144,11 +149,11 @@ buildkonfig { buildConfigField(STRING, "BUILD_FLAVOR", buildFlavor) buildConfigField(STRING, "NAVER_MAP_STYLE_ID", naverMapStyleId) buildConfigField(STRING, "NAVER_MAP_CLIENT_ID", naverMapClientId) + buildConfigField(STRING, "APP_VERSION_NAME", appVersionName) buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) buildConfigField(STRING, "APP_BUNDLE_ID", appBundleIdDev) } - defaultConfigs("release") { buildConfigField(STRING, "BUILD_FLAVOR", buildFlavor) buildConfigField(STRING, "NAVER_MAP_STYLE_ID", naverMapStyleId) @@ -157,6 +162,27 @@ buildkonfig { buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) buildConfigField(STRING, "APP_BUNDLE_ID", appBundleId) } + targetConfigs { + // android용 입니다. + create("debug") { + buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) + buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) + } + create("release") { + buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) + buildConfigField(STRING, "FESTABOOK_URL", baseUrl) + } + // ios용 입니다. + create("Debug") { + buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) + buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) + } + create("Release") { + buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) + buildConfigField(STRING, "FESTABOOK_URL", baseUrl) + } + } + } android { @@ -185,8 +211,8 @@ android { libs.versions.android.targetSdk .get() .toInt() - versionCode = 1 - versionName = "1.0" + versionCode = appVersionCode.toInt() + versionName = appVersionName } packaging { resources { @@ -244,3 +270,40 @@ mokkery { ktorfit { compilerPluginVersion.set("2.3.3") } + +val updateIosVersion by tasks.registering { + + val plistFile = rootProject.layout.projectDirectory.file("iosApp/iosApp/Info.plist") + val versionName = providers.gradleProperty("APP_VERSION_NAME") + val versionCode = providers.gradleProperty("APP_VERSION_CODE") + + inputs.file(plistFile) + inputs.property("versionName", versionName) + inputs.property("versionCode", versionCode) + + doLast { + val file = plistFile.asFile + + var text = file.readText() + + text = text.replace( + Regex("CFBundleShortVersionString\\s*.*"), + "CFBundleShortVersionString\n\t\t${versionName.get()}" + ) + + text = text.replace( + Regex("CFBundleVersion\\s*.*"), + "CFBundleVersion\n\t\t${versionCode.get()}" + ) + + file.writeText(text) + } +} + +tasks.withType().configureEach { + dependsOn(updateIosVersion) +} + +tasks.named("build") { + dependsOn(updateIosVersion) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index dcbe1dbc..13e5f823 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -2,6 +2,8 @@ + + ().create(this) } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt index d1d8b863..7e920f8b 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt @@ -4,14 +4,25 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import com.daedan.festabook.presentation.FestabookScreen +import androidx.compose.runtime.remember +import com.daedan.festabook.presentation.common.component.FestabookSnackbar +import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.component.SettingRoute +import com.daedan.festabook.presentation.setting.component.platform.rememberNotificationPermissionManager +import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings import com.daedan.festabook.presentation.theme.FestabookTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel import org.jetbrains.compose.ui.tooling.preview.Preview class MainActivity : ComponentActivity() { - private val metroVmf by lazy { - (application as FestabookApp).festabookAppGraph.metroViewModelFactory + private val androidAppGraph by lazy { (application as FestabookApp).androidAppGraph } + private val notificationPermissionManagerFactory by lazy { + androidAppGraph.notificationPermissionManagerFactory } override fun onCreate(savedInstanceState: Bundle?) { @@ -20,9 +31,35 @@ class MainActivity : ComponentActivity() { setContent { FestabookTheme { - FestabookScreen( - onAppFinish = ::finish, - ) +// FestabookScreen( +// onAppFinish = ::finish, +// ) + val snackbarHostState = remember { SnackbarHostState() } + val snackbarManager = rememberAppSnackbarManager(snackbarHostState) + val openAppSettings = rememberOpenAppSettings() + Scaffold(snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { data -> + FestabookSnackbar(data) + } + }) { innerPadding -> + val viewModel = metroViewModel() + SettingRoute( + notificationPermissionManager = + rememberNotificationPermissionManager( + notificationPermissionManagerFactory = notificationPermissionManagerFactory, + onPermissionGrant = { + viewModel.saveNotificationId() + }, + onPermissionDeny = { + snackbarManager.showPermissionDeniedSnackbar( + openAppSettings, + ) + }, + ), + onShowSnackBar = {}, + onShowErrorSnackBar = {}, + ) + } } } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.android.kt deleted file mode 100644 index 3630d8dd..00000000 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.daedan.festabook - -import android.os.Build - -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" -} - -actual fun getPlatform(): Platform = AndroidPlatform() diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt index 96f1ba00..a6441a82 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt @@ -33,4 +33,4 @@ interface AndroidAppGraph : FestabookAppGraph { fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } -val Context.appGraph get() = (applicationContext as FestabookApp).festabookAppGraph +val Context.androidAppGraph get() = (applicationContext as FestabookApp).androidAppGraph diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.android.kt new file mode 100644 index 00000000..681b7ae2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.android.kt @@ -0,0 +1,53 @@ +package com.daedan.festabook.presentation + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject + +@AssistedInject +actual class NotificationPermissionManager( + @Assisted private val context: Context, + @Assisted private val launchPermission: (String) -> Unit, + @Assisted private val shouldShowRationale: (String) -> Boolean, +) { + @AssistedFactory + actual interface Factory { + fun create( + context: Context, + launchPermission: (String) -> Unit, + shouldShowRationale: (String) -> Boolean, + ): NotificationPermissionManager + } + + actual suspend fun checkPermission(): PermissionState { + runIfAtLeastTiramisu { + val permission = + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + val rationale = shouldShowRationale(Manifest.permission.POST_NOTIFICATIONS) + + return when { + permission == PackageManager.PERMISSION_GRANTED -> PermissionState.GRANTED + rationale -> PermissionState.NEED_RATIONALE + else -> PermissionState.DENIED + } + } + return PermissionState.GRANTED + } + + actual fun requestPermission() { + runIfAtLeastTiramisu { + launchPermission(Manifest.permission.POST_NOTIFICATIONS) + } + } + + private inline fun runIfAtLeastTiramisu(action: () -> Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + action() + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt index f07f4322..475d43a4 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.android.kt @@ -4,10 +4,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import com.daedan.festabook.di.FestabookAppGraph -import com.daedan.festabook.di.appGraph +import com.daedan.festabook.di.androidAppGraph @Composable actual fun rememberAppGraph(): FestabookAppGraph { val context = LocalContext.current - return remember(context) { context.appGraph } + return remember(context) { context.androidAppGraph } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.android.kt new file mode 100644 index 00000000..ccd3aaad --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.android.kt @@ -0,0 +1,29 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.daedan.festabook.presentation.NotificationPermissionManager + +@Composable +actual fun rememberNotificationPermissionManager( + notificationPermissionManagerFactory: NotificationPermissionManager.Factory, + onPermissionGrant: () -> Unit, + onPermissionDeny: () -> Unit, +): NotificationPermissionManager { + val permissionLauncher = + rememberPermissionLauncher { isGranted -> + if (isGranted) onPermissionGrant() else onPermissionDeny() + } + val context = LocalContext.current + val activity = LocalActivity.current + + return remember(notificationPermissionManagerFactory, permissionLauncher) { + notificationPermissionManagerFactory.create( + context = context, + launchPermission = { onResult -> permissionLauncher(onResult) }, + shouldShowRationale = { permission -> shouldShowRationale(permission, activity) }, + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.android.kt new file mode 100644 index 00000000..855c9c69 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.android.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberOpenAppSettings(): () -> Unit { + val context = LocalContext.current + return remember { + { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.android.kt new file mode 100644 index 00000000..2ed650e6 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.android.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberPermissionLauncher(onResult: (Boolean) -> Unit): (String) -> Unit { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + onResult(isGranted) + } + + return { permission -> + launcher.launch(permission) + } +} + +actual fun shouldShowRationale( + permission: String, + activity: Any?, +): Boolean = (activity as? ComponentActivity)?.shouldShowRequestPermissionRationale(permission) ?: false diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_arrow_forward_right.xml b/composeApp/src/commonMain/composeResources/drawable/ic_arrow_forward_right.xml index 5c628e40..7fb85640 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_arrow_forward_right.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_arrow_forward_right.xml @@ -1,5 +1,13 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#D6D6D6" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp"> - - <path android:fillColor="@android:color/white" android:pathData="M321,880L250,809L579,480L250,151L321,80L721,480L321,880Z"/> - -</vector> + + + + + diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/Greeting.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/Greeting.kt deleted file mode 100644 index 57687887..00000000 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/Greeting.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.daedan.festabook - -class Greeting { - private val platform = getPlatform() - - fun greet(): String = "Hello, ${platform.name}!" -} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt deleted file mode 100644 index b033bd5c..00000000 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.daedan.festabook - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt index 531dbbb7..468e2cd7 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt @@ -29,13 +29,13 @@ class FestivalNotificationRepositoryImpl( withTimeoutOrNullFallback( producer = { deviceLocalDataSource.getDeviceId().firstOrNull() }, onFallback = { /*TODO 로그 */ }, - ) ?: return Result.failure(IllegalStateException()) + ) ?: return Result.failure(IllegalStateException("deviceId가 null")) val festivalId = withTimeoutOrNullFallback( producer = { festivalLocalDataSource.getFestivalId().firstOrNull() }, onFallback = { /*TODO 로그 */ }, - ) ?: return Result.failure(IllegalStateException()) + ) ?: return Result.failure(IllegalStateException("festivalId가 null")) val result = festivalNotificationRemoteDataSource @@ -89,11 +89,14 @@ class FestivalNotificationRepositoryImpl( } } - override suspend fun setFestivalNotificationIsAllow(isAllowed: Boolean) { - withTimeoutOrNullFallback( - producer = { festivalLocalDataSource.getFestivalId().firstOrNull() }, - onFallback = { /*TODO 로그 */ }, - )?.let { festivalId -> + override suspend fun saveFestivalNotificationIsAllow(isAllowed: Boolean): Result { + val festivalId = + withTimeoutOrNullFallback( + producer = { festivalLocalDataSource.getFestivalId().firstOrNull() }, + onFallback = { /*TODO 로그 */ }, + ) ?: return Result.failure(IllegalStateException()) + + return runCatching { festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed( festivalId = festivalId, isAllowed = isAllowed, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt index caf7f2e1..b38f12bf 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt @@ -1,7 +1,10 @@ package com.daedan.festabook.di +import com.daedan.festabook.presentation.NotificationPermissionManager import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory interface FestabookAppGraph { val metroViewModelFactory: MetroViewModelFactory + + val notificationPermissionManagerFactory: NotificationPermissionManager.Factory } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt index 616d7447..edad3d2a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt @@ -9,5 +9,5 @@ interface FestivalNotificationRepository { fun getFestivalNotificationIsAllow(): Flow - suspend fun setFestivalNotificationIsAllow(isAllowed: Boolean) + suspend fun saveFestivalNotificationIsAllow(isAllowed: Boolean): Result } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt index 3856116e..58a98f0d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt @@ -2,7 +2,6 @@ package com.daedan.festabook.presentation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import com.daedan.festabook.di.FestabookAppGraph @@ -14,13 +13,14 @@ import com.daedan.festabook.presentation.platform.rememberAppVersionManager import com.daedan.festabook.presentation.platform.rememberLocationSource import com.daedan.festabook.presentation.splash.SplashViewModel import com.daedan.festabook.presentation.splash.navigation.splashNavGraph +import dev.zacsweers.metrox.viewmodel.metroViewModel @Composable fun FestabookScreen( onAppFinish: () -> Unit, modifier: Modifier = Modifier, appGraph: FestabookAppGraph = rememberAppGraph(), - splashViewModel: SplashViewModel = viewModel(factory = appGraph.metroViewModelFactory), + splashViewModel: SplashViewModel = metroViewModel(), ) { val locationSource = rememberLocationSource() val festabookNavigator = rememberFestabookNavigator() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.kt new file mode 100644 index 00000000..124bd242 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.kt @@ -0,0 +1,9 @@ +package com.daedan.festabook.presentation + +expect class NotificationPermissionManager { + interface Factory + + suspend fun checkPermission(): PermissionState + + fun requestPermission() +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/PermissionState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/PermissionState.kt new file mode 100644 index 00000000..97a3b219 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/PermissionState.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.presentation + +enum class PermissionState { + GRANTED, + NEED_RATIONALE, + DENIED, +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt index 65f769f2..6e1bb067 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt @@ -1,7 +1,10 @@ package com.daedan.festabook.presentation.common.component +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -21,12 +24,14 @@ fun FestabookTopAppBar( style = style, modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) .padding( top = festabookSpacing.paddingTitleHorizontal, bottom = festabookSpacing.paddingBody4, start = festabookSpacing.paddingScreenGutter, end = festabookSpacing.paddingScreenGutter, - ).fillMaxWidth(), + ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SnackBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SnackBar.kt index 1da61d10..26c2ea05 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SnackBar.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SnackBar.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -16,6 +17,8 @@ import festabookkmp.composeapp.generated.resources.error_network_exception import festabookkmp.composeapp.generated.resources.error_server_exception import festabookkmp.composeapp.generated.resources.error_unknown_exception import festabookkmp.composeapp.generated.resources.fail_snackbar_confirm +import festabookkmp.composeapp.generated.resources.move_to_setting_text +import festabookkmp.composeapp.generated.resources.notification_permission_denied_message import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -39,6 +42,8 @@ class SnackbarManager( private val actionLabel: String, private val errorMessages: Map, String>, private val defaultErrorMessage: String, + private val permissionDeniedMessage: String, + private val moveToSettingLabel: String, ) { fun show(message: String) { hostState.currentSnackbarData?.dismiss() @@ -55,6 +60,21 @@ class SnackbarManager( val message = errorMessages[throwable::class] ?: defaultErrorMessage show(message) } + + fun showPermissionDeniedSnackbar(onOpenSettings: () -> Unit) { + hostState.currentSnackbarData?.dismiss() + scope.launch { + val result = + hostState.showSnackbar( + message = permissionDeniedMessage, + actionLabel = moveToSettingLabel, + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + onOpenSettings() + } + } + } } @Composable @@ -67,6 +87,8 @@ fun rememberAppSnackbarManager( val networkErrorMessage = stringResource(Res.string.error_network_exception) val unknownErrorMessage = stringResource(Res.string.error_unknown_exception) val actionLabel = stringResource(Res.string.fail_snackbar_confirm) + val permissionDeniedMessage = stringResource(Res.string.notification_permission_denied_message) + val moveToSettingLabel = stringResource(Res.string.move_to_setting_text) val errorMessages = remember { @@ -79,6 +101,14 @@ fun rememberAppSnackbarManager( } return remember(snackbarHostState, scope) { - SnackbarManager(snackbarHostState, scope, actionLabel, errorMessages, unknownErrorMessage) + SnackbarManager( + hostState = snackbarHostState, + scope = scope, + actionLabel = actionLabel, + errorMessages = errorMessages, + defaultErrorMessage = unknownErrorMessage, + permissionDeniedMessage = permissionDeniedMessage, + moveToSettingLabel = moveToSettingLabel, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt new file mode 100644 index 00000000..500ac145 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.home + +import com.daedan.festabook.domain.model.Organization + +sealed interface FestivalUiState { + data object Loading : FestivalUiState + + data class Success( + val organization: Organization, + ) : FestivalUiState + + data class Error( + val throwable: Throwable, + ) : FestivalUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt new file mode 100644 index 00000000..1e97e294 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -0,0 +1,71 @@ +package com.daedan.festabook.presentation.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.repository.FestivalRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@ContributesIntoMap(AppScope::class) +@ViewModelKey(HomeViewModel::class) +@Inject +class HomeViewModel( + private val festivalRepository: FestivalRepository, +) : ViewModel() { + private val _festivalUiState = MutableStateFlow(FestivalUiState.Loading) + val festivalUiState: StateFlow = _festivalUiState.asStateFlow() + + private val _lineupUiState = MutableStateFlow(LineupUiState.Loading) + val lineupUiState: StateFlow = _lineupUiState.asStateFlow() + + private val _navigateToScheduleEvent = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigateToScheduleEvent: SharedFlow = _navigateToScheduleEvent.asSharedFlow() + + init { + loadFestival() + loadLineup() + } + + fun loadFestival() { + viewModelScope.launch { + _festivalUiState.value = FestivalUiState.Loading + + val result = festivalRepository.getFestivalInfo() + result + .onSuccess { festival -> + _festivalUiState.value = FestivalUiState.Success(festival) + }.onFailure { + _festivalUiState.value = FestivalUiState.Error(it) + } + } + } + + fun navigateToScheduleClick() { + _navigateToScheduleEvent.tryEmit(Unit) + } + + private fun loadLineup() { + viewModelScope.launch { + _lineupUiState.value = LineupUiState.Loading + + val result = festivalRepository.getLineUpGroupByDate() + result + .onSuccess { lineups -> + val lineupItems = lineups.toUiModel().getLineupItems() + _lineupUiState.value = LineupUiState.Success(lineupItems) + }.onFailure { + _lineupUiState.value = LineupUiState.Error(it) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineUpItemOfDayUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineUpItemOfDayUiModel.kt new file mode 100644 index 00000000..7c45da4e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineUpItemOfDayUiModel.kt @@ -0,0 +1,37 @@ +package com.daedan.festabook.presentation.home + +import com.daedan.festabook.domain.model.LineupItem +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn +import kotlin.time.Clock + +data class LineUpItemOfDayUiModel( + val id: Long, + val date: LocalDate, + val isDDay: Boolean, + val lineupItems: List, +) + +data class LineUpItemGroupUiModel( + private val group: Map>, + private val today: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()), +) { + fun getLineupItems(): List = + group.entries.map { + LineUpItemOfDayUiModel( + id = it.hashCode().toLong(), + date = it.key, + isDDay = it.key == today, + lineupItems = it.value, + ) + } +} + +fun Map>.toUiModel(): LineUpItemGroupUiModel = + LineUpItemGroupUiModel( + group = + this.mapValues { (_, lineupItems) -> + lineupItems.map { it.toUiModel() } + }, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupItemUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupItemUiModel.kt new file mode 100644 index 00000000..a0cfe9d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupItemUiModel.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.home + +import com.daedan.festabook.domain.model.LineupItem +import kotlinx.datetime.LocalDateTime + +data class LineupItemUiModel( + val id: Long, + val imageUrl: String, + val name: String, + val performanceAt: LocalDateTime, +) + +fun LineupItem.toUiModel(): LineupItemUiModel = + LineupItemUiModel( + id = id, + imageUrl = imageUrl, + name = name, + performanceAt = performanceAt, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt new file mode 100644 index 00000000..dcfc03e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt @@ -0,0 +1,13 @@ +package com.daedan.festabook.presentation.home + +sealed interface LineupUiState { + data object Loading : LineupUiState + + data class Success( + val lineups: List, + ) : LineupUiState + + data class Error( + val throwable: Throwable, + ) : LineupUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt new file mode 100644 index 00000000..1db1a844 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -0,0 +1,116 @@ +package com.daedan.festabook.presentation.setting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.repository.FestivalNotificationRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@ContributesIntoMap(AppScope::class) +@ViewModelKey(SettingViewModel::class) +@Inject +class SettingViewModel( + private val festivalNotificationRepository: FestivalNotificationRepository, +) : ViewModel() { + private val _permissionCheckEvent: MutableSharedFlow = MutableSharedFlow() + val permissionCheckEvent: SharedFlow = _permissionCheckEvent.asSharedFlow() + + private val _isAllowed = MutableStateFlow(false) + val isAllowed: StateFlow = _isAllowed.asStateFlow() + + private val _error: MutableSharedFlow = MutableSharedFlow() + val error: SharedFlow = _error.asSharedFlow() + + private val _isLoading: MutableStateFlow = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _success: MutableSharedFlow = MutableSharedFlow() + val success = _success.asSharedFlow() + + init { + viewModelScope.launch { + festivalNotificationRepository + .getFestivalNotificationIsAllow() + .collect { _isAllowed.value = it } + } + } + + fun notificationAllowClick() { + if (!_isAllowed.value) { + viewModelScope.launch { + _permissionCheckEvent.emit(Unit) + } + } else { + deleteNotificationId() + } + } + + private suspend fun saveNotificationIsAllowed(isAllowed: Boolean) { + val result = festivalNotificationRepository.saveFestivalNotificationIsAllow(isAllowed) + + result + .onSuccess { + updateNotificationIsAllowed(isAllowed) + }.onFailure { + _error.emit(it) + } + } + + private fun updateNotificationIsAllowed(allowed: Boolean) { + _isAllowed.value = allowed + } + + fun saveNotificationId() { + if (_isLoading.value) return + _isLoading.value = true + + // Optimistic UI 적용, 요청 실패 시 원복 + viewModelScope.launch { + saveNotificationIsAllowed(true) + + val result = + festivalNotificationRepository.saveFestivalNotification() + + result + .onSuccess { + _success.emit(Unit) + }.onFailure { + _error.emit(it) + saveNotificationIsAllowed(false) +// Timber.e(it, "${this::class.java.simpleName} NotificationId 저장 실패") + }.also { + _isLoading.value = false + } + } + } + + private fun deleteNotificationId() { + if (_isLoading.value) return + _isLoading.value = true + + // Optimistic UI 적용, 요청 실패 시 원복 + viewModelScope.launch { + saveNotificationIsAllowed(false) + val result = + festivalNotificationRepository.deleteFestivalNotification() + + result + .onFailure { + _error.emit(it) + saveNotificationIsAllowed(true) +// Timber.e(it, "${this::class.java.simpleName} NotificationId 삭제 실패") + }.also { + _isLoading.value = false + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/NotificationPermissionDialog.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/NotificationPermissionDialog.kt new file mode 100644 index 00000000..55b070d7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/NotificationPermissionDialog.kt @@ -0,0 +1,45 @@ +package com.daedan.festabook.presentation.setting.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.daedan.festabook.presentation.theme.FestabookColor +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.confirm +import festabookkmp.composeapp.generated.resources.notification_permission_message +import festabookkmp.composeapp.generated.resources.setting_title +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun NotificationPermissionDialog(onConfirm: () -> Unit) { + val title = stringResource(Res.string.setting_title) + val message = stringResource(Res.string.notification_permission_message) + val confirmText = stringResource(Res.string.confirm) + AlertDialog( + containerColor = FestabookColor.white, + onDismissRequest = {}, + confirmButton = { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = FestabookColor.black, + contentColor = FestabookColor.white, + ), + ) { Text(confirmText) } + }, + title = { Text(title) }, + text = { Text(message) }, + ) +} + +@Preview +@Composable +private fun NotificationPermissionDialogPreview() { + NotificationPermissionDialog( + onConfirm = { }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt new file mode 100644 index 00000000..22e995b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -0,0 +1,389 @@ +package com.daedan.festabook.presentation.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.BuildKonfig +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.PermissionState +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.FestabookSwitch +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.home.FestivalUiState +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.setting.SettingViewModel +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.festabookSpacing +import dev.zacsweers.metrox.viewmodel.metroViewModel +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.ic_arrow_forward_right +import festabookkmp.composeapp.generated.resources.move +import festabookkmp.composeapp.generated.resources.setting_app_info_title +import festabookkmp.composeapp.generated.resources.setting_app_version +import festabookkmp.composeapp.generated.resources.setting_contact_us +import festabookkmp.composeapp.generated.resources.setting_current_university_notice +import festabookkmp.composeapp.generated.resources.setting_notice_enabled +import festabookkmp.composeapp.generated.resources.setting_notice_title +import festabookkmp.composeapp.generated.resources.setting_service_policy +import festabookkmp.composeapp.generated.resources.setting_title +import kotlinx.datetime.LocalDate +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +private const val POLICY_URL: String = + "https://www.notion.so/244a540dc0b780638e56e31c4bdb3c9f" + +private const val CONTACT_US_URL = + "https://forms.gle/XjqJFfQrTPgkZzGZ9" + +@Composable +fun SettingRoute( + notificationPermissionManager: NotificationPermissionManager, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, + modifier: Modifier = Modifier, + settingViewModel: SettingViewModel = metroViewModel(), + homeViewModel: HomeViewModel = metroViewModel(), +) { + val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() + val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() + val isSubscribedLoading by settingViewModel.isLoading.collectAsStateWithLifecycle() + val uriHandler = LocalUriHandler.current + + val enableMessage = stringResource(Res.string.setting_notice_enabled) + + var showPermissionDialog by remember { mutableStateOf(false) } + + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + val permission = notificationPermissionManager.checkPermission() + + when (permission) { + PermissionState.GRANTED -> {} + + PermissionState.NEED_RATIONALE -> { + showPermissionDialog = true + } + + PermissionState.DENIED -> { + notificationPermissionManager.requestPermission() + } + } + } + + ObserveAsEvents(flow = settingViewModel.success) { + onShowSnackBar(enableMessage) + } + + ObserveAsEvents(flow = settingViewModel.error) { + onShowErrorSnackBar(it) + } + if (showPermissionDialog) { + NotificationPermissionDialog( + onConfirm = { + showPermissionDialog = false + notificationPermissionManager.requestPermission() + }, + ) + } + SettingScreen( + modifier = modifier, + festivalUiState = festival, + isUniversitySubscribed = isUniversitySubscribed, + appVersion = "v ${BuildKonfig.APP_VERSION_NAME}", + isSubscribeEnabled = !isSubscribedLoading, + onSubscribeClick = { settingViewModel.notificationAllowClick() }, + onPolicyClick = { uriHandler.openUri(POLICY_URL) }, + onContactUsClick = { uriHandler.openUri(CONTACT_US_URL) }, + onError = { + onShowErrorSnackBar(it.throwable) +// Timber.w( +// it.throwable, +// "${"SettingRoute"}: ${it.throwable.message}", +// ) + }, + ) +} + +@Composable +fun SettingScreen( + festivalUiState: FestivalUiState, + isUniversitySubscribed: Boolean, + appVersion: String, + isSubscribeEnabled: Boolean, + modifier: Modifier = Modifier, + onSubscribeClick: (Boolean) -> Unit = {}, + onPolicyClick: () -> Unit = {}, + onContactUsClick: () -> Unit = {}, + onError: (FestivalUiState.Error) -> Unit = {}, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + val currentOnError by rememberUpdatedState(onError) + + LaunchedEffect(festivalUiState) { + when (festivalUiState) { + is FestivalUiState.Error -> currentOnError(festivalUiState) + else -> Unit + } + } + + Scaffold( + topBar = { + FestabookTopAppBar( + title = stringResource(Res.string.setting_title), + ) + }, + modifier = modifier, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = FestabookColor.white) + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .padding(innerPadding), + ) { + when (festivalUiState) { + is FestivalUiState.Success -> { + SubscriptionContent( + universityName = festivalUiState.organization.organizationName, + isUniversitySubscribed = isUniversitySubscribed, + onSubscribeClick = onSubscribeClick, + isSubscribeEnabled = isSubscribeEnabled, + ) + } + + else -> {} + } + + HorizontalDivider( + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding(vertical = 20.dp), + color = FestabookColor.gray100, + thickness = festabookSpacing.paddingBody2, + ) + + AppInfoContent( + appVersion = appVersion, + onPolicyClick = onPolicyClick, + onContactUsClick = onContactUsClick, + ) + } + } +} + +@Composable +private fun SubscriptionContent( + universityName: String, + isUniversitySubscribed: Boolean, + isSubscribeEnabled: Boolean, + onSubscribeClick: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(Res.string.setting_notice_title), + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(top = 20.dp), + ) + + Row( + modifier = Modifier.wrapContentSize(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(Res.string.setting_current_university_notice), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = FestabookTypography.titleMedium, + modifier = + Modifier.padding( + top = festabookSpacing.paddingBody3, + ), + ) + + Text( + text = universityName, + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = FestabookColor.gray500, + ) + } + + FestabookSwitch( + enabled = isSubscribeEnabled, + checked = isUniversitySubscribed, + onCheckedChange = onSubscribeClick, + ) + } + } +} + +@Composable +private fun AppInfoContent( + appVersion: String, + onPolicyClick: () -> Unit, + onContactUsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(Res.string.setting_app_info_title), + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody3), + style = FestabookTypography.bodyMedium, + ) + + AppVersionInfo( + appVersion = appVersion, + ) + + AppInfoButton( + text = stringResource(Res.string.setting_service_policy), + onClick = onPolicyClick, + ) + AppInfoButton( + text = stringResource(Res.string.setting_contact_us), + onClick = onContactUsClick, + ) + } +} + +@Composable +private fun AppVersionInfo( + appVersion: String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .padding(vertical = festabookSpacing.paddingBody3), + ) { + Text( + text = stringResource(Res.string.setting_app_version), + style = FestabookTypography.titleMedium, + ) + + Text( + text = appVersion, + style = FestabookTypography.bodyMedium, + ) + } +} + +@Composable +private fun AppInfoButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .requiredWidth(screenWidthDp) + .clickable { + onClick() + }.padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.paddingBody3, + ), + ) { + Text( + text = text, + style = FestabookTypography.titleMedium, + ) + + Icon( + painter = painterResource(Res.drawable.ic_arrow_forward_right), + contentDescription = stringResource(Res.string.move), + tint = Color.Unspecified, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + FestabookTheme { + var isSubscribed by remember { mutableStateOf(false) } + SettingScreen( + festivalUiState = + FestivalUiState.Success( + Organization( + id = 1, + organizationName = "성균관대학교 인문사회과학철학문학자연캠퍼스 인문사회과학철학문학자연캠퍼스", + festival = + Festival( + festivalName = "성균관대학교 축제축제축제축제축제축제축제축제축제축제축제축제", + festivalImages = listOf(), + startDate = LocalDate(2026, 1, 1), + endDate = LocalDate(2026, 1, 2), + ), + ), + ), + isUniversitySubscribed = isSubscribed, + onSubscribeClick = { isSubscribed = !isSubscribed }, + appVersion = "v1.0.0", + isSubscribeEnabled = true, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.kt new file mode 100644 index 00000000..f92aa41f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.kt @@ -0,0 +1,11 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.compose.runtime.Composable +import com.daedan.festabook.presentation.NotificationPermissionManager + +@Composable +expect fun rememberNotificationPermissionManager( + notificationPermissionManagerFactory: NotificationPermissionManager.Factory, + onPermissionGrant: () -> Unit, + onPermissionDeny: () -> Unit, +): NotificationPermissionManager diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.kt new file mode 100644 index 00000000..58ba5253 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberOpenAppSettings(): () -> Unit diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.kt new file mode 100644 index 00000000..c90383b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.kt @@ -0,0 +1,11 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberPermissionLauncher(onResult: (Boolean) -> Unit): (String) -> Unit + +expect fun shouldShowRationale( + permission: String, + activity: Any?, +): Boolean diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt new file mode 100644 index 00000000..075c93db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -0,0 +1,29 @@ +@file:Suppress("ktlint:standard:no-empty-file") + +package com.daedan.festabook.presentation.setting.navigation + +// import androidx.navigation.NavGraphBuilder +// import androidx.navigation.compose.composable +// import com.daedan.festabook.presentation.NotificationPermissionManager +// import com.daedan.festabook.presentation.home.HomeViewModel +// import com.daedan.festabook.presentation.main.MainTabRoute +// import com.daedan.festabook.presentation.setting.SettingViewModel +// import com.daedan.festabook.presentation.setting.component.SettingRoute +// +// fun NavGraphBuilder.settingNavGraph( +// homeViewModel: HomeViewModel, +// settingViewModel: SettingViewModel, +// notificationPermissionManager: NotificationPermissionManager, +// onShowSnackBar: (String) -> Unit, +// onShowErrorSnackBar: (Throwable) -> Unit, +// ) { +// composable { +// SettingRoute( +// homeViewModel = homeViewModel, +// settingViewModel = settingViewModel, +// notificationPermissionManager = notificationPermissionManager, +// onShowSnackBar = onShowSnackBar, +// onShowErrorSnackBar = onShowErrorSnackBar, +// ) +// } +// } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt index fd5aa565..43b26abd 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt @@ -1,16 +1,56 @@ package com.daedan.festabook +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController -import com.daedan.festabook.presentation.FestabookScreen +import com.daedan.festabook.di.IosAppGraph +import com.daedan.festabook.presentation.common.component.FestabookSnackbar +import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.component.SettingRoute +import com.daedan.festabook.presentation.setting.component.platform.rememberNotificationPermissionManager +import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings import com.daedan.festabook.presentation.theme.FestabookTheme -import platform.posix.exit +import dev.zacsweers.metro.createGraph +import dev.zacsweers.metrox.viewmodel.metroViewModel + +private val iosAppGraph = createGraph() +private val notificationPermissionManagerFactory = iosAppGraph.notificationPermissionManagerFactory @Suppress("ktlint:standard:function-naming") fun MainViewController() = ComposeUIViewController { FestabookTheme { - FestabookScreen( - onAppFinish = { exit(0) }, - ) + val snackbarHostState = remember { SnackbarHostState() } + val snackbarManager = rememberAppSnackbarManager(snackbarHostState) + val openAppSettings = rememberOpenAppSettings() + Scaffold(snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + FestabookSnackbar(data) + } + }) { innerPadding -> + val viewModel = metroViewModel() + SettingRoute( + notificationPermissionManager = + rememberNotificationPermissionManager( + notificationPermissionManagerFactory = notificationPermissionManagerFactory, + onPermissionGrant = { + viewModel.saveNotificationId() + }, + onPermissionDeny = { + snackbarManager.showPermissionDeniedSnackbar( + openAppSettings, + ) + }, + ), + onShowSnackBar = {}, + onShowErrorSnackBar = {}, + ) + } } +// FestabookScreen( +// onAppFinish = { exit(0) }, +// ) } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt deleted file mode 100644 index 36cbe559..00000000 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.daedan.festabook - -import platform.UIKit.UIDevice - -class IOSPlatform : Platform { - override val name: String = - UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion -} - -actual fun getPlatform(): Platform = IOSPlatform() diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.ios.kt new file mode 100644 index 00000000..e5215d90 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/NotificationPermissionManager.ios.kt @@ -0,0 +1,45 @@ +package com.daedan.festabook.presentation + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.UserNotifications.UNAuthorizationStatusAuthorized +import platform.UserNotifications.UNAuthorizationStatusProvisional +import platform.UserNotifications.UNUserNotificationCenter +import kotlin.coroutines.resume + +@AssistedInject +actual class NotificationPermissionManager( + @Assisted private val launchPermission: (String) -> Unit, +) { + @AssistedFactory + actual interface Factory { + fun create(launchPermission: (String) -> Unit): NotificationPermissionManager + } + + private val center = UNUserNotificationCenter.currentNotificationCenter() + + actual suspend fun checkPermission(): PermissionState = + suspendCancellableCoroutine { const -> + center.getNotificationSettingsWithCompletionHandler { settings -> + val result = + when (settings?.authorizationStatus) { + UNAuthorizationStatusAuthorized, + UNAuthorizationStatusProvisional, + -> { + PermissionState.GRANTED + } + + else -> { + PermissionState.DENIED + } + } + const.resume(result) + } + } + + actual fun requestPermission() { + launchPermission("") + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.ios.kt new file mode 100644 index 00000000..9ea59afe --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberNotificationPermissionManager.ios.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.daedan.festabook.presentation.NotificationPermissionManager + +@Composable +actual fun rememberNotificationPermissionManager( + notificationPermissionManagerFactory: NotificationPermissionManager.Factory, + onPermissionGrant: () -> Unit, + onPermissionDeny: () -> Unit, +): NotificationPermissionManager { + val permissionLauncher = + rememberPermissionLauncher { isGranted -> + if (isGranted) onPermissionGrant() else onPermissionDeny() + } + return remember(notificationPermissionManagerFactory, permissionLauncher) { + notificationPermissionManagerFactory.create( + launchPermission = { permission -> permissionLauncher(permission) }, + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt new file mode 100644 index 00000000..f125f2fa --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.Foundation.NSURL +import platform.UIKit.UIApplication + +@Composable +actual fun rememberOpenAppSettings(): () -> Unit = + remember { + { + val url = + NSURL.URLWithString("app-settings:") ?: return@remember + UIApplication.sharedApplication.openURL(url, mapOf(), null) + } + } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.ios.kt new file mode 100644 index 00000000..94eb766e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberPermissionLauncher.ios.kt @@ -0,0 +1,40 @@ +package com.daedan.festabook.presentation.setting.component.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import platform.UserNotifications.UNAuthorizationOptionAlert +import platform.UserNotifications.UNAuthorizationOptionBadge +import platform.UserNotifications.UNAuthorizationOptionSound +import platform.UserNotifications.UNUserNotificationCenter + +@Composable +actual fun rememberPermissionLauncher(onResult: (Boolean) -> Unit): (String) -> Unit { + val scope = rememberCoroutineScope() + val currentOnResult = rememberUpdatedState(onResult) + + return remember { + { _ -> + val center = UNUserNotificationCenter.currentNotificationCenter() + + center.requestAuthorizationWithOptions( + options = + UNAuthorizationOptionAlert or + UNAuthorizationOptionSound or + UNAuthorizationOptionBadge, + ) { granted, _ -> + scope.launch(Dispatchers.Main) { + currentOnResult.value(granted) + } + } + } + } +} + +actual fun shouldShowRationale( + permission: String, + activity: Any?, +): Boolean = false diff --git a/gradle.properties b/gradle.properties index bb728e64..f3e0f481 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,17 +1,15 @@ #Kotlin kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M - #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.configuration-cache=true org.gradle.caching=true - #Android android.nonTransitiveRClass=true android.useAndroidX=true - kotlin.apple.deprecated.allowUsingEmbedAndSignWithCocoaPodsDependencies=true +kotlin.native.cacheKind.iosSimulatorArm64=none kotlin.incremental.native=true kotlin.native.binary.stripDebugSymbols=false kotlin.native.cacheKind=static @@ -19,3 +17,5 @@ kotlin.native.disableCompilerDaemon=false buildkonfig.flavor=dev +APP_VERSION_NAME=2.1.0 +APP_VERSION_CODE=20100 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 243229c1..952c27c7 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -172,14 +172,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 807e4cd2..e070a884 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,12 +1,24 @@ - - + - - CADisableMinimumFrameDurationOnPhone - - LSApplicationQueriesSchemes - - itms-apps - - + + CFBundleName + festabook + + CADisableMinimumFrameDurationOnPhone + + + CFBundleShortVersionString + 2.1.0 + + CFBundleVersion + 20100 + + CADisableMinimumFrameDurationOnPhone + + LSApplicationQueriesSchemes + + itms-apps + + From b6c303cf5a1a32005634af3556b165a1f6427f23 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:04:19 +0900 Subject: [PATCH 05/54] =?UTF-8?q?[Feat]=20FCM=20CMP=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Android 플랫폼 Firebase Cloud Messaging(FCM) 도입 및 디바이스 식별자 관리 구현 * **feat: Firebase Cloud Messaging(FCM) 연동 및 알림 서비스 구현** * `libs.versions.toml` 및 `build.gradle.kts`에 `firebase-messaging` 의존성을 추가했습니다. * `MyFirebaseMessagingService`를 추가하여 푸시 메시지 수신(`onMessageReceived`) 및 토큰 갱신(`onNewToken`) 처리를 구현하고 `AndroidManifest.xml`에 등록했습니다. * `NotificationHelper`를 신규 생성하여 알림 채널 설정 및 알림 클릭 시 특정 공지사항으로 이동하는 딥링크 펜딩 인텐트 로직을 구현했습니다. * **feat: 앱 초기화 시 디바이스 식별자 및 FCM 토큰 관리** * `FestabookApp` 클래스에 종속성 주입(Metro)을 연결하고 앱 시작 시 필요한 초기화 로직을 추가했습니다. * 디바이스 고유 UUID가 없는 경우 새로 생성하여 `DeviceLocalDataSource`에 저장하도록 구현했습니다. * `FirebaseMessaging`을 통해 FCM 토큰을 취득하고 `FcmDataSource`를 통해 서버/로컬에 갱신하도록 구성했습니다. * 앱 실행 시 Crashlytics의 미전송 리포트가 있는 경우 즉시 전송하도록 `sendUnsentReports()`를 호출합니다. * **refactor: Android 의존성 주입 그래프 확장** * `AndroidAppGraph` 인터페이스에 `FestabookApp` 인스턴스 주입을 위한 `inject` 메서드를 추가했습니다. * `AppUpdateManager`를 제공하는 `@Provides` 메서드를 추가하여 관련 기능 확장을 준비했습니다. * refactor: `DeviceRepository` 내 기기 등록 로직 개선 및 책임 이동 * **refactor: `DeviceRepository` 인터페이스 및 구현체 내 `registerDevice` 메서드 수정** * 메서드 시그니처를 `registerDevice(fcmToken: String): Result`으로 변경하여 호출부의 책임을 줄였습니다. * 기존에 외부에서 주입받던 `deviceIdentifier`를 `DeviceLocalDataSource`에서 직접 조회하거나 없을 경우 `randomUUID()`를 통해 새로 생성하여 저장하도록 내부 로직을 개선했습니다. * 전달받은 `fcmToken`이 로컬에 저장되어 있지 않은 경우 `FcmDataSource`를 통해 저장하는 로직을 추가했습니다. * 서버로부터 등록 성공 응답을 받은 경우, 반환된 기기 ID를 `saveDeviceId`를 통해 자동으로 저장하도록 구현했습니다. * 결과 반환 타입을 `Result`에서 `Result`으로 변경하여 데이터 흐름을 캡슐화했습니다. * feat: 플랫폼별 딥링크 핸들러 및 UUID 생성 구현 * **feat: 플랫폼별 공통 `RememberDeepLinkHandler` 구현** * `commonMain`: 딥링크 키 정의(`DeepLinkKeys`) 및 `RememberDeepLinkHandler` expect 함수를 선언했습니다. * **Android**: `ComponentActivity`의 `Intent`와 `OnNewIntentListener`를 사용하여 딥링크 이벤트를 구독하고 처리하도록 구현했습니다. * **iOS**: `NSNotificationCenter`를 통해 `OpenURLNotification`을 관찰하여 전달된 `NSURL`을 처리하는 핸들러를 구현했습니다. * **feat: 플랫폼별 UUID 생성 함수 구현** * `commonMain`: 고유 식별자 생성을 위한 `randomUUID()` expect 함수를 정의했습니다. * **Android**: `java.util.UUID`를 사용하여 구현했습니다. * **iOS**: `platform.Foundation.NSUUID`를 사용하여 구현했습니다. * **refactor: Android 알림(Notification) 설정 및 리소스 정리** * `NotificationHelper`: 알림 아이콘 색상을 테마 컬러(`FestabookColorPalette`)와 연동하고, `MainActivity` 참조 경로를 수정했습니다. * `MyFirebaseMessagingService`: 불필요한 로그(Timber) 출력을 제거하고, 알림 제목/내용이 없을 경우를 대비해 `strings.xml`의 기본값을 사용하도록 보완했습니다. * **Resource**: 알림 전용 아이콘들을 `commonMain`에서 `androidMain` 리소스로 이동하고, FCM용 기본 문자열 리소스를 추가했습니다. * feat: 네트워크 통신 로직 개선 및 Android 디바이스 등록 프로세스 최적화 * **refactor: Ktor HttpClient 설정 및 JSON 직렬화 로직 개선** * `ContentNegotiation` 설정 시 `ContentType.Text.JavaScript` 타입에 대해 공통 `Json` 인스턴스를 사용하도록 수정하여 일관성을 높였습니다. * `DefaultRequest` 플러그인을 추가하여 모든 요청의 기본 `Content-Type` 헤더를 `application/json`으로 설정했습니다. * **refactor: Android 디바이스 식별자 및 FCM 토큰 등록 로직 개선** * `FestabookApp`에서 `DeviceLocalDataSource` 및 `FcmDataSource`를 직접 참조하던 방식을 `DeviceRepository` 주입으로 변경하여 계층 간 의존성을 정제했습니다. * `setupDeviceIdentifiers` 메서드에서 UUID를 수동으로 생성하고 저장하던 로직을 `DeviceRepository.registerDevice` 호출로 통합하여 관리 효율성을 높였습니다. * `AndroidAppGraph`에 `FirebaseMessaging` 객체를 제공하는 `@Provides` 메서드를 추가했습니다. * **chore: Android 프로젝트 설정 및 매니페스트 수정** * `AndroidManifest.xml` 내 `MyFirebaseMessagingService`의 패키지 경로를 `.presentation.service`로 올바르게 수정했습니다. * `libs.versions.toml`에서 `android-minSdk` 버전을 `24`에서 `28`로 상향 조정했습니다. * refactor: Android 플랫폼 서비스 클래스 패키지 위치 변경 * **refactor: `MyFirebaseMessagingService` 및 `NotificationHelper` 패키지 이동** * 서비스 관련 클래스들을 `com.daedan.festabook.presentation.service`에서 `com.daedan.festabook.service` 패키지로 이동하여 구조를 단순화했습니다. * 패키지 변경에 따라 `AndroidManifest.xml` 내의 서비스 선언 경로를 업데이트했습니다. * `FestabookApp.kt` 등 관련 참조 파일의 임포트 경로를 수정했습니다. * feat: iOS Firebase Cloud Messaging(FCM) 설정 및 관련 의존성 추가 * **feat: iOS 프로젝트 내 Firebase Messaging 및 Core 추가** * `iosApp/Podfile` 및 `composeApp/build.gradle.kts`에 `FirebaseMessaging`과 `FirebaseCore` 포드를 추가했습니다. * `Podfile.lock`에 관련 의존성 버전을 업데이트했습니다. * **feat: iOS 알림 권한 및 백그라운드 모드 설정** * `iosApp/Info.plist`: `UIBackgroundModes`에 `remote-notification`을 추가하고 `FirebaseAppDelegateProxyEnabled`를 `false`로 설정했습니다. * `iosApp/iosAppDebug.entitlements`: 디버그 환경을 위한 `aps-environment` 권한 설정을 추가했습니다. * **refactor: iOS 빌드 설정 및 불필요한 파일 제거** * `NativeBuildKonfig.kt`: 사용되지 않는 iOS 전용 Konfig 객체를 삭제했습니다. * `iosApp.xcodeproj`: 디버그 빌드 시 `CODE_SIGN_ENTITLEMENTS`를 지정하고, 관련 빌드 단계(Shell Script) 순서를 조정했습니다. * `NetworkBindings.kt`: 사용되지 않는 Ktor headers 관련 import를 제거했습니다. * refactor: DI 그래프 구조 개선 및 iOS 플랫폼 앱 델리게이트 로직 통합 * **refactor: CoroutineScope 제공 로직을 공통(commonMain) 영역으로 이동** * `AndroidAppGraph`에 정의되어 있던 `CoroutineScope` 제공 로직을 `FestabookAppGraph`로 이동하여 플랫폼 간 공통으로 사용하도록 변경했습니다. * **feat: iOS 플랫폼 DI 및 원격 알림 처리를 위한 기반 작업** * `IosAppGraph`에 `DeviceRepository` 의존성을 추가했습니다. * `AppDelegate.swift`에서 직접 처리하던 Firebase 및 네이버 지도 설정 로직을 KMP 공통 로직인 `DefaultFestabookAppDelegate`로 위임하도록 수정했습니다. * `didRegisterForRemoteNotificationsWithDeviceToken` 및 `didReceiveRemoteNotification` 콜백을 추가하여 iOS 원격 알림 처리 로직을 연결했습니다. * **chore: 불필요한 임포트 제거** * `iOSApp.swift` 내 사용되지 않는 `FirebaseCore` 임포트를 제거했습니다. * feat: 플랫폼별 축제 알림 등록 API 경로 동적 처리 * **feat: 축제 알림 API 엔드포인트 플랫폼 파라미터화** * `FestivalNotificationService`: 기존에 `android`로 고정되어 있던 API 경로를 `{platform}` 가변 경로로 변경하고, 요청 시 플랫폼 정보를 전달받도록 수정했습니다. * `FestivalNotificationRemoteDataSourceImpl`: 알림 저장 요청 시 `festivalNotificationPlatform()`을 호출하여 현재 플랫폼에 맞는 경로로 요청을 보내도록 개선했습니다. * **feat: 플랫폼별 식별자 반환을 위한 expect/actual 함수 추가** * `commonMain`: 플랫폼별 문자열을 반환하는 `festivalNotificationPlatform()` expect 함수를 정의했습니다. * `androidMain` / `iosMain`: 각 플랫폼에 대응하는 "android" 및 "ios" 문자열을 반환하도록 actual 함수를 구현했습니다. * feat: iOS 플랫폼 AppDelegate 및 Firebase Messaging 대리자 구현 * **feat: iOS 앱 생명주기 및 DI 관리를 위한 `FestabookAppDelegate` 추가** * `DefaultFestabookAppDelegate` 싱글톤 객체를 통해 `IosAppGraph` 및 앱 초기화 로직(Firebase, Naver Map 설정)을 통합 관리합니다. * Swift 코드와의 상호운용성을 위해 `FestabookAppDelegate`, `AppGraphDelegate` 인터페이스를 정의했습니다. * **feat: iOS 알림 및 FCM 연동 기능 구현** * `DefaultFirebaseMessagingDelegate`: FCM 등록 토큰 수신 시 `DeviceRepository`를 통해 서버에 기기를 등록하는 로직을 구현했습니다. * `DefaultUserNotificationDelegate`: 포그라운드 상태에서의 알림 표시 및 소리 옵션을 제어하는 `UNUserNotificationCenterDelegateProtocol`을 구현했습니다. * **refactor: iOS DI 그래프 참조 방식 변경** * `RememberAppGraph.ios.kt`에서 매번 `createGraph()`를 호출하던 방식에서 `DefaultFestabookAppDelegate`에 싱글톤으로 생성된 인스턴스를 참조하도록 수정하여 일관성을 확보했습니다. * refactor: Android 및 iOS 진입점의 `FestabookScreen` 호출 주석 처리 및 미사용 코드 정리 * **refactor: Android 및 iOS 플랫폼 공통 UI 호출 로직 수정** * `MainActivity` 및 `MainViewController`에서 `FestabookScreen` 호출부를 주석 처리했습니다. * iOS `MainViewController`에서 불필요한 `exit(0)` 관련 임포트를 제거했습니다. * **refactor: 플랫폼별 의존성 주입(DI) 그래프 정리** * `AndroidAppGraph.kt`: 사용하지 않는 `CoroutineScope`, `Dispatchers`, `SingleIn` 등의 임포트를 제거했습니다. * `IosAppGraph.kt`: 사용하지 않는 `ExploreRepository`, `FestivalNotificationRepository` 임포트를 제거했습니다. * **feat: Android `MainActivity` 새 인스턴스 생성 로직 추가** * `MainActivity` 내부에 `FLAG_ACTIVITY_NEW_TASK` 및 `FLAG_ACTIVITY_CLEAR_TASK` 플래그를 설정하여 액티비티를 새로 시작하는 `newIntent` companion object 메서드를 추가했습니다. * `MainActivity`에서 더 이상 사용되지 않는 `metroVmf` 변수를 제거했습니다. * feat: iOS FCM 알림 수신 및 백그라운드 처리 로직 보완 * **feat: `DefaultFestabookAppDelegate` 알림 처리 로직 추가** * 백그라운드 알림 수신 시 호출되는 `application` 메서드에 `UIBackgroundFetchResultNewData`를 반환하도록 수정하여 수신 처리를 명확히 했습니다. * `platform.UIKit.registerForRemoteNotifications` 임포트를 추가했습니다. * **fix: `AppDelegate.swift` 타입 캐스팅 및 안정성 개선** * `fetchCompletionHandler` 호출 시 결과를 강제 캐스팅(`as!`)하던 방식을 `UIBackgroundFetchResult` 생성자를 통한 안전한 변환 방식으로 수정했습니다. * **chore: iOS 플랫폼별 델리게이트 주석 추가** * `DefaultFirebaseMessagingDelegate`: FCM 토큰 등록 섹션에 주석을 추가했습니다. * `DefaultUserNotificationDelegate`: 포그라운드 알림 수신 섹션에 주석을 추가했습니다. * refactor: FestivalNotificationService 패키지 이동 및 API 요청 로직 개선 * **refactor: FestivalNotificationService 관련 구조 개선 및 오타 수정** * `FestivalNotificationService` 인터페이스를 `com.daedan.festabook.data.service.platform`에서 `com.daedan.festabook.data.service` 패키지로 이동했습니다. * `saveFestivalNotification` API 엔드포인트 경로의 중괄호 오타(`{platform}}` -> `{platform}`)를 수정했습니다. * `FestivalNotificationRemoteDataSourceTest`에서 `saveFestivalNotification` 호출 시 플랫폼 파라미터를 전달하도록 수정했습니다. * **refactor: DeviceRepositoryImpl 기기 등록 로직 단순화** * `registerDevice` 메서드에서 FCM 토큰 존재 여부를 확인하던 조건부 로직을 제거하고, 전달받은 토큰을 항상 저장 및 사용하도록 로직을 단순화했습니다. * refactor: applicationScope 의존성 주입 방식으로 변경 * `FestabookApp`에서 직접 생성하던 `applicationScope`를 `@Inject`를 통해 주입받도록 수정하여 의존성 관리를 개선했습니다. * fix: Android RememberDeepLinkHandler 내 ComponentActivity 형변환 예외 방지 * LocalContext를 ComponentActivity로 캐스팅할 때 안전한 캐스팅(`as?`)과 조기 반환을 적용하여 발생 가능한 `ClassCastException`을 방지하고 안정성을 높였습니다. * refactor: Android 및 iOS 플랫폼 코드 내 미사용 임포트 제거 * `DefaultFestabookAppDelegate.kt` (iOS): 사용하지 않는 `platform.Foundation.NSLog` 임포트 제거 * `FestabookApp.kt` (Android): 사용하지 않는 `kotlinx.coroutines.Dispatchers` 및 `kotlinx.coroutines.SupervisorJob` 임포트 제거 --- composeApp/build.gradle.kts | 3 + .../src/androidMain/AndroidManifest.xml | 8 ++ .../com/daedan/festabook/FestabookApp.kt | 48 ++++++++++ .../com/daedan/festabook/MainActivity.kt | 12 ++- .../FestivalNotificationEndpoint.android.kt | 3 + .../data/util/UUIDExtension.android.kt | 5 ++ .../daedan/festabook/di/AndroidAppGraph.kt | 10 +-- .../RememberDeepLinkHandler.android.kt | 33 +++++++ .../service/MyFirebaseMessagingService.kt | 27 ++++++ .../festabook/service/NotificationHelper.kt | 75 ++++++++++++++++ .../ic_festabook_logo_notification_large.xml | 0 .../ic_festabook_logo_notification_small.xml | 0 .../src/androidMain/res/values/strings.xml | 4 + ...estivalNotificationRemoteDataSourceImpl.kt | 2 + .../data/repository/DeviceRepositoryImpl.kt | 35 +++++--- .../service/FestivalNotificationService.kt | 3 +- .../platform/FestivalNotificationPlatform.kt | 3 + .../festabook/data/util/UUIDExtension.kt | 3 + .../daedan/festabook/di/FestabookAppGraph.kt | 12 ++- .../daedan/festabook/di/NetworkBindings.kt | 12 +-- .../domain/repository/DeviceRepository.kt | 5 +- .../platform/RememberDeepLinkHandler.kt | 14 +++ ...estivalNotificationRemoteDataSourceTest.kt | 3 + .../festabook/DefaultFestabookAppDelegate.kt | 62 +++++++++++++ .../daedan/festabook/MainViewController.kt | 3 - .../com/daedan/festabook/NativeBuildKonfig.kt | 5 -- .../FestivalNotificationEndpoint.ios.kt | 3 + .../festabook/data/util/UUIDExtension.ios.kt | 5 ++ .../festabook/delegate/AppGraphDelegate.kt | 7 ++ .../DefaultFirebaseMessagingDelegate.kt | 31 +++++++ .../DefaultUserNotificationDelegate.kt | 25 ++++++ .../delegate/FestabookAppDelegate.kt | 28 ++++++ .../com/daedan/festabook/di/IosAppGraph.kt | 3 + .../platform/RememberAppGraph.ios.kt | 5 +- .../platform/RememberDeepLinkHandler.ios.kt | 34 +++++++ gradle/libs.versions.toml | 9 +- iosApp/Podfile | 10 +-- iosApp/Podfile.lock | 15 +++- iosApp/iosApp.xcodeproj/project.pbxproj | 88 ++++++++++--------- iosApp/iosApp/AppDelegate.swift | 22 +++-- iosApp/iosApp/Info.plist | 8 +- iosApp/iosApp/iOSApp.swift | 1 - iosApp/iosApp/iosAppDebug.entitlements | 8 ++ 43 files changed, 591 insertions(+), 101 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt rename composeApp/src/{commonMain/composeResources => androidMain/res}/drawable/ic_festabook_logo_notification_large.xml (100%) rename composeApp/src/{commonMain/composeResources => androidMain/res}/drawable/ic_festabook_logo_notification_small.xml (100%) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt delete mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/NativeBuildKonfig.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/AppGraphDelegate.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/FestabookAppDelegate.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt create mode 100644 iosApp/iosApp/iosAppDebug.entitlements diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3f7fa296..0fc509f2 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -75,6 +75,8 @@ kotlin { pod("NMapsMap") pod("FirebaseCrashlytics") pod("FirebaseAnalytics") + pod("FirebaseMessaging") + pod("FirebaseCore") } androidTarget { compilerOptions { @@ -106,6 +108,7 @@ kotlin { implementation(project.dependencies.platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics.ndk) implementation(libs.firebase.analytics) + implementation(libs.firebase.messaging) } commonMain.dependencies { implementation(libs.compose.navigationevent) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 13e5f823..7ede7356 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -22,6 +22,14 @@ + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt index 7d561e9c..4f1d6158 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt @@ -2,17 +2,65 @@ package com.daedan.festabook import android.app.Application import com.daedan.festabook.di.AndroidAppGraph +import com.daedan.festabook.domain.repository.DeviceRepository +import com.daedan.festabook.service.NotificationHelper +import com.google.firebase.Firebase +import com.google.firebase.crashlytics.crashlytics +import com.google.firebase.messaging.FirebaseMessaging import com.naver.maps.map.NaverMapSdk +import dev.zacsweers.metro.Inject import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch class FestabookApp : Application() { val androidAppGraph by lazy { createGraphFactory().create(this) } + @Inject + private lateinit var firebaseMessaging: FirebaseMessaging + + @Inject + private lateinit var deviceRepository: DeviceRepository + + @Inject + private lateinit var applicationScope: CoroutineScope + override fun onCreate() { + androidAppGraph.inject(this) super.onCreate() setupNaverSdk() + sendUnsentReports() + setupNotificationChannel() + setupDeviceIdentifiers() + } + + private fun sendUnsentReports() { + Firebase.crashlytics.sendUnsentReports() + } + + private fun setupNotificationChannel() { + runCatching { + NotificationHelper.createNotificationChannel(this) + }.onSuccess { +// Timber.d("알림 채널 설정 완료") + }.onFailure { e -> +// Timber.e(e, "FestabookApp: 알림 채널 설정 실패 ${e.message}") + } + } + + private fun setupDeviceIdentifiers() { + firebaseMessaging + .token + .addOnSuccessListener { token -> + applicationScope.launch { + deviceRepository.registerDevice(token) + // Timber.d("📡 FCM 토큰 저장: $token") + } + }.addOnFailureListener { + // Timber.w(it, "❌ FCM 토큰 수신 실패") + } } private fun setupNaverSdk() { diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt index 7e920f8b..8077c50f 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt @@ -1,5 +1,7 @@ package com.daedan.festabook +import android.content.Context +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -31,9 +33,6 @@ class MainActivity : ComponentActivity() { setContent { FestabookTheme { -// FestabookScreen( -// onAppFinish = ::finish, -// ) val snackbarHostState = remember { SnackbarHostState() } val snackbarManager = rememberAppSnackbarManager(snackbarHostState) val openAppSettings = rememberOpenAppSettings() @@ -63,6 +62,13 @@ class MainActivity : ComponentActivity() { } } } + + companion object { + fun newIntent(context: Context) = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } } @Preview diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt new file mode 100644 index 00000000..765a574e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.data.service.platform + +actual fun festivalNotificationPlatform(): String = "android" diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.android.kt new file mode 100644 index 00000000..fd1e094f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.android.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.data.util + +import java.util.UUID + +actual fun randomUUID(): String = UUID.randomUUID().toString() diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt index a6441a82..6304d0fa 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt @@ -6,13 +6,10 @@ import com.daedan.festabook.FestabookApp import com.daedan.festabook.presentation.splash.platform.AppVersionManager import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.firebase.messaging.FirebaseMessaging import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.Provides -import dev.zacsweers.metro.SingleIn -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob @DependencyGraph(AppScope::class) interface AndroidAppGraph : FestabookAppGraph { @@ -25,12 +22,13 @@ interface AndroidAppGraph : FestabookAppGraph { ): AndroidAppGraph } + fun inject(app: FestabookApp) + @Provides fun provideAppUpdateManager(application: Application): AppUpdateManager = AppUpdateManagerFactory.create(application) @Provides - @SingleIn(AppScope::class) - fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun provideFirebaseMessaging(): FirebaseMessaging = FirebaseMessaging.getInstance() } val Context.androidAppGraph get() = (applicationContext as FestabookApp).androidAppGraph diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt new file mode 100644 index 00000000..043aa0dc --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt @@ -0,0 +1,33 @@ +package com.daedan.festabook.presentation.platform + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.core.util.Consumer + +actual typealias Intent = Intent + +@Composable +actual fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { + val context = LocalContext.current + val activity = context as? ComponentActivity ?: return + val currentOnDeepLink by rememberUpdatedState(onDeepLink) + + LaunchedEffect(Unit) { + currentOnDeepLink(activity.intent) + } + + DisposableEffect(activity) { + val listener = + Consumer { intent -> + currentOnDeepLink(intent) + } + activity.addOnNewIntentListener(listener) + onDispose { activity.removeOnNewIntentListener(listener) } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt new file mode 100644 index 00000000..9086c655 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.service + +import com.daedan.festabook.R +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +class MyFirebaseMessagingService : FirebaseMessagingService() { + override fun onNewToken(token: String) { +// Timber.d("Refreshed token: $token") + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { +// Timber.d("From: ${remoteMessage.from}") + + // remoteMessage.data + if (remoteMessage.data.isNotEmpty()) { +// Timber.d("Data Payload: ${remoteMessage.data}") + val title = + remoteMessage.data["title"] ?: getString(R.string.default_notification_title) + val content = + remoteMessage.data["body"] ?: getString(R.string.default_notification_body) + val noticeIdToExpand = remoteMessage.data["announcementId"] ?: "-1" + + NotificationHelper.showNotification(this, title, content, noticeIdToExpand) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt new file mode 100644 index 00000000..f8729c8f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt @@ -0,0 +1,75 @@ +package com.daedan.festabook.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.NotificationCompat +import com.daedan.festabook.MainActivity +import com.daedan.festabook.R +import com.daedan.festabook.presentation.platform.DeepLinkKeys +import com.daedan.festabook.presentation.theme.FestabookColorPalette +import com.daedan.festabook.vectorToBitmap + +object NotificationHelper { + private const val CHANNEL_ID = "notice_channel" + private const val CHANNEL_NAME = "공지사항" + + // Android 8.0에서 알림 채널을 생성 + fun createNotificationChannel(context: Context) { + val channel = + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "앱 공지사항 알림 채널" + } + + val notificationManager = + context.getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + // 실제 알림을 생성 + fun showNotification( + context: Context, + title: String, + content: String, + announcementId: String, + ) { + val intent = + MainActivity.newIntent(context).apply { + putExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, true) + putExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, announcementId.toLongOrNull()) + } + + val pendingIntent = + PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notificationBuilder = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_festabook_logo_notification_small) + .setColor(FestabookColorPalette().white.toArgb()) + .setContentTitle(title) + .setContentText(content) + .setLargeIcon( + vectorToBitmap(context, R.drawable.ic_festabook_logo_notification_large), + ).setStyle(NotificationCompat.BigTextStyle().bigText(content)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build()) + } +} diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_festabook_logo_notification_large.xml b/composeApp/src/androidMain/res/drawable/ic_festabook_logo_notification_large.xml similarity index 100% rename from composeApp/src/commonMain/composeResources/drawable/ic_festabook_logo_notification_large.xml rename to composeApp/src/androidMain/res/drawable/ic_festabook_logo_notification_large.xml diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_festabook_logo_notification_small.xml b/composeApp/src/androidMain/res/drawable/ic_festabook_logo_notification_small.xml similarity index 100% rename from composeApp/src/commonMain/composeResources/drawable/ic_festabook_logo_notification_small.xml rename to composeApp/src/androidMain/res/drawable/ic_festabook_logo_notification_small.xml diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 71a8245e..b28b6ede 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -1,3 +1,7 @@ Festabookkmp + + + 알림 + 새로운 소식이 있습니다. \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt index b7f98778..4fa70238 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt @@ -4,6 +4,7 @@ import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse import com.daedan.festabook.data.service.FestivalNotificationService +import com.daedan.festabook.data.service.platform.festivalNotificationPlatform import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @@ -20,6 +21,7 @@ class FestivalNotificationRemoteDataSourceImpl( ApiResult.toApiResult { festivalNotificationService.saveFestivalNotification( festivalId, + festivalNotificationPlatform(), FestivalNotificationRequest(deviceId = deviceId), ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt index 2cb51917..3b8c0c63 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.data.repository import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource import com.daedan.festabook.data.datasource.local.FcmDataSource import com.daedan.festabook.data.datasource.remote.device.DeviceRemoteDataSource +import com.daedan.festabook.data.util.randomUUID import com.daedan.festabook.data.util.toResult import com.daedan.festabook.data.util.withTimeoutOrNullFallback import com.daedan.festabook.domain.repository.DeviceRepository @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn @ContributesBinding(AppScope::class) @@ -38,17 +40,28 @@ class DeviceRepositoryImpl( initialValue = null, ) - override suspend fun registerDevice( - deviceIdentifier: String, - fcmToken: String, - ): Result { - val response = - deviceRemoteDataSource - .registerDevice( - deviceIdentifier = deviceIdentifier, - fcmToken = fcmToken, - ).toResult() - return response.mapCatching { it.id } + override suspend fun registerDevice(fcmToken: String): Result { + fcmDataSource.saveFcmToken(fcmToken) + + val deviceIdentifier = + deviceLocalDataSource + .getUuid() + .firstOrNull() ?: randomUUID().also { uuid -> + deviceLocalDataSource.saveUuid(uuid) + // Timber.d("🆕 UUID 생성 및 저장: $uuid") + } + + return deviceRemoteDataSource + .registerDevice( + deviceIdentifier = deviceIdentifier, + fcmToken = fcmToken, + ).toResult() + .onSuccess { + saveDeviceId(it.id) + // Timber.d("기기 등록 성공! 서버에서 받은 ID: $id") + }.onFailure { + // Timber.e(throwable, "MainViewModel: 기기 등록 실패: ${throwable.message}") + }.map { Unit } } override suspend fun saveDeviceId(deviceId: Long) { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt index 64dbaf95..a62f7db6 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt @@ -9,9 +9,10 @@ import de.jensklingenberg.ktorfit.http.POST import de.jensklingenberg.ktorfit.http.Path interface FestivalNotificationService { - @POST("festivals/{festivalId}/notifications/android") + @POST("festivals/{festivalId}/notifications/{platform}") suspend fun saveFestivalNotification( @Path("festivalId") id: Long, + @Path("platform") platform: String, @Body request: FestivalNotificationRequest, ): Response diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt new file mode 100644 index 00000000..7633c63b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.data.service.platform + +expect fun festivalNotificationPlatform(): String diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.kt new file mode 100644 index 00000000..de58d3b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.data.util + +expect fun randomUUID(): String diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt index b38f12bf..abfbe258 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt @@ -1,10 +1,20 @@ package com.daedan.festabook.di import com.daedan.festabook.presentation.NotificationPermissionManager +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob interface FestabookAppGraph { val metroViewModelFactory: MetroViewModelFactory - val notificationPermissionManagerFactory: NotificationPermissionManager.Factory + + @Provides + @SingleIn(AppScope::class) + fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt index 2cefd11f..fec768d0 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt @@ -27,8 +27,11 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides +import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.header import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.KotlinxSerializationConverter import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json @@ -56,13 +59,12 @@ object NetworkBindings { json(json) register( ContentType.Text.JavaScript, - KotlinxSerializationConverter( - Json { - ignoreUnknownKeys = true - }, - ), + KotlinxSerializationConverter(json), ) } + install(DefaultRequest) { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } install(authPlugin.plugin) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/DeviceRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/DeviceRepository.kt index 08f2dfce..0912cf2e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/DeviceRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/DeviceRepository.kt @@ -1,10 +1,7 @@ package com.daedan.festabook.domain.repository interface DeviceRepository { - suspend fun registerDevice( - deviceIdentifier: String, - fcmToken: String, - ): Result + suspend fun registerDevice(fcmToken: String): Result suspend fun saveDeviceId(deviceId: Long) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt new file mode 100644 index 00000000..9fb28080 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt @@ -0,0 +1,14 @@ +package com.daedan.festabook.presentation.platform + +import androidx.compose.runtime.Composable + +object DeepLinkKeys { + const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" + const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" + const val INITIALIZED_ID = -1L +} + +expect class Intent + +@Composable +expect fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt index 64555902..e47ddccc 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt @@ -5,6 +5,7 @@ import com.daedan.festabook.data.datasource.remote.schedule.FAKE_HTTP_RESPONSE import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse import com.daedan.festabook.data.service.FestivalNotificationService +import com.daedan.festabook.data.service.platform.festivalNotificationPlatform import de.jensklingenberg.ktorfit.Response import dev.mokkery.answering.returns import dev.mokkery.everySuspend @@ -56,6 +57,7 @@ class FestivalNotificationRemoteDataSourceTest { everySuspend { festivalNotificationService.saveFestivalNotification( festivalId, + festivalNotificationPlatform(), request, ) } returns FAKE_SAVE_FESTIVAL_NOTIFICATION_HTTP_RESPONSE @@ -68,6 +70,7 @@ class FestivalNotificationRemoteDataSourceTest { verifySuspend { festivalNotificationService.saveFestivalNotification( festivalId, + festivalNotificationPlatform(), request, ) } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt new file mode 100644 index 00000000..8f294596 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt @@ -0,0 +1,62 @@ +package com.daedan.festabook + +import cocoapods.FirebaseCore.FIRApp +import cocoapods.FirebaseMessaging.FIRMessaging +import cocoapods.NMapsMap.NMFAuthManager +import com.daedan.festabook.delegate.DefaultFirebaseMessagingDelegate +import com.daedan.festabook.delegate.DefaultUserNotificationDelegate +import com.daedan.festabook.delegate.FestabookAppDelegate +import com.daedan.festabook.di.IosAppGraph +import dev.zacsweers.metro.createGraph +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import platform.Foundation.NSData +import platform.UIKit.UIApplication +import platform.UIKit.UIBackgroundFetchResult +import platform.UserNotifications.UNUserNotificationCenter +import kotlin.experimental.ExperimentalObjCName + +@OptIn(ExperimentalForeignApi::class, ExperimentalObjCName::class) +object DefaultFestabookAppDelegate : FestabookAppDelegate { + override val appGraph: IosAppGraph = createGraph() + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val firebaseMessagingDelegate by lazy { + DefaultFirebaseMessagingDelegate(appScope) + } + + private val userNotificationDelegate by lazy { + DefaultUserNotificationDelegate() + } + + override fun application( + application: UIApplication, + launchOptions: Map?, + ): Boolean { + FIRApp.configure() + NMFAuthManager.shared().ncpKeyId = BuildKonfig.NAVER_MAP_CLIENT_ID + FIRMessaging.messaging().delegate = firebaseMessagingDelegate + val center = UNUserNotificationCenter.currentNotificationCenter() + center.delegate = userNotificationDelegate + return true + } + + override fun application( + application: UIApplication, + deviceToken: NSData, + ) { + FIRMessaging.messaging().APNSToken = deviceToken + } + + // 백그라운드에서 실행 + override fun application( + application: UIApplication, + userInfo: Map, + fetchCompletionHandler: (UIBackgroundFetchResult) -> Unit, + ) { + // 이후 화면 이동 로직 처리 + fetchCompletionHandler(UIBackgroundFetchResult.UIBackgroundFetchResultNewData) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt index 43b26abd..d240f1ef 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt @@ -50,7 +50,4 @@ fun MainViewController() = ) } } -// FestabookScreen( -// onAppFinish = { exit(0) }, -// ) } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/NativeBuildKonfig.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/NativeBuildKonfig.kt deleted file mode 100644 index 7920c03f..00000000 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/NativeBuildKonfig.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.daedan.festabook - -object NativeBuildKonfig { - val NAVER_MAP_CLIENT_ID = BuildKonfig.NAVER_MAP_CLIENT_ID -} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt new file mode 100644 index 00000000..7082f987 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.data.service.platform + +actual fun festivalNotificationPlatform(): String = "ios" diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.ios.kt new file mode 100644 index 00000000..5bcc0530 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/util/UUIDExtension.ios.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.data.util + +import platform.Foundation.NSUUID + +actual fun randomUUID(): String = NSUUID.UUID().UUIDString diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/AppGraphDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/AppGraphDelegate.kt new file mode 100644 index 00000000..adb8c007 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/AppGraphDelegate.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.delegate + +import com.daedan.festabook.di.IosAppGraph + +interface AppGraphDelegate { + val appGraph: IosAppGraph +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt new file mode 100644 index 00000000..5bffa936 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt @@ -0,0 +1,31 @@ +package com.daedan.festabook.delegate + +import cocoapods.FirebaseMessaging.FIRMessaging +import cocoapods.FirebaseMessaging.FIRMessagingDelegateProtocol +import com.daedan.festabook.DefaultFestabookAppDelegate +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import platform.Foundation.NSLog +import platform.darwin.NSObject + +@OptIn(ExperimentalForeignApi::class) +class DefaultFirebaseMessagingDelegate( + private val scope: CoroutineScope, +) : NSObject(), + FIRMessagingDelegateProtocol { + private val deviceRepository = DefaultFestabookAppDelegate.appGraph.deviceRepository + + override fun messaging( + messaging: FIRMessaging, + didReceiveRegistrationToken: String?, + ) { + // FCM 토큰 등록 + didReceiveRegistrationToken?.let { + scope.launch { + val result = deviceRepository.registerDevice(it) + NSLog(result.toString()) + } + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt new file mode 100644 index 00000000..797d7a04 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.delegate + +import platform.UserNotifications.UNNotification +import platform.UserNotifications.UNNotificationPresentationOptionAlert +import platform.UserNotifications.UNNotificationPresentationOptionSound +import platform.UserNotifications.UNNotificationPresentationOptions +import platform.UserNotifications.UNUserNotificationCenter +import platform.UserNotifications.UNUserNotificationCenterDelegateProtocol +import platform.darwin.NSObject + +// 임시 +class DefaultUserNotificationDelegate : + NSObject(), + UNUserNotificationCenterDelegateProtocol { + override fun userNotificationCenter( + center: UNUserNotificationCenter, + willPresentNotification: UNNotification, + withCompletionHandler: (UNNotificationPresentationOptions) -> Unit, + ) { + // 포그라운드일 때 실행 + val userInfo = willPresentNotification.request.content.userInfo + val options = UNNotificationPresentationOptionAlert or UNNotificationPresentationOptionSound + withCompletionHandler(options) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/FestabookAppDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/FestabookAppDelegate.kt new file mode 100644 index 00000000..206bae73 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/FestabookAppDelegate.kt @@ -0,0 +1,28 @@ +package com.daedan.festabook.delegate + +import platform.Foundation.NSData +import platform.UIKit.UIApplication +import platform.UIKit.UIBackgroundFetchResult + +/** + * Swift 단에서 사용되는 메서드를 인터페이스로 따로 분리한 클래스입니다. + * Objc 클래스를 상속한 클래스는 스위프트에서 사용이 불가능하여 별도의 인터페이스로 분리합니다. + */ + +interface FestabookAppDelegate : AppGraphDelegate { + fun application( + application: UIApplication, + launchOptions: Map?, + ): Boolean = true + + fun application( + application: UIApplication, + deviceToken: NSData, + ) + + fun application( + application: UIApplication, + userInfo: Map, + fetchCompletionHandler: (UIBackgroundFetchResult) -> Unit, + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt index c2a73fd7..153af385 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.di import com.daedan.festabook.data.service.AppVersionService import com.daedan.festabook.data.service.createAppVersionService +import com.daedan.festabook.domain.repository.DeviceRepository import com.daedan.festabook.presentation.splash.platform.AppVersionManager import de.jensklingenberg.ktorfit.Ktorfit import dev.zacsweers.metro.AppScope @@ -12,6 +13,8 @@ import dev.zacsweers.metro.Provides interface IosAppGraph : FestabookAppGraph { val appVersionManager: AppVersionManager + val deviceRepository: DeviceRepository + @Provides fun provideAppVersionService(ktorfit: Ktorfit): AppVersionService = ktorfit.createAppVersionService() } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt index e1e3a0ab..90fdb42a 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberAppGraph.ios.kt @@ -2,9 +2,8 @@ package com.daedan.festabook.presentation.platform import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.daedan.festabook.DefaultFestabookAppDelegate import com.daedan.festabook.di.FestabookAppGraph -import com.daedan.festabook.di.IosAppGraph -import dev.zacsweers.metro.createGraph @Composable -actual fun rememberAppGraph(): FestabookAppGraph = remember { createGraph() } +actual fun rememberAppGraph(): FestabookAppGraph = remember { DefaultFestabookAppDelegate.appGraph } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt new file mode 100644 index 00000000..d21be1ec --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.Foundation.NSURL + +actual class Intent( + val url: NSURL, +) + +@Composable +actual fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { + val currentOnDeepLink by rememberUpdatedState(onDeepLink) + + DisposableEffect(Unit) { + val observer = + NSNotificationCenter.defaultCenter.addObserverForName( + name = "OpenURLNotification", // 커스텀 알림 이름 (AppDelegate에서 쏴줘야 함) + `object` = null, + queue = NSOperationQueue.mainQueue, + ) { notification -> + val url = notification?.userInfo?.get("url") as? NSURL ?: return@addObserverForName + currentOnDeepLink(Intent(url)) + } + + onDispose { + NSNotificationCenter.defaultCenter.removeObserver(observer) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b171b8b..624deb3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "8.11.2" android-compileSdk = "36" -android-minSdk = "24" +android-minSdk = "28" android-targetSdk = "36" appUpdateKtx = "2.1.0" androidx-activity = "1.12.2" @@ -36,6 +36,9 @@ composeNavigationEvent = "1.0.1" crashlytics = "3.0.6" firebaseBom = "34.0.0" googleServices = "4.4.4" +firebaseMessaging = "25.0.1" +firebaseMessagingKtx = "25.0.1" + [libraries] androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" } @@ -75,9 +78,7 @@ play-services-location = { module = "com.google.android.gms:play-services-locati firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } - - - +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging", version.ref = "firebaseMessaging" } map-sdk = { module = "com.naver.maps:map-sdk", version.ref = "mapSdk" } [plugins] diff --git a/iosApp/Podfile b/iosApp/Podfile index 4def610d..df98ad2f 100644 --- a/iosApp/Podfile +++ b/iosApp/Podfile @@ -6,10 +6,10 @@ platform :ios, '17.0' target 'iosApp' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - - # Pods for iosApp - pod 'NMapsMap' - pod 'FirebaseCrashlytics' - pod 'FirebaseAnalytics' + pod 'NMapsMap' + pod 'FirebaseCrashlytics' + pod 'FirebaseAnalytics' + pod 'FirebaseMessaging' + pod 'FirebaseCore' end diff --git a/iosApp/Podfile.lock b/iosApp/Podfile.lock index e16180ad..3b471ef9 100644 --- a/iosApp/Podfile.lock +++ b/iosApp/Podfile.lock @@ -39,6 +39,15 @@ PODS: - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) + - FirebaseMessaging (12.7.0): + - FirebaseCore (~> 12.7.0) + - FirebaseInstallations (~> 12.7.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) - FirebaseRemoteConfigInterop (12.7.0) - FirebaseSessions (12.7.0): - FirebaseCore (~> 12.7.0) @@ -120,7 +129,9 @@ PODS: DEPENDENCIES: - FirebaseAnalytics + - FirebaseCore - FirebaseCrashlytics + - FirebaseMessaging - NMapsMap SPEC REPOS: @@ -131,6 +142,7 @@ SPEC REPOS: - FirebaseCoreInternal - FirebaseCrashlytics - FirebaseInstallations + - FirebaseMessaging - FirebaseRemoteConfigInterop - FirebaseSessions - GoogleAdsOnDeviceConversion @@ -150,6 +162,7 @@ SPEC CHECKSUMS: FirebaseCoreInternal: 571a2dd8c975410966199623351db3a3265c874d FirebaseCrashlytics: 9ce9ce4bb179e31ab91a937d0a73d302a9752d1b FirebaseInstallations: 6d05424a046b68ca146b4de4376f05b4e9262fc3 + FirebaseMessaging: b5f7bdc62b91b6102015991fb7bc6fa75f643908 FirebaseRemoteConfigInterop: 0bae7d21510b3a0a79af727961c4973aac585add FirebaseSessions: bc59e7d0ba90f96f40325fd1defbdec86ba26f20 GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f @@ -162,6 +175,6 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 -PODFILE CHECKSUM: 6a5bb667335a398cc3b70f78c63e6562bba25452 +PODFILE CHECKSUM: 3408dee9b9ba102f60ed2e9d0f7845f603c7e05a COCOAPODS: 1.16.2 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 952c27c7..3904e6db 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,14 +7,14 @@ objects = { /* Begin PBXBuildFile section */ - 16DE98C222044EEB49FDBF6A /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2201D4329717E54D7A131D01 /* Pods_iosApp.framework */; }; + EC87257A5AA124CF2A3C2B18 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 855378A5D29CC552A6E4D743 /* Pods_iosApp.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 2201D4329717E54D7A131D01 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 715130B6FFE2A7B0E186B7B0 /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; - B8BCC787F9096E9D0A4FA367 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; + 820F350A1443CEFD9CFD725C /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; + 855378A5D29CC552A6E4D743 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DA1BDCF16C59B3496748A3AF /* Festabookkmp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Festabookkmp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E18E71FF9DA441A898F4ADFD /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -38,6 +38,8 @@ }; 8E6C8D378651BF8144E66D20 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Configuration; sourceTree = ""; }; @@ -48,7 +50,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 16DE98C222044EEB49FDBF6A /* Pods_iosApp.framework in Frameworks */, + EC87257A5AA124CF2A3C2B18 /* Pods_iosApp.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -66,29 +68,29 @@ AA6B4BFFBE21B04BAE042633 /* Pods */ = { isa = PBXGroup; children = ( - B8BCC787F9096E9D0A4FA367 /* Pods-iosApp.debug.xcconfig */, - 715130B6FFE2A7B0E186B7B0 /* Pods-iosApp.release.xcconfig */, + E18E71FF9DA441A898F4ADFD /* Pods-iosApp.debug.xcconfig */, + 820F350A1443CEFD9CFD725C /* Pods-iosApp.release.xcconfig */, ); path = Pods; sourceTree = ""; }; - D92D345B7FB3140D9C93B269 = { + B18F61A9FE67F7A0CE1C6E67 /* Frameworks */ = { isa = PBXGroup; children = ( - 8E6C8D378651BF8144E66D20 /* Configuration */, - 82A10FDF00A7F8537FA4736B /* iosApp */, - 98EF0CE0C58AB9EF3E7B94AD /* Products */, - AA6B4BFFBE21B04BAE042633 /* Pods */, - E65F83FCE5F8C4CFBF210765 /* Frameworks */, + 855378A5D29CC552A6E4D743 /* Pods_iosApp.framework */, ); + name = Frameworks; sourceTree = ""; }; - E65F83FCE5F8C4CFBF210765 /* Frameworks */ = { + D92D345B7FB3140D9C93B269 = { isa = PBXGroup; children = ( - 2201D4329717E54D7A131D01 /* Pods_iosApp.framework */, + 8E6C8D378651BF8144E66D20 /* Configuration */, + 82A10FDF00A7F8537FA4736B /* iosApp */, + 98EF0CE0C58AB9EF3E7B94AD /* Products */, + AA6B4BFFBE21B04BAE042633 /* Pods */, + B18F61A9FE67F7A0CE1C6E67 /* Frameworks */, ); - name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ @@ -98,14 +100,14 @@ isa = PBXNativeTarget; buildConfigurationList = 998CC3A54864A134052B84E5 /* Build configuration list for PBXNativeTarget "iosApp" */; buildPhases = ( - D512DFA25A168C847B29D525 /* [CP] Check Pods Manifest.lock */, + 45397E90AE128BF2330FA164 /* [CP] Check Pods Manifest.lock */, C08EE85BEF61373E7C695D8C /* Compile Kotlin Framework */, F1B46811A2A8CB470EB44A06 /* Sources */, 3ED20EC5BFA21FC0E7D100C7 /* Frameworks */, A504BDED7EC474099DAD9F64 /* Resources */, - 20E523B2847A296CA11BAE04 /* [CP] Embed Pods Frameworks */, 67E4A4402F6D7E2800C3FBD6 /* Copy Google Service plist */, 67E4A4352F6D707D00C3FBD6 /* Inititialize Firebase Crashlytics */, + 884269F5D9640B20E82AC683 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -164,21 +166,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 20E523B2847A296CA11BAE04 /* [CP] Embed Pods Frameworks */ = { + 45397E90AE128BF2330FA164 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; 67E4A4352F6D707D00C3FBD6 /* Inititialize Firebase Crashlytics */ = { @@ -222,46 +229,41 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nDEBUG_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-DEBUG.plist\"\nRELEASE_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-RELEASE.plist\"\n\n# 2. 앱이 실제로 참조하게 될 목적지 경로\nDESTINATION_PATH=\"${SRCROOT}/iosApp/GoogleService-Info.plist\"\n\n# 3. 빌드 구성(Configuration)에 따라 복사 실행\nif [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n echo \"복사 중: Debug용 Firebase 설정...\"\n cp \"${DEBUG_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nelse\n echo \"복사 중: Release용 Firebase 설정...\"\n cp \"${RELEASE_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nfi\n"; }; - C08EE85BEF61373E7C695D8C /* Compile Kotlin Framework */ = { + 884269F5D9640B20E82AC683 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); - name = "Compile Kotlin Framework"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; - D512DFA25A168C847B29D525 /* [CP] Check Pods Manifest.lock */ = { + C08EE85BEF61373E7C695D8C /* Compile Kotlin Framework */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", ); - name = "[CP] Check Pods Manifest.lock"; + name = "Compile Kotlin Framework"; outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -278,16 +280,17 @@ /* Begin XCBuildConfiguration section */ 94BDB26E51A3BD8821288379 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B8BCC787F9096E9D0A4FA367 /* Pods-iosApp.debug.xcconfig */; + baseConfigurationReference = E18E71FF9DA441A898F4ADFD /* Pods-iosApp.debug.xcconfig */; buildSettings = { ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/iosAppDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -304,6 +307,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = eoehdeksruf.festabook.debug; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -370,7 +374,7 @@ }; A71446CCDF09E963D579622B /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 715130B6FFE2A7B0E186B7B0 /* Pods-iosApp.release.xcconfig */; + baseConfigurationReference = 820F350A1443CEFD9CFD725C /* Pods-iosApp.release.xcconfig */; buildSettings = { ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 0a05496a..3d35b101 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -1,14 +1,26 @@ -import FirebaseCore import ComposeApp -import NMapsMap +import UIKit class AppDelegate: NSObject, UIApplicationDelegate { + private lazy var festabookAppDelegate = DefaultFestabookAppDelegate.shared + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { - FirebaseApp.configure() - NMFAuthManager.shared().ncpKeyId = NativeBuildKonfig.shared.NAVER_MAP_CLIENT_ID - return true + return festabookAppDelegate.application(application: application, launchOptions: launchOptions) + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + festabookAppDelegate.application(application: application, deviceToken: deviceToken) + } + + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + festabookAppDelegate.application(application: application, userInfo: userInfo) { result in + fetchCompletionHandler(UIBackgroundFetchResult(rawValue: UInt(result)) ?? .noData) + } } } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index e070a884..53c8d262 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -13,12 +13,18 @@ CFBundleVersion 20100 - + CADisableMinimumFrameDurationOnPhone LSApplicationQueriesSchemes itms-apps + UIBackgroundModes + + remote-notification + + FirebaseAppDelegateProxyEnabled + diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index d2d4ae69..aa6a303c 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,5 +1,4 @@ import SwiftUI -import FirebaseCore @main struct iOSApp: App { diff --git a/iosApp/iosApp/iosAppDebug.entitlements b/iosApp/iosApp/iosAppDebug.entitlements new file mode 100644 index 00000000..903def2a --- /dev/null +++ b/iosApp/iosApp/iosAppDebug.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + From e0393991d579061168b9a3a8f5ebce29d00cb861 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:44:19 +0900 Subject: [PATCH 06/54] =?UTF-8?q?[Chore]=20Napier=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Napier 로깅 라이브러리 추가 및 Android 초기화 설정 * **chore: Napier 라이브러리 의존성 추가** * `libs.versions.toml`에 Napier(v2.7.1)를 정의하고, `commonMain` 소스셋에 의존성을 추가했습니다. * **feat: Android 플랫폼 내 Napier 초기화 로직 구현** * `FestabookApp` 클래스에 디버그 빌드(`BuildConfig.DEBUG`) 시에만 로그가 출력되도록 `DebugAntilog`를 설정하는 `setupNapier()` 메서드를 추가했습니다. # Conflicts: # composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt * fix: iOS `AppDelegate` 원격 알림 결과 처리 타입 변환 로직 수정 * `didReceiveRemoteNotification` 메서드 내에서 `UIBackgroundFetchResult` 생성 시 `result.value.uint64Value`를 참조하도록 수정하여 타입 변환 로직을 보완했습니다. * style: FestabookApp.kt 내 import 구문 정렬 * `FestabookApp.kt` 파일의 import 문 순서를 정렬하여 코드를 정리했습니다. --- composeApp/build.gradle.kts | 1 + .../kotlin/com/daedan/festabook/FestabookApp.kt | 9 +++++++++ .../com/daedan/festabook/DefaultFestabookAppDelegate.kt | 9 +++++++++ gradle/libs.versions.toml | 2 ++ iosApp/iosApp/AppDelegate.swift | 2 +- 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0fc509f2..027ccd93 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -111,6 +111,7 @@ kotlin { implementation(libs.firebase.messaging) } commonMain.dependencies { + implementation(libs.napier) implementation(libs.compose.navigationevent) implementation(libs.compose.navigation) implementation(libs.coil.compose) diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt index 4f1d6158..c16d2780 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt @@ -10,6 +10,8 @@ import com.google.firebase.messaging.FirebaseMessaging import com.naver.maps.map.NaverMapSdk import dev.zacsweers.metro.Inject import dev.zacsweers.metro.createGraphFactory +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -31,6 +33,7 @@ class FestabookApp : Application() { androidAppGraph.inject(this) super.onCreate() setupNaverSdk() + setupNapier() sendUnsentReports() setupNotificationChannel() setupDeviceIdentifiers() @@ -67,4 +70,10 @@ class FestabookApp : Application() { NaverMapSdk.getInstance(this).client = NaverMapSdk.NcpKeyClient(BuildKonfig.NAVER_MAP_CLIENT_ID) } + + private fun setupNapier() { + if (BuildConfig.DEBUG) { + Napier.base(DebugAntilog()) + } + } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt index 8f294596..eb68eb1c 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt @@ -8,6 +8,8 @@ import com.daedan.festabook.delegate.DefaultUserNotificationDelegate import com.daedan.festabook.delegate.FestabookAppDelegate import com.daedan.festabook.di.IosAppGraph import dev.zacsweers.metro.createGraph +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,6 +38,7 @@ object DefaultFestabookAppDelegate : FestabookAppDelegate { launchOptions: Map?, ): Boolean { FIRApp.configure() + setupNapier() NMFAuthManager.shared().ncpKeyId = BuildKonfig.NAVER_MAP_CLIENT_ID FIRMessaging.messaging().delegate = firebaseMessagingDelegate val center = UNUserNotificationCenter.currentNotificationCenter() @@ -59,4 +62,10 @@ object DefaultFestabookAppDelegate : FestabookAppDelegate { // 이후 화면 이동 로직 처리 fetchCompletionHandler(UIBackgroundFetchResult.UIBackgroundFetchResultNewData) } + + private fun setupNapier() { + if (BuildKonfig.BUILD_FLAVOR == "dev") { + Napier.base(DebugAntilog()) + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 624deb3a..3a843042 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ ktlintVersion = "0.5.0" mokkery = "3.2.0" landscapistCoil3 = "2.9.5" mapSdk = "3.22.1" +napier = "2.7.1" playServicesLocation = "21.3.0" composeNavigationEvent = "1.0.1" crashlytics = "3.0.6" @@ -74,6 +75,7 @@ landscapist-zoomable = { module = "com.github.skydoves:landscapist-zoomable", ve compottie = { module = "io.github.alexzhirkevich:compottie", version.ref = "compottie" } ktlint = { module = "io.nlopez.compose.rules:ktlint", version.ref = "ktlintVersion" } metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" } +napier = { module = "io.github.aakira:napier", version.ref = "napier" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 3d35b101..e389672f 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -20,7 +20,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void) { festabookAppDelegate.application(application: application, userInfo: userInfo) { result in - fetchCompletionHandler(UIBackgroundFetchResult(rawValue: UInt(result)) ?? .noData) + fetchCompletionHandler(UIBackgroundFetchResult(rawValue: UInt(result.value.uint64Value)) ?? .noData) } } } From f1d5ac1cf40d0d2d79e8fdacc32a900ef2e4d913 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:46:10 +0900 Subject: [PATCH 07/54] =?UTF-8?q?[Feat]=20MainScreen=20CMP=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=8F=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 플랫폼 공통 네비게이션 그래프 구현 및 모듈화 * **feat: 신규 네비게이션 그래프 정의** * `HomeNavigation.kt`: `homeNavGraph`를 추가하여 홈 화면 진입 시 첫 방문 팝업(`FirstVisitDialog`) 노출 로직 및 `HomeScreen` 연결을 구현했습니다. * `MainNavigation.kt`: `mainNavGraph`를 추가하여 메인 화면의 하위 탭들에서 공유할 ViewModel들을 `mainBackEntry` 범위로 초기화하고 `MainScreen`으로 전달하도록 구성했습니다. * `PlaceMapNavigation.kt`: `placeMapNavGraph`를 추가하고, `PlaceDetail` 이동 시 `PlaceUiModel` 등을 전달하기 위한 커스텀 `NavType`과 화면 전환 애니메이션(슬라이드 및 페이드)을 적용했습니다. * **refactor: 기존 네비게이션 코드 복구 및 정리** * `NewsNavigation.kt`, `SettingNavigation.kt`, `ScheduleNavigation.kt`: 주석 처리되어 있던 각 탭별 네비게이션 그래프 구현부를 복구하고 미사용 `@file:Suppress` 설정을 제거했습니다. * feat: 홈 화면 UI 구현 및 메인 내비게이션 구조 개선 * **feat: 홈 화면(Home) 주요 컴포넌트 추가** * `HomeScreen`: 축제 개요, 포스터 리스트, 라인업 정보를 포함한 메인 홈 화면을 구현했습니다. * `HomePosterList`: `HorizontalPager`를 이용해 축제 포스터를 무한 스크롤 및 스케일 애니메이션과 함께 표시하는 기능을 추가했습니다. * `HomeHeader` & `HomeFestivalInfo`: 대학교 이름 및 축제 상세 정보를 표시하는 헤더를 구현했습니다. * `HomeLineupItem` & `HomeArtistItem`: 일자별 아티스트 라인업을 가로 리스트 형태로 표시하도록 구현했습니다. * **feat: 메인 화면(Main) 및 공통 컴포넌트 구현** * `MainScreen`: 하단 탭 내비게이션과 각 화면별 `NavHost`를 통합 관리하는 메인 진입점을 구현했습니다. * `FestabookBottomNavigationBar`: 커스텀 디자인이 적용된 하단 탭 바를 추가했으며, 중앙 지도(PLACE_MAP) 탭에 특수 아이콘 및 레이아웃을 적용했습니다. * `FirstVisitDialog`: 앱 최초 방문 시 알림 권한 등을 안내하는 팝업 다이얼로그를 추가했습니다. * **refactor: ViewModel 의존성 주입 및 로직 개선** * `MainViewModel`: 기기 UUID 및 FCM 토큰 등록, 뒤로가기 이중 클릭 종료 처리, 최초 방문 여부 확인 로직을 추가했습니다. * `PlaceDetailViewModel`: Metro 라이브러리를 이용한 `@AssistedInject` 및 `ViewModelAssistedFactory` 구조를 적용하여 의존성 주입 방식을 개선했습니다. * `MetroViewModelFactoryImpl`: `assistedFactoryProviders`를 주입받도록 수정했습니다. * **fix: 기타 코드 정리** * `PlaceMapRoute` 내 불필요한 `@Suppress` 어노테이션을 제거했습니다. * feat: 홈 화면 페스티벌 정보 및 라인업 컴포넌트 구현 * **feat: 홈 화면 주요 UI 컴포넌트 추가** * `HomeHeader`: 대학교 이름 표시 및 확장 버튼을 포함한 헤더 구현. * `HomeFestivalInfo`: 페스티벌 이름과 기간 정보를 표시하는 컴포넌트 추가. * `HomePosterList`: `HorizontalPager`를 이용한 무한 스크롤 포스터 리스트 구현 (중앙 강조 애니메이션 포함). * `HomeLineupItem` & `HomeArtistItem`: 날짜별 아티스트 라인업 정보 및 개별 아티스트 아이템 레이아웃 구현. * `HomeLineupHeader`: 라인업 섹션 타이틀 및 일정 화면 이동 버튼 추가. * **feat: 홈 화면 비즈니스 로직 및 상태 관리 추가** * `HomeViewModel`: 페스티벌 정보 및 라인업 데이터를 로드하고 `UiState`를 관리하는 로직 구현. * `FestivalUiState` & `LineupUiState`: 로딩, 성공, 에러 상태를 정의하는 Sealed Interface 추가. * `LineupItemUiModel` & `LineUpItemOfDayUiModel`: 도메인 모델을 UI 표현식에 맞게 변환하는 Mapper 및 모델 정의. * **refactor: 플랫폼별 텍스트 스타일 대응** * `HomeTextStyle`: Android에서 폰트 패딩을 제거(`includeFontPadding = false`)하기 위해 `expect`/`actual` 프로퍼티 추가. * style: 공통 UI 아이콘 리소스 속성 정리 * **style: 벡터 아이콘의 불필요한 속성 제거** * `ic_home.xml`, `ic_news.xml`, `ic_schedule.xml`, `ic_setting.xml` 파일에서 `android:fillColor="?attr/colorControlNormal"` 속성을 제거했습니다. * 아이콘 테마 및 색상을 코드를 통해 동적으로 제어하기 위해 하드코딩된 속성을 정리했습니다. * `ic_schedule.xml`의 미사용 경로 데이터 및 속성을 정리하여 리소스 파일을 경량화했습니다. * chore: Compottie 라이브러리 버전 업데이트 * **chore: Compottie 의존성 버전 상향** * `libs.versions.toml` 파일에서 `compottie` 버전을 `2.0.3`에서 `2.1.0`으로 업데이트했습니다. * refactor: Metro 라이브러리를 활용한 의존성 주입 구조 개선 및 플랫폼 의존성 제거 * **refactor: `PlaceDetailViewModel` 의존성 주입 방식 변경** * Metro의 `assistedMetroViewModel`을 도입하여 `@AssistedInject` 기반의 ViewModel 생성을 구현했습니다. * `CreationExtras.Key`인 `PlaceKey`와 `PlaceDetailKey`를 정의하여 네비게이션 인자를 ViewModel에 안전하게 전달하도록 수정했습니다. * 기존의 수동 `Factory` 생성 로직을 제거하고 `MutableCreationExtras`를 사용하는 방식으로 개선했습니다. * **refactor: 네비게이션 커스텀 `NavType` 및 Android 의존성 제거** * `PlaceMapNavigation.kt`에서 `android.net.Uri` 및 `android.os.Bundle` 의존성을 제거했습니다. * Compose Multiplatform 환경에 맞게 `androidx.savedstate.SavedState`를 사용하여 데이터를 저장하고 읽어오도록 `defaultNavType` 구현을 수정했습니다. * `Uri.decode`/`encode` 대신 `kotlinx.serialization`을 이용한 직접적인 JSON 직렬화 방식을 적용했습니다. * **feat: Navigation 이벤트 및 백핸들러 개선** * `androidx.navigationevent` 라이브러리를 도입하여 `NavigationBackHandler` 및 `rememberNavigationEventState`를 적용했습니다. * 기존 `BackHandler`를 대체하여 보다 공통화된 네비게이션 백프레스 로직을 구현했습니다. * **chore: 코드 정리 및 구조 최적화** * `MainScreen`에서 더 이상 사용하지 않는 `placeDetailViewModelFactory` 주입 로직을 제거했습니다. * `MainViewModel`의 `registerDeviceAndFcmToken` 호출 등 불필요하게 남아있던 `LaunchedEffect` 및 임포트 구문을 정리했습니다. * feat: iOS 딥링크 처리 로직 구현 및 Kotlin Multiplatform 대응 리팩터링 * **feat: iOS 알림 기반 딥링크 처리 구현** * `DefaultUserNotificationDelegate`: iOS 포그라운드 및 백그라운드 알림 수신 시 `announcementId`를 추출하여 `NSNotificationCenter`를 통해 이벤트를 발행하도록 수정했습니다. * `RememberDeepLinkHandler.ios.kt`: `fcmNewsNotification` 이벤트를 구독하여 알림 상세 ID 및 이동 가능 여부를 포함한 `Intent` 객체를 생성하도록 구현했습니다. * **refactor: `MainViewModel` 로직 개선 및 의존성 정리** * `DeviceRepository` 의존성 및 FCM 토큰 수동 등록 로직을 제거했습니다. * `festivalRepository.getIsFirstVisit()`을 Flow 형태로 구독하여 실시간으로 최초 방문 상태를 반영하도록 수정했습니다. * `System.currentTimeMillis()` 대신 `kotlinx-datetime`의 `Clock`을 사용하도록 교체하여 플랫폼 독립성을 높였습니다. * **refactor: 공통 `Intent` 인터페이스 및 네비게이션 구조 개선** * `RememberDeepLinkHandler.kt`: `expect class Intent`에 `getLongExtra`, `getBooleanExtra` 메서드를 추가하여 플랫폼 공통 인터페이스를 확장했습니다. * `mainNavGraph`: 플랫폼별 위치 소스 구현을 위해 `FusedLocationSource` 타입을 추상화된 `LocationSource`로 변경했습니다. * `MainActivity.kt`: 개별 뷰모델 기반의 초기 진입 코드를 통합 `FestabookScreen`으로 대체하여 진입점 구조를 단순화했습니다. * **refactor: 홈 화면 UI 및 리소스 참조 방식 수정** * `HomeScreen`: `LocalContext` 및 Android 전용 리소스(`R.string`) 의존성을 제거하고, Compose Multiplatform의 `Res` 리소스를 사용하도록 변경했습니다. * 날짜 처리 로직을 `java.time`에서 `kotlinx-datetime`으로 마이그레이션했습니다. * `Organization` 모델의 속성명을 `universityName`에서 `organizationName`으로 수정했습니다. * **chore: 기타 변경 사항** * `FestaBookAppGraph`의 오타를 `FestabookAppGraph`로 수정했습니다. * feat: 메인 내비게이션 그래프 복구 및 API 인증 로직 개선 * **feat: 메인 화면 내비게이션 연결** * `FestabookScreen.kt`: 주석 처리되어 있던 `mainNavGraph` 호출부를 복구하여 앱 진입 시 메인 화면으로의 내비게이션 흐름을 활성화했습니다. * **refactor: API 인증 헤더의 축제 ID 동적 할당 로직 구현** * `FestaBookAuthPlugin.kt`: HTTP 요청 헤더(`festival`)에 삽입되는 축제 ID를 하드코딩된 값에서 `festivalLocalDataSource`를 통해 로컬 저장소의 값을 비동기적으로 가져오도록 수정했습니다. `firstOrNull()`을 사용하여 최신 상태의 ID를 동적으로 반영합니다. * fix: 알림 스위치 설정 버그, 플레이스 상세 화면 버그 해결 * **refactor: ViewModel 주입 방식 간소화** * `MainNavigation.kt`: `viewModel(mainBackEntry, factory = ...)` 형태의 복잡한 주입 방식을 `metroViewModel(mainBackEntry)`로 개선하여 가독성을 높였습니다. * `SettingScreen.kt`: 컴포넌트 내부에서 직접 생성하던 ViewModel을 외부에서 주입받도록 수정하여 의존성 구조를 명확히 했습니다. * **feat: 설정 화면 알림 권한 처리 로직 개선** * `SettingScreen`: 알림 권한이 허용(`GRANTED`)되었을 때, `saveNotificationId()`를 호출하여 서버에 기기 식별자를 저장하고 성공 메시지를 표시하도록 로직을 추가했습니다. * 기존 `success` Flow를 구독하여 스낵바를 띄우던 `ObserveAsEvents` 로직을 제거하고, 권한 상태 변화에 따른 직접 처리 방식으로 변경했습니다. * **fix: 네비게이션 커스텀 타입 직렬화 추가** * `PlaceMapNavigation.kt`: 커스텀 `NavType` 구현체에 `serializeAsValue` 메서드를 추가하여 객체를 안전하게 문자열로 변환하여 전달할 수 있도록 보완했습니다. * **chore: Android `MainActivity` 및 설정 코드 정리** * `MainActivity.kt`: 사용하지 않는 Import 문과 멤버 변수(`androidAppGraph`, `notificationPermissionManagerFactory`)를 제거했습니다. * refactor: iOS 진입점 구조 개선 및 공통 컴포넌트 레이아웃 수정 * **refactor: iOS `MainViewController` 구조 단순화** * 기존에 `MainViewController`에서 직접 관리하던 `SettingRoute`, `Scaffold`, `SnackbarHost` 로직을 제거했습니다. * 공통 엔트리포인트인 `FestabookScreen`을 호출하도록 변경하고, 앱 종료 콜백(`onAppFinish`)에 `exit(0)`를 적용했습니다. * **fix: `NaverMapLogo` 리렌더링 로직 수정 (iOS)** * `NaverMapLogo`의 `LaunchedEffect` 키에 `naverMap` 객체를 추가하여, 지도 인스턴스 변경 시 로고 위치 설정 로직이 정확히 재실행되도록 보완했습니다. * **style: `FestabookTopAppBar` 레이아웃 수정** * `FestabookTopAppBar`에서 `windowInsetsPadding(WindowInsets.statusBars)` 설정을 제거했습니다. 이는 상위 컨테이너에서 Insets 처리를 담당하도록 변경된 것에 따른 수정입니다. * refactor: 불필요한 임포트 제거 및 정적 분석 설정 업데이트 * **refactor: 미사용 임포트 및 의존성 정리** * `SettingScreen.kt`: 사용하지 않는 `metroViewModel` 임포트를 제거하고 파라미터 순서를 조정했습니다. * `MainNavigation.kt`: 사용하지 않는 `androidx.lifecycle.viewmodel.compose.viewModel` 임포트를 제거했습니다. * `RememberDeepLinkHandler.ios.kt`: iOS 소스셋 내 미사용 `NSURL` 임포트를 제거했습니다. * **chore: 정적 분석(ktlint) 예외 설정 추가** * `PlaceMapScreen.kt`: `PlaceMapRoute` 함수에서 ViewModel을 직접 전달하는 패턴에 대해 `ktlint:compose:vm-forwarding-check` 억제(Suppress) 어노테이션을 추가했습니다. * fix: 아이콘 XML fillColor 하드코딩 제거 및 tint 파라미터로 색상 제어 drawable XML에서 하드코딩된 #ffffff를 #000000(중립 기본색)으로 변경. Icon 컴포저블의 tint 파라미터(selectedColor/unselectedColor)가 실제 색상을 제어하도록 수정. Co-authored-by: YongJun Jung * fix: `FestaBookAuthPlugin` 내 Festival ID 유효성 검사 추가 * **fix: API 요청 시 Festival ID가 없을 경우의 예외 처리 추가** * `FestaBookAuthPlugin`에서 `festivalLocalDataSource`로부터 가져온 `festivalId`가 `null`인 경우, 헤더 설정 로직을 진행하지 않고 즉시 반환(`return@onRequest`)하도록 수정하여 비정상적인 요청 발생을 방지했습니다. * fix: iOS 앱 종료 로직 수정 * **fix: iOS `MainViewController` 내 앱 종료 콜백 수정** * `FestabookScreen`의 `onAppFinish` 콜백에서 호출하던 `exit(0)` 로직을 제거했습니다. 이는 iOS 앱 가이드라인에 따른 비정상 종료 방지 및 자연스러운 앱 상태 관리를 위한 수정입니다. * refactor: iOS 딥링크 처리 구조 개선 및 Android 초기화 로직 수정 * **refactor: iOS `Intent` 클래스 및 딥링크 처리 로직 유연화** * iOS 플랫폼의 `Intent` 클래스 구현을 고정 프로퍼티 방식에서 `Map` 기반의 `extras` 시스템으로 변경했습니다. * `getLongExtra`, `getBooleanExtra` 메서드가 `extras` 맵에서 값을 동적으로 조회하도록 수정했습니다. * 딥링크 알림 수신 시 `DeepLinkKeys`를 사용하여 데이터를 `Intent`에 전달하도록 로직을 보완했습니다. * **fix: Android `MainActivity` 호출 순서 수정** * `enableEdgeToEdge()` 호출 시점을 `super.onCreate()` 이후로 변경하여 초기화 로직의 안정성을 개선했습니다. * **style: `HomeScreen` 테마 컬러 적용** * `HomeScreen`의 `Scaffold` 배경색을 하드코딩된 `Color.White` 대신 공통 테마 정의인 `FestabookColor.white`로 변경했습니다. * refactor: PlaceDetailViewModel 내 미사용 주석 코드 제거 * **refactor: 미사용 ViewModel Factory 주석 삭제** * `PlaceDetailViewModel.kt` 파일에서 더 이상 사용하지 않고 주석 처리되어 있던 `companion object`와 `factory` 메서드 관련 코드를 제거하여 소스 코드를 정리했습니다. * refactor: iOS `MainViewController` 내 미사용 임포트 제거 * **refactor: 불필요한 POSIX exit 임포트 삭제** * `MainViewController.kt`에서 사용되지 않는 `platform.posix.exit` 임포트 구문을 제거하여 코드를 정리했습니다. * style: HomeScreen.kt 내 미사용 임포트 제거 * **style: 미사용 임포트 정리** * `HomeScreen.kt`: 사용하지 않는 `androidx.compose.ui.graphics.Color` 임포트 구문을 제거했습니다. --------- Co-authored-by: 박지민 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: YongJun Jung --- .../com/daedan/festabook/MainActivity.kt | 48 +-- .../home/component/HomeTextStyle.android.kt | 6 + .../composeResources/drawable/ic_home.xml | 2 +- .../composeResources/drawable/ic_news.xml | 2 +- .../composeResources/drawable/ic_schedule.xml | 16 +- .../composeResources/drawable/ic_setting.xml | 4 +- .../data/service/api/FestaBookAuthPlugin.kt | 6 +- .../di/viewmodel/MetroViewModelFactoryImpl.kt | 3 +- .../festabook/presentation/FestabookScreen.kt | 13 +- .../common/component/FestabookTopAppBar.kt | 4 - .../presentation/home/HomeViewModel.kt | 1 + .../home/component/HomeArtistItem.kt | 71 +++++ .../home/component/HomeFestivalInfo.kt | 48 +++ .../presentation/home/component/HomeHeader.kt | 72 +++++ .../home/component/HomeLineupHeader.kt | 79 +++++ .../home/component/HomeLineupItem.kt | 174 +++++++++++ .../home/component/HomePosterList.kt | 116 +++++++ .../presentation/home/component/HomeScreen.kt | 284 ++++++++++++++++++ .../home/component/HomeTextStyle.kt | 5 + .../home/navigation/HomeNavigation.kt | 42 +++ .../presentation/main/MainViewModel.kt | 72 +++++ .../component/FestabookBottomNavigationBar.kt | 169 +++++++++++ .../main/component/FirstVisitDialog.kt | 180 +++++++++++ .../presentation/main/component/MainScreen.kt | 245 +++++++++++++++ .../main/navigation/MainNavigation.kt | 34 +++ .../news/navigation/NewsNavigation.kt | 38 ++- .../placeDetail/PlaceDetailViewModel.kt | 31 +- .../placeMap/navigation/PlaceMapNavigation.kt | 94 ++++++ .../platform/RememberDeepLinkHandler.kt | 12 +- .../schedule/navigation/ScheduleNavigation.kt | 36 ++- .../setting/component/SettingScreen.kt | 14 +- .../setting/navigation/SettingNavigation.kt | 52 ++-- .../daedan/festabook/MainViewController.kt | 46 +-- .../DefaultUserNotificationDelegate.kt | 24 +- .../home/component/HomeTextStyle.ios.kt | 5 + .../placeMap/component/NaverMapLogo.kt | 2 +- .../platform/RememberDeepLinkHandler.ios.kt | 33 +- gradle/libs.versions.toml | 2 +- 38 files changed, 1875 insertions(+), 210 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomePosterList.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt index 8077c50f..b8f9c6ed 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/MainActivity.kt @@ -6,59 +6,21 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.daedan.festabook.presentation.common.component.FestabookSnackbar -import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager -import com.daedan.festabook.presentation.setting.SettingViewModel -import com.daedan.festabook.presentation.setting.component.SettingRoute -import com.daedan.festabook.presentation.setting.component.platform.rememberNotificationPermissionManager -import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings +import com.daedan.festabook.presentation.FestabookScreen import com.daedan.festabook.presentation.theme.FestabookTheme -import dev.zacsweers.metrox.viewmodel.metroViewModel import org.jetbrains.compose.ui.tooling.preview.Preview class MainActivity : ComponentActivity() { - private val androidAppGraph by lazy { (application as FestabookApp).androidAppGraph } - private val notificationPermissionManagerFactory by lazy { - androidAppGraph.notificationPermissionManagerFactory - } - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) + enableEdgeToEdge() setContent { FestabookTheme { - val snackbarHostState = remember { SnackbarHostState() } - val snackbarManager = rememberAppSnackbarManager(snackbarHostState) - val openAppSettings = rememberOpenAppSettings() - Scaffold(snackbarHost = { - SnackbarHost(hostState = snackbarHostState) { data -> - FestabookSnackbar(data) - } - }) { innerPadding -> - val viewModel = metroViewModel() - SettingRoute( - notificationPermissionManager = - rememberNotificationPermissionManager( - notificationPermissionManagerFactory = notificationPermissionManagerFactory, - onPermissionGrant = { - viewModel.saveNotificationId() - }, - onPermissionDeny = { - snackbarManager.showPermissionDeniedSnackbar( - openAppSettings, - ) - }, - ), - onShowSnackBar = {}, - onShowErrorSnackBar = {}, - ) - } + FestabookScreen( + onAppFinish = { finish() }, + ) } } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.android.kt new file mode 100644 index 00000000..f0867fb9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.android.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.ui.text.PlatformTextStyle + +actual val HomeTextStyle: PlatformTextStyle? + get() = PlatformTextStyle(includeFontPadding = false) diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_home.xml b/composeApp/src/commonMain/composeResources/drawable/ic_home.xml index 725d0d07..6ab1cc5d 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_home.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_home.xml @@ -6,8 +6,8 @@ diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_news.xml b/composeApp/src/commonMain/composeResources/drawable/ic_news.xml index f9a2b0bd..37c51cee 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_news.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_news.xml @@ -4,7 +4,7 @@ android:viewportWidth="25" android:viewportHeight="24"> diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_schedule.xml b/composeApp/src/commonMain/composeResources/drawable/ic_schedule.xml index 0ebd049b..1833e82d 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_schedule.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_schedule.xml @@ -4,28 +4,28 @@ android:viewportWidth="25" android:viewportHeight="24"> diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_setting.xml b/composeApp/src/commonMain/composeResources/drawable/ic_setting.xml index 56283999..3e493410 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_setting.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_setting.xml @@ -6,11 +6,11 @@ diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt index 9c2e67e7..b21018cf 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.data.service.api import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import dev.zacsweers.metro.Inject import io.ktor.client.plugins.api.createClientPlugin +import kotlinx.coroutines.flow.firstOrNull @Inject class FestaBookAuthPlugin( @@ -11,10 +12,7 @@ class FestaBookAuthPlugin( val plugin = createClientPlugin(name = "FestaBookAuthPlugin") { onRequest { request, _ -> -// val festivalId = festivalLocalDataSource.getFestivalId() -// 헤더가 필요한데 현재 탐색화면에서 id를 고를 수가 없어서 임시로 하드 코딩 해놨습니다!!! - val festivalId = 1 - + val festivalId = festivalLocalDataSource.getFestivalId().firstOrNull() ?: return@onRequest request.headers["festival"] = festivalId.toString() } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/viewmodel/MetroViewModelFactoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/viewmodel/MetroViewModelFactoryImpl.kt index e4d693a3..487a7e33 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/viewmodel/MetroViewModelFactoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/viewmodel/MetroViewModelFactoryImpl.kt @@ -7,6 +7,7 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Provider import dev.zacsweers.metro.SingleIn import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory import kotlin.reflect.KClass @Inject @@ -14,6 +15,6 @@ import kotlin.reflect.KClass @SingleIn(AppScope::class) class MetroViewModelFactoryImpl( override val viewModelProviders: Map, Provider>, -// override val assistedFactoryProviders: Map, Provider>, + override val assistedFactoryProviders: Map, Provider>, // override val manualAssistedFactoryProviders: Map, Provider>, ) : MetroViewModelFactory() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt index 58a98f0d..3f66903f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt @@ -7,6 +7,7 @@ import androidx.navigation.navOptions import com.daedan.festabook.di.FestabookAppGraph import com.daedan.festabook.presentation.explore.navigation.exploreNavGraph import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.navigation.mainNavGraph import com.daedan.festabook.presentation.main.rememberFestabookNavigator import com.daedan.festabook.presentation.platform.rememberAppGraph import com.daedan.festabook.presentation.platform.rememberAppVersionManager @@ -56,11 +57,11 @@ fun FestabookScreen( onBackClick = { festabookNavigator.popBackStack() }, onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main) }, ) -// mainNavGraph( -// appGraph = appGraph, -// onAppFinish = onAppFinish, -// locationSource = locationSource, -// festabookNavigator = festabookNavigator, -// ) + mainNavGraph( + appGraph = appGraph, + onAppFinish = onAppFinish, + locationSource = locationSource, + festabookNavigator = festabookNavigator, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt index 6e1bb067..fbaf30e8 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt @@ -1,10 +1,7 @@ package com.daedan.festabook.presentation.common.component -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,7 +22,6 @@ fun FestabookTopAppBar( modifier = modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) .padding( top = festabookSpacing.paddingTitleHorizontal, bottom = festabookSpacing.paddingBody4, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt index 1e97e294..3c5a34a1 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.presentation.home.FestivalUiState import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt new file mode 100644 index 00000000..0f14d9dd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -0,0 +1,71 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun HomeArtistItem( + artistName: String, + artistImageUrl: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.width(68.dp), + ) { + CoilImage( + url = artistImageUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(HomeArtistItem.ArtistImage) + .border(1.dp, FestabookColor.gray300, HomeArtistItem.ArtistImage), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = artistName, + style = FestabookTypography.labelLarge, + color = FestabookColor.gray700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private object HomeArtistItem { + val ArtistImage = + RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomEndPercent = 50, + bottomStartPercent = 5, + ) +} + +@Preview +@Composable +private fun HomeArtistItemPreview() { + HomeArtistItem( + artistName = "실리카겔", + artistImageUrl = "sample", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt new file mode 100644 index 00000000..2249864f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -0,0 +1,48 @@ +package com.daedan.festabook.presentation.home.component + +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun HomeFestivalInfo( + festivalName: String, + festivalDate: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp), + ) { + Text( + text = festivalName, + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = festivalDate, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeFestivalInfoPreview() { + HomeFestivalInfo( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + festivalDate = "2025년 10월 15일 - 10월 17일", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt new file mode 100644 index 00000000..24dc3c15 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -0,0 +1,72 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.home_navigate_to_explore_desc +import festabookkmp.composeapp.generated.resources.ic_dropdown +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun HomeHeader( + universityName: String, + onExpandClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + modifier = Modifier.clickable { onExpandClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = universityName, + style = + FestabookTypography.displayLarge.copy( + platformStyle = HomeTextStyle, + lineHeight = 34.sp, + ), + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + painter = painterResource(Res.drawable.ic_dropdown), + tint = FestabookColor.black, + contentDescription = stringResource(Res.string.home_navigate_to_explore_desc), + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeHeaderPreview() { + HomeHeader( + universityName = "가천대학교", + onExpandClick = {}, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt new file mode 100644 index 00000000..03f3410b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt @@ -0,0 +1,79 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.home_lineup_title +import festabookkmp.composeapp.generated.resources.home_navigate_to_schedule_desc +import festabookkmp.composeapp.generated.resources.home_navigate_to_schedule_text +import festabookkmp.composeapp.generated.resources.ic_arrow_forward_right +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun HomeLineupHeader( + onScheduleClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.home_lineup_title), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Row( + modifier = + Modifier + .clickable( + onClick = onScheduleClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.home_navigate_to_schedule_text), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray400, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + painter = painterResource(Res.drawable.ic_arrow_forward_right), + contentDescription = stringResource(Res.string.home_navigate_to_schedule_desc), + tint = FestabookColor.gray400, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeLineupHeaderPreview() { + HomeLineupHeader( + onScheduleClick = {}, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt new file mode 100644 index 00000000..b3a4ce23 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -0,0 +1,174 @@ +package com.daedan.festabook.presentation.home.component + +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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel +import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.home_is_d_day +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.todayIn +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import kotlin.time.Clock + +@Composable +fun HomeLineupItem( + uiModel: LineUpItemOfDayUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + // 날짜 + 배지 영역 + Column( + modifier = Modifier.padding(horizontal = 16.dp).width(IntrinsicSize.Max), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "${uiModel.date.month.number}.${uiModel.date.day}", + style = FestabookTypography.titleLarge, + color = FestabookColor.black, + ) + + if (uiModel.isDDay) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(FestabookColor.black) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = stringResource(Res.string.home_is_d_day), + style = FestabookTypography.labelSmall, + color = FestabookColor.white, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + + HorizontalDivider( + thickness = 1.dp, + color = FestabookColor.gray700, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 아티스트 가로 리스트 + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(uiModel.lineupItems) { item -> + HomeArtistItem( + artistName = item.name, + artistImageUrl = item.imageUrl, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeLineupItemPreview() { + HomeLineupItem( + uiModel = + LineUpItemOfDayUiModel( + id = 1L, + date = Clock.System.todayIn(TimeZone.currentSystemDefault()), + isDDay = true, + lineupItems = + listOf( + LineupItemUiModel( + id = 1, + name = "실리카겔", + imageUrl = "sample", + performanceAt = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + LineupItemUiModel( + id = 2, + name = "한로로", + imageUrl = "sample", + performanceAt = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + LineupItemUiModel( + id = 3, + name = "실리카겔", + imageUrl = "sample", + performanceAt = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + LineupItemUiModel( + id = 4, + name = "한로로", + imageUrl = "sample", + performanceAt = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + LineupItemUiModel( + id = 5, + name = "실리카겔", + imageUrl = "sample", + performanceAt = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + LineupItemUiModel( + id = 6, + name = "한로로", + imageUrl = "sample", + performanceAt = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + ), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomePosterList.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomePosterList.kt new file mode 100644 index 00000000..548c2c96 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -0,0 +1,116 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.daedan.festabook.presentation.common.component.FestabookImage +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.festabookShapes +import org.jetbrains.compose.ui.tooling.preview.Preview +import kotlin.math.absoluteValue + +@Composable +fun HomePosterList( + posterUrls: List, + modifier: Modifier = Modifier, +) { + if (posterUrls.isEmpty()) return + + // 무한 스크롤을 위한 큰 수 설정 + val initialPage = (Int.MAX_VALUE / 2) - ((Int.MAX_VALUE / 2) % posterUrls.size) + val pagerState = + rememberPagerState( + initialPage = initialPage, + pageCount = { Int.MAX_VALUE }, + ) + + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidth = + with(density) { windowInfo.containerSize.width.toDp() } + val itemWidth = 300.dp + // 화면 중앙에 아이템이 오도록 패딩 계산 + val horizontalPadding = (screenWidth - itemWidth) / 2 + + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(itemWidth), + contentPadding = PaddingValues(horizontal = horizontalPadding), + pageSpacing = 12.dp, + modifier = + modifier + .fillMaxWidth() + .height(400.dp), // item_home_poster 높이 + verticalAlignment = Alignment.CenterVertically, + ) { page -> + val actualIndex = page % posterUrls.size + val imageUrl = posterUrls[actualIndex] + + // 스크롤 위치에 따른 Scale 계산 + val pageOffset = + ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue + + // 중앙(0)이면 1.0f, 멀어질수록 작아짐 (최소 0.9f) + val scale = + lerp( + start = 1.0f, + stop = 0.9f, + fraction = pageOffset.coerceIn(0f, 1f), + ) + + // 투명도 조절 (중앙은 1.0, 멀어지면 약간 투명하게) + val alpha = + lerp( + start = 1.0f, + stop = 0.6f, + fraction = pageOffset.coerceIn(0f, 1f), + ) + + Box( + modifier = + Modifier + .width(itemWidth) + .height(400.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + }.cardBackground(shape = festabookShapes.radius2) + .clip(festabookShapes.radius2), + ) { + FestabookImage( + imageUrl = imageUrl, + modifier = Modifier.fillMaxSize(), + enablePopUp = true, + ) + } + } +} + +@Preview +@Composable +private fun HomePosterListPreview() { + HomePosterList( + posterUrls = + listOf( + "sample", + "sample", + "sample", + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt new file mode 100644 index 00000000..a6027c41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -0,0 +1,284 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.domain.model.Poster +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.formatFestivalPeriod +import com.daedan.festabook.presentation.home.FestivalUiState +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel +import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.theme.FestabookColor +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.error_fail_to_load_info +import festabookkmp.composeapp.generated.resources.setting_notice_enabled +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.todayIn +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import kotlin.time.Clock + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onNavigateToExplore: () -> Unit, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val festivalUiState by viewModel.festivalUiState.collectAsStateWithLifecycle() + val lineupUiState by viewModel.lineupUiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + val settingEnabledText = stringResource(Res.string.setting_notice_enabled) + + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + notificationPermissionManager.requestPermission() + } + + ObserveAsEvents(flow = settingViewModel.success) { + onShowSnackBar(settingEnabledText) + } + + ObserveAsEvents(flow = settingViewModel.error) { + currentOnShowErrorSnackbar(it) + } + + LaunchedEffect(festivalUiState) { + when (val state = festivalUiState) { + is FestivalUiState.Error -> { + currentOnShowErrorSnackbar(state.throwable) + } + + else -> { + Unit + } + } + } + + when (val state = festivalUiState) { + is FestivalUiState.Loading -> { + LoadingStateScreen(modifier = modifier) + } + + is FestivalUiState.Error -> { + Box(modifier = modifier.fillMaxSize()) { + Text( + text = stringResource(Res.string.error_fail_to_load_info), + modifier = Modifier.align(Alignment.Center), + ) + } + } + + is FestivalUiState.Success -> { + FestivalOverview( + festivalUiState = state, + lineupUiState = lineupUiState, + onNavigateToExplore = onNavigateToExplore, + onNavigateToSchedule = viewModel::navigateToScheduleClick, + modifier = modifier, + ) + } + } +} + +@Composable +private fun FestivalOverview( + festivalUiState: FestivalUiState.Success, + lineupUiState: LineupUiState, + onNavigateToExplore: () -> Unit, + onNavigateToSchedule: () -> Unit, + modifier: Modifier = Modifier, +) { + val universityName = festivalUiState.organization.organizationName + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = FestabookColor.white, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize(), + ) { + HomeHeader( + universityName = universityName, + onExpandClick = onNavigateToExplore, + modifier = Modifier.padding(top = 40.dp, bottom = 12.dp), + ) + + LazyColumn( + modifier = + Modifier + .fillMaxSize(), + ) { + // 포스터 리스트 + item { + val posterUrls = + festivalUiState.organization.festival.festivalImages + .sortedBy { it.sequence } + .map { it.imageUrl } + + HomePosterList( + posterUrls = posterUrls, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + + // 축제 정보 + item { + val festival = festivalUiState.organization.festival + HomeFestivalInfo( + festivalName = festival.festivalName, + festivalDate = + formatFestivalPeriod( + festival.startDate, + festival.endDate, + ), + modifier = Modifier.padding(top = 16.dp), + ) + } + + // 구분선 + item { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, + modifier = + Modifier + .padding(top = 16.dp), + ) + } + + // 라인업 헤더 + item { + HomeLineupHeader( + onScheduleClick = onNavigateToSchedule, + ) + } + + // 라인업 리스트 + when (lineupUiState) { + is LineupUiState.Success -> { + items( + items = lineupUiState.lineups, + key = { it.id }, + ) { lineupItem -> + HomeLineupItem(uiModel = lineupItem) + } + } + + is LineupUiState.Loading -> { + // 로딩 시 동작 논의 후 추가 + } + + is LineupUiState.Error -> { + // 에러 표시 + } + } + + // 하단 여백 추가 + item { + Spacer(modifier = Modifier.padding(bottom = 60.dp)) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FestivalOverviewPreview() { + val sampleFestival = + Organization( + id = 1, + organizationName = "가천대학교", + festival = + Festival( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + startDate = Clock.System.todayIn(TimeZone.currentSystemDefault()), + endDate = + Clock.System + .todayIn(TimeZone.currentSystemDefault()) + .plus(DatePeriod(days = 1)), + festivalImages = + listOf( + Poster(1, "sample", 1), + Poster(2, "sample", 2), + ), + ), + ) + + val sampleLineups = + LineUpItemGroupUiModel( + group = + mapOf( + Clock.System.todayIn(TimeZone.currentSystemDefault()) to + listOf( + LineupItemUiModel( + 1, + "sample", + "실리카겔", + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + LineupItemUiModel( + 2, + "sample", + "아이유", + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + ), + Clock.System + .todayIn(TimeZone.currentSystemDefault()) + .plus(DatePeriod(days = 1)) to + listOf( + LineupItemUiModel( + 3, + "sample", + "뉴진스", + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + ), + ), + ) + + FestivalOverview( + festivalUiState = FestivalUiState.Success(sampleFestival), + lineupUiState = LineupUiState.Success(sampleLineups.getLineupItems()), + onNavigateToExplore = {}, + onNavigateToSchedule = {}, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.kt new file mode 100644 index 00000000..500f98ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.ui.text.PlatformTextStyle + +expect val HomeTextStyle: PlatformTextStyle? diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt new file mode 100644 index 00000000..f600d9ab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -0,0 +1,42 @@ +package com.daedan.festabook.presentation.home.navigation + +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.component.HomeScreen +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.main.MainViewModel +import com.daedan.festabook.presentation.main.component.FirstVisitDialog +import com.daedan.festabook.presentation.setting.SettingViewModel + +fun NavGraphBuilder.homeNavGraph( + viewModel: HomeViewModel, + mainViewModel: MainViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackbar: (String) -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + onSubscriptionConfirm: () -> Unit, + onNavigateToExplore: () -> Unit, +) { + composable { + val isFirstVisit by mainViewModel.isFirstVisit.collectAsStateWithLifecycle() + if (isFirstVisit) { + FirstVisitDialog( + onConfirm = { onSubscriptionConfirm() }, + onDecline = { mainViewModel.declineAlert() }, + ) + } + HomeScreen( + viewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, + onNavigateToExplore = onNavigateToExplore, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = onShowSnackbar, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt new file mode 100644 index 00000000..40a25f60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt @@ -0,0 +1,72 @@ +package com.daedan.festabook.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.repository.FestivalRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.time.Clock + +@ContributesIntoMap(AppScope::class) +@ViewModelKey(MainViewModel::class) +@Inject +class MainViewModel( + festivalRepository: FestivalRepository, +) : ViewModel() { + private val _backPressEvent: MutableSharedFlow = + MutableSharedFlow( + extraBufferCapacity = 1, + ) + val backPressEvent: SharedFlow = _backPressEvent.asSharedFlow() + + private val _navigateNewsEvent: MutableSharedFlow = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + ) + val navigateNewsEvent = _navigateNewsEvent.asSharedFlow() + + private val _isFirstVisit = MutableStateFlow(false) + val isFirstVisit: StateFlow = _isFirstVisit.asStateFlow() + + private var lastBackPressedTime: Long = 0 + + init { + viewModelScope.launch { + festivalRepository.getIsFirstVisit().collect { + _isFirstVisit.value = it + } + } + } + + fun navigateToNews() { + _navigateNewsEvent.tryEmit(Unit) + } + + fun declineAlert() { + _isFirstVisit.value = false + } + + fun onBackPressed() { + val currentTime = Clock.System.now().toEpochMilliseconds() + if (currentTime - lastBackPressedTime < BACK_PRESS_INTERVAL) { + _backPressEvent.tryEmit(true) + } else { + lastBackPressedTime = currentTime + _backPressEvent.tryEmit(false) + } + } + + companion object { + private const val BACK_PRESS_INTERVAL: Long = 2000L + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt new file mode 100644 index 00000000..0966968d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt @@ -0,0 +1,169 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.main.FestabookMainTab +import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.selectedColor +import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.unselectedColor +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.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.btn_fab_manu +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun FestabookBottomNavigationBar( + currentTab: FestabookMainTab?, + onTabSelect: (FestabookMainTab) -> Unit, + modifier: Modifier = Modifier, + onTabReSelect: (FestabookMainTab) -> Unit = {}, +) { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = + modifier + .shadow( + elevation = 10.dp, + clip = false, + ).background(color = FestabookColor.white) + .navigationBarsPadding(), + ) { + Row( + modifier = + Modifier + .background(color = FestabookColor.white) + .fillMaxWidth() + .height(70.dp), + ) { + FestabookMainTab.entries.forEach { item -> + when (item) { + FestabookMainTab.PLACE_MAP -> { + Spacer(modifier = Modifier.weight(1f)) + } + + else -> { + FestabookNavigationItem( + tab = item, + selected = item == currentTab, + onClick = { + if (it == currentTab) onTabReSelect(it) + onTabSelect(it) + }, + ) + } + } + } + } + PlaceMapNavigationItem(onClick = { + if (it == currentTab) onTabReSelect(it) + onTabSelect(it) + }) + } +} + +@Composable +private fun RowScope.FestabookNavigationItem( + onClick: (FestabookMainTab) -> Unit, + tab: FestabookMainTab, + selected: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .weight(1f) + .fillMaxHeight() + .background(color = FestabookColor.white) + .selectable( + selected = selected, + onClick = { onClick(tab) }, + interactionSource = remember { MutableInteractionSource() }, + indication = null, // 리플(Ripple) 완벽 제거 + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(tab.iconResId), + contentDescription = stringResource(tab.labelResId), + tint = if (selected) selectedColor else unselectedColor, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody1)) + Text( + text = stringResource(tab.labelResId), + style = FestabookTypography.labelMedium, + color = if (selected) selectedColor else unselectedColor, + ) + } + } +} + +@Composable +private fun PlaceMapNavigationItem( + onClick: (FestabookMainTab) -> Unit, + modifier: Modifier = Modifier, +) { + Image( + modifier = + modifier + .offset(y = -festabookSpacing.paddingBody4) + .clickable( + onClick = { + onClick(FestabookMainTab.PLACE_MAP) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + painter = painterResource(Res.drawable.btn_fab_manu), + contentDescription = stringResource(FestabookMainTab.PLACE_MAP.labelResId), + ) +} + +@Preview(showBackground = true) +@Composable +private fun FestabookBottomNavigationBarPreview() { + FestabookTheme { + var currentTabState by remember { mutableStateOf(FestabookMainTab.HOME) } + Scaffold( + bottomBar = { + FestabookBottomNavigationBar( + currentTab = currentTabState, + onTabSelect = { currentTabState = it }, + ) + }, + ) { it } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt new file mode 100644 index 00000000..442c0f78 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt @@ -0,0 +1,180 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +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 +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.ic_alarm +import festabookkmp.composeapp.generated.resources.main_alarm_dialog_cancel_button +import festabookkmp.composeapp.generated.resources.main_alarm_dialog_confirm_button +import festabookkmp.composeapp.generated.resources.main_alarm_dialog_message +import festabookkmp.composeapp.generated.resources.main_alarm_dialog_title +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun FirstVisitDialog( + onConfirm: () -> Unit, + onDecline: () -> Unit = {}, +) { + var isVisible by remember { mutableStateOf(true) } + if (isVisible) { + FirstVisitInfoDialog( + title = stringResource(Res.string.main_alarm_dialog_title), + message = stringResource(Res.string.main_alarm_dialog_message), + confirmButtonText = stringResource(Res.string.main_alarm_dialog_confirm_button), + declineButtonText = stringResource(Res.string.main_alarm_dialog_cancel_button), + iconResId = Res.drawable.ic_alarm, + confirmButtonColor = FestabookColor.accentBlue, + declineButtonColor = FestabookColor.gray400, + onConfirm = { + onConfirm() + isVisible = false + }, + onDecline = onDecline, + ) + } +} + +@Composable +private fun FirstVisitInfoDialog( + title: String, + message: String, + confirmButtonText: String, + declineButtonText: String, + confirmButtonColor: Color, + declineButtonColor: Color, + onConfirm: () -> Unit, + onDecline: () -> Unit, + iconResId: DrawableResource? = null, +) { + Dialog( + onDismissRequest = { onDecline() }, + properties = + DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + Column( + modifier = + Modifier + .background( + color = FestabookColor.white, + shape = festabookShapes.radius4, + ).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + iconResId?.let { + Image( + painter = painterResource(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)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = onDecline, + modifier = + Modifier + .wrapContentWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = FestabookColor.white, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + border = BorderStroke(width = 1.dp, declineButtonColor), + contentPadding = PaddingValues(festabookSpacing.paddingBody1), + ) { + Text( + color = declineButtonColor, + text = declineButtonText, + ) + } + Spacer(Modifier.padding(festabookSpacing.paddingBody1)) + Button( + onClick = onConfirm, + modifier = + Modifier + .wrapContentWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = confirmButtonColor, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + contentPadding = PaddingValues(festabookSpacing.paddingBody3), + ) { + Text(text = confirmButtonText) + } + } + } + } +} + +@Preview +@Composable +private fun UpdateDialogPreview() { + FestabookTheme { + FirstVisitDialog( + onConfirm = {}, + onDecline = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt new file mode 100644 index 00000000..019f46a8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -0,0 +1,245 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.navigation.compose.NavHost +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.di.FestabookAppGraph +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.FestabookSnackbar +import com.daedan.festabook.presentation.common.component.SnackbarManager +import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.navigation.homeNavGraph +import com.daedan.festabook.presentation.main.FestabookMainTab +import com.daedan.festabook.presentation.main.FestabookNavigator +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.main.MainViewModel +import com.daedan.festabook.presentation.main.rememberFestabookNavigator +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.navigation.newsNavGraph +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph +import com.daedan.festabook.presentation.placeMap.platform.LocationSource +import com.daedan.festabook.presentation.platform.DeepLinkKeys +import com.daedan.festabook.presentation.platform.Intent +import com.daedan.festabook.presentation.platform.RememberDeepLinkHandler +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.navigation.scheduleNavGraph +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.component.platform.rememberNotificationPermissionManager +import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings +import com.daedan.festabook.presentation.setting.navigation.settingNavGraph +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.back_press_exit_message +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +@Suppress("ktlint:compose:vm-forwarding-check") +fun MainScreen( + appGraph: FestabookAppGraph, + locationSource: LocationSource, + onAppFinish: () -> Unit, + festabookNavigator: FestabookNavigator, + mainViewModel: MainViewModel, + homeViewModel: HomeViewModel, + scheduleViewModel: ScheduleViewModel, + placeMapViewModel: PlaceMapViewModel, + newsViewModel: NewsViewModel, + settingViewModel: SettingViewModel, + modifier: Modifier = Modifier, +) { + val mainNavigator = rememberFestabookNavigator(MainTabRoute.Home) + val snackbarHostState = remember { SnackbarHostState() } + val snackbarManager = rememberAppSnackbarManager(snackbarHostState) + val backPressExitMessage = stringResource(Res.string.back_press_exit_message) + val openAppSettings = rememberOpenAppSettings() + val state = rememberNavigationEventState(NavigationEventInfo.None) + + val notificationPermissionManager = + rememberNotificationPermissionManager( + notificationPermissionManagerFactory = appGraph.notificationPermissionManagerFactory, + onPermissionGrant = { settingViewModel.saveNotificationId() }, + onPermissionDeny = { snackbarManager.showPermissionDeniedSnackbar(openAppSettings) }, + ) + + ObserveAsEvents(flow = mainViewModel.navigateNewsEvent) { + mainNavigator.navigateToMainTab(FestabookMainTab.NEWS) + } + ObserveAsEvents(flow = mainViewModel.backPressEvent) { isDoublePress -> + if (isDoublePress) { + onAppFinish() + } else { + snackbarManager.show(backPressExitMessage) + } + } + ObserveAsEvents(flow = homeViewModel.navigateToScheduleEvent) { + mainNavigator.navigateToMainTab(FestabookMainTab.SCHEDULE) + } + + NavigationBackHandler( + state = state, + ) { + mainViewModel.onBackPressed() + } + + RememberDeepLinkHandler { intent -> + handleNavigation(intent, newsViewModel, mainViewModel) + } + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { data -> + FestabookSnackbar(data) + } + }, + bottomBar = { + if (mainNavigator.shouldShowBottomBar) { + FestabookBottomNavigationBar( + currentTab = mainNavigator.currentTab, + onTabSelect = { mainNavigator.navigateToMainTab(it) }, + onTabReSelect = { tab -> + when (tab) { + FestabookMainTab.SCHEDULE -> { + scheduleViewModel.loadSchedules() + } + + FestabookMainTab.PLACE_MAP -> { + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() + } + + else -> { + Unit + } + } + }, + ) + } + }, + modifier = modifier, + ) { innerPadding -> + val isVisible = mainNavigator.currentTab == FestabookMainTab.PLACE_MAP + PlaceMapRoute( + modifier = + Modifier + .graphicsLayer { + alpha = if (isVisible) 1f else 0f + }.padding(innerPadding) + .pointerInput(isVisible) { + if (!isVisible) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + .changes + .forEach { it.consume() } + } + } + } + }, + placeMapViewModel = placeMapViewModel, + locationSource = locationSource, +// logger = appGraph.defaultFirebaseLogger, + onShowErrorSnackBar = snackbarManager::showError, + onStartPlaceDetail = { + mainNavigator.navigate( + FestabookRoute.PlaceDetail( + placeDetailUiModel = it.placeDetail.value, + ), + ) + }, + ) + FestabookNavHost( + modifier = Modifier.padding(innerPadding), + festabookNavigator = festabookNavigator, + navigator = mainNavigator, + mainViewModel = mainViewModel, + homeViewModel = homeViewModel, + scheduleViewModel = scheduleViewModel, + settingViewModel = settingViewModel, + newsViewModel = newsViewModel, + notificationPermissionManager = notificationPermissionManager, + snackbarManager = snackbarManager, + ) + } +} + +@Composable +private fun FestabookNavHost( + navigator: FestabookNavigator, + festabookNavigator: FestabookNavigator, + mainViewModel: MainViewModel, + homeViewModel: HomeViewModel, + scheduleViewModel: ScheduleViewModel, + newsViewModel: NewsViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + snackbarManager: SnackbarManager, + modifier: Modifier = Modifier, +) { + NavHost( + modifier = modifier, + startDestination = navigator.startRoute, + navController = navigator.navController, + ) { + homeNavGraph( + viewModel = homeViewModel, + mainViewModel = mainViewModel, + onNavigateToExplore = { festabookNavigator.navigate(FestabookRoute.Explore) }, + onSubscriptionConfirm = { + settingViewModel.notificationAllowClick() + mainViewModel.declineAlert() + }, + onShowSnackbar = snackbarManager::show, + onShowErrorSnackbar = snackbarManager::showError, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + ) + scheduleNavGraph( + viewModel = scheduleViewModel, + onShowErrorSnackbar = snackbarManager::showError, + ) + placeMapNavGraph( + onBackToPreviousClick = { navigator.popBackStack() }, + onShowErrorSnackbar = snackbarManager::showError, + ) + newsNavGraph( + viewModel = newsViewModel, + onShowErrorSnackbar = snackbarManager::showError, + ) + settingNavGraph( + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = snackbarManager::show, + onShowErrorSnackBar = snackbarManager::showError, + ) + } +} + +private fun handleNavigation( + intent: Intent, + newsViewModel: NewsViewModel, + mainViewModel: MainViewModel, +) { + val noticeIdToExpand = + intent.getLongExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, DeepLinkKeys.INITIALIZED_ID) + if (noticeIdToExpand != DeepLinkKeys.INITIALIZED_ID) newsViewModel.expandNotice(noticeIdToExpand) + val canNavigateToNews = intent.getBooleanExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, false) + if (canNavigateToNews) mainViewModel.navigateToNews() +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt new file mode 100644 index 00000000..4dbb4feb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.main.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.di.FestabookAppGraph +import com.daedan.festabook.presentation.main.FestabookNavigator +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.component.MainScreen +import com.daedan.festabook.presentation.placeMap.platform.LocationSource +import dev.zacsweers.metrox.viewmodel.metroViewModel + +fun NavGraphBuilder.mainNavGraph( + onAppFinish: () -> Unit, + appGraph: FestabookAppGraph, + locationSource: LocationSource, + festabookNavigator: FestabookNavigator, +) { + composable { + val mainBackEntry = + festabookNavigator.navController.getBackStackEntry() + MainScreen( + appGraph = appGraph, + locationSource = locationSource, + onAppFinish = onAppFinish, + festabookNavigator = festabookNavigator, + homeViewModel = metroViewModel(mainBackEntry), + scheduleViewModel = metroViewModel(mainBackEntry), + placeMapViewModel = metroViewModel(mainBackEntry), + settingViewModel = metroViewModel(mainBackEntry), + mainViewModel = metroViewModel(mainBackEntry), + newsViewModel = metroViewModel(mainBackEntry), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt index cc08066d..ee34b28f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt @@ -1,21 +1,19 @@ -@file:Suppress("ktlint:standard:no-empty-file") - package com.daedan.festabook.presentation.news.navigation -// -// import androidx.navigation.NavGraphBuilder -// import androidx.navigation.compose.composable -// import com.daedan.festabook.presentation.main.MainTabRoute -// import com.daedan.festabook.presentation.news.NewsViewModel -// import com.daedan.festabook.presentation.news.component.NewsScreen -// -// fun NavGraphBuilder.newsNavGraph( -// viewModel: NewsViewModel, -// onShowErrorSnackbar: (Throwable) -> Unit, -// ) { -// composable { -// NewsScreen( -// newsViewModel = viewModel, -// onShowErrorSnackbar = onShowErrorSnackbar, -// ) -// } -// } + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.component.NewsScreen + +fun NavGraphBuilder.newsNavGraph( + viewModel: NewsViewModel, + onShowErrorSnackbar: (Throwable) -> Unit, +) { + composable { + NewsScreen( + newsViewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt index f19439dc..c4e513db 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt @@ -2,8 +2,7 @@ package com.daedan.festabook.presentation.placeDetail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.lifecycle.viewmodel.CreationExtras import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel @@ -11,9 +10,13 @@ import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState import com.daedan.festabook.presentation.placeDetail.model.toUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -25,7 +28,15 @@ class PlaceDetailViewModel( @Assisted private val receivedPlaceDetail: PlaceDetailUiModel?, ) : ViewModel() { @AssistedFactory - interface Factory { + @ViewModelAssistedFactoryKey(PlaceDetailViewModel::class) + @ContributesIntoMap(AppScope::class) + interface Factory : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): PlaceDetailViewModel = + create( + place = extras[PlaceKey], + receivedPlaceDetail = extras[PlaceDetailKey], + ) + fun create( place: PlaceUiModel?, receivedPlaceDetail: PlaceDetailUiModel?, @@ -85,15 +96,7 @@ class PlaceDetailViewModel( ) } - companion object { - fun factory( - factory: Factory, - place: PlaceUiModel?, - receivedPlaceDetail: PlaceDetailUiModel?, - ) = viewModelFactory { - initializer { - factory.create(place, receivedPlaceDetail) - } - } - } + object PlaceKey : CreationExtras.Key + + object PlaceDetailKey : CreationExtras.Key } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt new file mode 100644 index 00000000..e44a8740 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -0,0 +1,94 @@ +package com.daedan.festabook.presentation.placeMap.navigation + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import androidx.savedstate.SavedState +import androidx.savedstate.read +import androidx.savedstate.write +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeDetail.component.PlaceDetailRoute +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel +import kotlinx.serialization.json.Json +import kotlin.reflect.typeOf + +fun NavGraphBuilder.placeMapNavGraph( + onBackToPreviousClick: () -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, +) { + composable { + } + + composable( + typeMap = + mapOf( + typeOf() to defaultNavType(), + typeOf() to defaultNavType(), + ), + enterTransition = { + slideInVertically(initialOffsetY = { it / 10 }) + fadeIn() + }, + exitTransition = { + slideOutVertically(targetOffsetY = { it / 10 }) + fadeOut() + }, + ) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel = + assistedMetroViewModel( + extras = + MutableCreationExtras().apply { + set(PlaceDetailViewModel.PlaceKey, route.placeUiModel) + set(PlaceDetailViewModel.PlaceDetailKey, route.placeDetailUiModel) + }, + ) + + PlaceDetailRoute( + modifier = + Modifier.graphicsLayer( + compositingStrategy = CompositingStrategy.Offscreen, + clip = true, + ), + viewModel = viewModel, + onBackToPreviousClick = onBackToPreviousClick, + onShowErrorSnackbar = onShowErrorSnackbar, + ) + } +} + +private inline fun defaultNavType() = + object : NavType(isNullableAllowed = true) { + override fun put( + bundle: SavedState, + key: String, + value: T, + ) { + bundle.write { + putString(key, Json.encodeToString(value)) + } + } + + override fun get( + bundle: SavedState, + key: String, + ): T? = + bundle.read { + getStringOrNull(key)?.let { Json.decodeFromString(it) } + } + + override fun parseValue(value: String): T = Json.decodeFromString(value) + + override fun serializeAsValue(value: T): String = Json.encodeToString(value) + } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt index 9fb28080..32771215 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt @@ -8,7 +8,17 @@ object DeepLinkKeys { const val INITIALIZED_ID = -1L } -expect class Intent +expect class Intent { + fun getLongExtra( + key: String, + defaultValue: Long, + ): Long + + fun getBooleanExtra( + key: String, + defaultValue: Boolean, + ): Boolean +} @Composable expect fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt index 6818d1cd..6caad10f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt @@ -1,21 +1,19 @@ -@file:Suppress("ktlint:standard:no-empty-file") - package com.daedan.festabook.presentation.schedule.navigation -// import androidx.navigation.NavGraphBuilder -// import androidx.navigation.compose.composable -// import com.daedan.festabook.presentation.main.MainTabRoute -// import com.daedan.festabook.presentation.schedule.ScheduleViewModel -// import com.daedan.festabook.presentation.schedule.component.ScheduleScreen -// -// fun NavGraphBuilder.scheduleNavGraph( -// viewModel: ScheduleViewModel, -// onShowErrorSnackbar: (Throwable) -> Unit, -// ) { -// composable { -// ScheduleScreen( -// scheduleViewModel = viewModel, -// onShowErrorSnackbar = onShowErrorSnackbar, -// ) -// } -// } +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.component.ScheduleScreen + +fun NavGraphBuilder.scheduleNavGraph( + viewModel: ScheduleViewModel, + onShowErrorSnackbar: (Throwable) -> Unit, +) { + composable { + ScheduleScreen( + scheduleViewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 22e995b3..2afa6de8 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -45,7 +45,6 @@ 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.festabookSpacing -import dev.zacsweers.metrox.viewmodel.metroViewModel import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.ic_arrow_forward_right import festabookkmp.composeapp.generated.resources.move @@ -73,9 +72,9 @@ fun SettingRoute( notificationPermissionManager: NotificationPermissionManager, onShowSnackBar: (String) -> Unit, onShowErrorSnackBar: (Throwable) -> Unit, + settingViewModel: SettingViewModel, + homeViewModel: HomeViewModel, modifier: Modifier = Modifier, - settingViewModel: SettingViewModel = metroViewModel(), - homeViewModel: HomeViewModel = metroViewModel(), ) { val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() @@ -90,7 +89,10 @@ fun SettingRoute( val permission = notificationPermissionManager.checkPermission() when (permission) { - PermissionState.GRANTED -> {} + PermissionState.GRANTED -> { + settingViewModel.saveNotificationId() + onShowSnackBar(enableMessage) + } PermissionState.NEED_RATIONALE -> { showPermissionDialog = true @@ -102,10 +104,6 @@ fun SettingRoute( } } - ObserveAsEvents(flow = settingViewModel.success) { - onShowSnackBar(enableMessage) - } - ObserveAsEvents(flow = settingViewModel.error) { onShowErrorSnackBar(it) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt index 075c93db..a7ab51fc 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -1,29 +1,27 @@ -@file:Suppress("ktlint:standard:no-empty-file") - package com.daedan.festabook.presentation.setting.navigation -// import androidx.navigation.NavGraphBuilder -// import androidx.navigation.compose.composable -// import com.daedan.festabook.presentation.NotificationPermissionManager -// import com.daedan.festabook.presentation.home.HomeViewModel -// import com.daedan.festabook.presentation.main.MainTabRoute -// import com.daedan.festabook.presentation.setting.SettingViewModel -// import com.daedan.festabook.presentation.setting.component.SettingRoute -// -// fun NavGraphBuilder.settingNavGraph( -// homeViewModel: HomeViewModel, -// settingViewModel: SettingViewModel, -// notificationPermissionManager: NotificationPermissionManager, -// onShowSnackBar: (String) -> Unit, -// onShowErrorSnackBar: (Throwable) -> Unit, -// ) { -// composable { -// SettingRoute( -// homeViewModel = homeViewModel, -// settingViewModel = settingViewModel, -// notificationPermissionManager = notificationPermissionManager, -// onShowSnackBar = onShowSnackBar, -// onShowErrorSnackBar = onShowErrorSnackBar, -// ) -// } -// } +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.component.SettingRoute + +fun NavGraphBuilder.settingNavGraph( + homeViewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, +) { + composable { + SettingRoute( + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = onShowSnackBar, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt index d240f1ef..ba8d7fda 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/MainViewController.kt @@ -1,53 +1,15 @@ package com.daedan.festabook -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController -import com.daedan.festabook.di.IosAppGraph -import com.daedan.festabook.presentation.common.component.FestabookSnackbar -import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager -import com.daedan.festabook.presentation.setting.SettingViewModel -import com.daedan.festabook.presentation.setting.component.SettingRoute -import com.daedan.festabook.presentation.setting.component.platform.rememberNotificationPermissionManager -import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings +import com.daedan.festabook.presentation.FestabookScreen import com.daedan.festabook.presentation.theme.FestabookTheme -import dev.zacsweers.metro.createGraph -import dev.zacsweers.metrox.viewmodel.metroViewModel - -private val iosAppGraph = createGraph() -private val notificationPermissionManagerFactory = iosAppGraph.notificationPermissionManagerFactory @Suppress("ktlint:standard:function-naming") fun MainViewController() = ComposeUIViewController { FestabookTheme { - val snackbarHostState = remember { SnackbarHostState() } - val snackbarManager = rememberAppSnackbarManager(snackbarHostState) - val openAppSettings = rememberOpenAppSettings() - Scaffold(snackbarHost = { - SnackbarHost(snackbarHostState) { data -> - FestabookSnackbar(data) - } - }) { innerPadding -> - val viewModel = metroViewModel() - SettingRoute( - notificationPermissionManager = - rememberNotificationPermissionManager( - notificationPermissionManagerFactory = notificationPermissionManagerFactory, - onPermissionGrant = { - viewModel.saveNotificationId() - }, - onPermissionDeny = { - snackbarManager.showPermissionDeniedSnackbar( - openAppSettings, - ) - }, - ), - onShowSnackBar = {}, - onShowErrorSnackBar = {}, - ) - } + FestabookScreen( + onAppFinish = { }, + ) } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt index 797d7a04..ea6db06d 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt @@ -1,9 +1,11 @@ package com.daedan.festabook.delegate +import platform.Foundation.NSNotificationCenter import platform.UserNotifications.UNNotification import platform.UserNotifications.UNNotificationPresentationOptionAlert import platform.UserNotifications.UNNotificationPresentationOptionSound import platform.UserNotifications.UNNotificationPresentationOptions +import platform.UserNotifications.UNNotificationResponse import platform.UserNotifications.UNUserNotificationCenter import platform.UserNotifications.UNUserNotificationCenterDelegateProtocol import platform.darwin.NSObject @@ -18,8 +20,28 @@ class DefaultUserNotificationDelegate : withCompletionHandler: (UNNotificationPresentationOptions) -> Unit, ) { // 포그라운드일 때 실행 - val userInfo = willPresentNotification.request.content.userInfo val options = UNNotificationPresentationOptionAlert or UNNotificationPresentationOptionSound withCompletionHandler(options) } + + override fun userNotificationCenter( + center: UNUserNotificationCenter, + didReceiveNotificationResponse: UNNotificationResponse, + withCompletionHandler: () -> Unit, + ) { + val userInfo = didReceiveNotificationResponse.notification.request.content.userInfo + val announcementId = + userInfo["announcementId"] ?: run { + withCompletionHandler() + return + } + + NSNotificationCenter.defaultCenter.postNotificationName( + aName = "fcmNewsNotification", + `object` = null, + userInfo = mapOf("announcementId" to announcementId), + ) + + withCompletionHandler() + } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.ios.kt new file mode 100644 index 00000000..d557193f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/home/component/HomeTextStyle.ios.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.ui.text.PlatformTextStyle + +actual val HomeTextStyle: PlatformTextStyle? = null diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt index 951ae2c6..40f6a3e2 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt @@ -30,7 +30,7 @@ actual fun NaverMapLogo( val systemTopPadding = WindowInsets.statusBars.getTop(density) var targetPosition by remember { mutableStateOf(Offset.Zero) } - LaunchedEffect(targetPosition) { + LaunchedEffect(targetPosition, naverMap) { naverMap?.run { platformMap.mapView.apply { logoAlign = NMFLogoAlignLeftTop diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt index d21be1ec..2d476e0b 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt @@ -6,11 +6,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import platform.Foundation.NSNotificationCenter import platform.Foundation.NSOperationQueue -import platform.Foundation.NSURL actual class Intent( - val url: NSURL, -) + val extras: Map = emptyMap(), +) { + actual fun getLongExtra( + key: String, + defaultValue: Long, + ): Long = extras[key] as? Long ?: defaultValue + + actual fun getBooleanExtra( + key: String, + defaultValue: Boolean, + ): Boolean = extras[key] as? Boolean ?: defaultValue +} @Composable actual fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { @@ -19,12 +28,24 @@ actual fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { DisposableEffect(Unit) { val observer = NSNotificationCenter.defaultCenter.addObserverForName( - name = "OpenURLNotification", // 커스텀 알림 이름 (AppDelegate에서 쏴줘야 함) + name = "fcmNewsNotification", // 커스텀 알림 이름 (AppDelegate에서 쏴줘야 함) `object` = null, queue = NSOperationQueue.mainQueue, ) { notification -> - val url = notification?.userInfo?.get("url") as? NSURL ?: return@addObserverForName - currentOnDeepLink(Intent(url)) + val notificationToExpand = + notification + ?.userInfo + ?.get("announcementId") + ?.toString() + ?.toLongOrNull() ?: return@addObserverForName + currentOnDeepLink( + Intent( + mapOf( + DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND to notificationToExpand, + DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS to true, + ), + ), + ) } onDispose { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a843042..a8b4099c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ androidx-lifecycle = "2.9.6" androidx-testExt = "1.3.0" composeMultiplatform = "1.9.3" datastorePreferences = "1.2.0" -compottie = "2.0.3" +compottie = "2.1.0" navigationCompose = "2.9.2" coil = "3.3.0" junit = "4.13.2" From c13c9d4fd8cbdd1674297116fe0000bd0e1a409e Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:07:23 +0900 Subject: [PATCH 08/54] =?UTF-8?q?[Feat]:=20=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC(Global=20Exception=20Handling)?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=95=B1=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B5=9C=EC=A0=81=ED=99=94=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 전역 예외 처리(Global Exception Handling) 구현 및 앱 설정 최적화 * **feat: 전역 예외 처리 시스템 및 에러 화면 구현** * `ErrorScreen`: 예기치 못한 에러 발생 시 노출되는 공통 UI 컴포넌트를 구현했습니다. * `FestabookGlobalExceptionHandler` & `ErrorActivity` (Android): Uncaught Exception 발생 시 에러 화면으로 전환하고, Firebase Crashlytics에 에러를 기록하며 앱을 재시작하는 로직을 추가했습니다. * `DefaultFestabookAppDelegate` & `ErrorViewController` (iOS): iOS 플랫폼의 전역 예외 포착 로직을 추가하고, 에러 상황에서 Crashlytics 기록 및 에러 화면이 표시되도록 구성했습니다. * 에러 관련 문자열 리소스 및 `ic_uncaught_error` 아이콘 스타일을 업데이트했습니다. * **feat: 앱 테마 및 화면 방향 고정 설정** * 라이트 모드 강제: Android(`AppCompatDelegate`) 및 iOS(`UIUserInterfaceStyleLight`) 환경에서 앱 테마를 라이트 모드로 고정했습니다. * 세로 화면 고정: `AndroidManifest.xml` 설정과 iOS `AppDelegate` 로직을 통해 앱의 화면 방향을 세로(Portrait)로 고정했습니다. * **refactor: 기타 코드 수정** * `SettingScreen`: 에러 핸들러 동작 검증을 위해 이용약관 클릭 시 예외를 발생시키는 임시 로직을 추가했습니다. * `FestabookApp` (Android): 앱 초기화 단계에서 라이트 테마 및 전역 예외 처리기 설정 함수를 호출하도록 수정했습니다. * refactor: iOS 테마 설정 방식 개선 및 미사용 파일 제거 * **refactor: iOS 라이트 모드 강제 설정 로직 최적화** * `DefaultFestabookAppDelegate`: `application.windows`를 순회하며 개별 윈도우의 스타일을 변경하던 방식에서 `UIWindow.appearance()`를 사용하는 방식으로 변경하여 테마 설정 로직을 간소화했습니다. * `AppDelegate.swift`: Swift 진입점에서 수행하던 `UIWindow.appearance()` 설정을 제거하고 Kotlin 공통 로직으로 통합했습니다. * **chore: 미사용 iOS 에러 뷰 컨트롤러 삭제** * `ErrorViewController.kt`: 더 이상 사용되지 않는 iOS 전용 에러 뷰 컨트롤러 파일을 삭제했습니다. * refactor: 전역 예외 처리 관련 미사용 문자열 리소스 제거 * **refactor: 안드로이드 문자열 리소스 정리** * `strings.xml` 파일에서 전역 예외 처리기(Uncaught Exception Handler) UI에 사용되었던 `uncaught_exception_title`, `uncaught_exception_message`, `uncaught_exception_confirm` 리소스를 제거했습니다. --- .../src/androidMain/AndroidManifest.xml | 8 +- .../com/daedan/festabook/FestabookApp.kt | 12 +++ .../presentation/error/ErrorActivity.kt | 58 ++++++++++++++ .../error/FestabookGlobalExceptionHandler.kt | 18 +++++ .../drawable/ic_uncaught_error.xml | 4 +- .../presentation/error/ErrorScreen.kt | 78 +++++++++++++++++++ .../festabook/DefaultFestabookAppDelegate.kt | 27 ++++++- iosApp/iosApp/AppDelegate.swift | 4 + 8 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/ErrorActivity.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/error/ErrorScreen.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 7ede7356..5f96d576 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -15,7 +15,8 @@ android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:exported="true" + android:screenOrientation="portrait"> @@ -23,6 +24,11 @@ + + diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt index c16d2780..0468bcc0 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt @@ -1,8 +1,10 @@ package com.daedan.festabook import android.app.Application +import androidx.appcompat.app.AppCompatDelegate import com.daedan.festabook.di.AndroidAppGraph import com.daedan.festabook.domain.repository.DeviceRepository +import com.daedan.festabook.presentation.error.FestabookGlobalExceptionHandler import com.daedan.festabook.service.NotificationHelper import com.google.firebase.Firebase import com.google.firebase.crashlytics.crashlytics @@ -37,6 +39,8 @@ class FestabookApp : Application() { sendUnsentReports() setupNotificationChannel() setupDeviceIdentifiers() + setLightTheme() + setGlobalExceptionHandler() } private fun sendUnsentReports() { @@ -76,4 +80,12 @@ class FestabookApp : Application() { Napier.base(DebugAntilog()) } } + + private fun setLightTheme() { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + } + + private fun setGlobalExceptionHandler() { + Thread.setDefaultUncaughtExceptionHandler(FestabookGlobalExceptionHandler(this)) + } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/ErrorActivity.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/ErrorActivity.kt new file mode 100644 index 00000000..83270b2a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/ErrorActivity.kt @@ -0,0 +1,58 @@ +package com.daedan.festabook.presentation.error + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.daedan.festabook.logging.FirebaseCrashlytics +import com.daedan.festabook.presentation.theme.FestabookTheme +import kotlin.system.exitProcess + +class ErrorActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + Thread.setDefaultUncaughtExceptionHandler(null) + enableEdgeToEdge() + super.onCreate(savedInstanceState) + getThrowableFromIntent()?.let { FirebaseCrashlytics.recordException(it) } + setContent { + FestabookTheme { + ErrorScreen(onRestart = ::restartApplication) + } + } + } + + @Suppress("DEPRECATION") + private fun getThrowableFromIntent(): Throwable? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getSerializableExtra(KEY_ERROR, Throwable::class.java) + } else { + intent.getSerializableExtra(KEY_ERROR) as? Throwable + } + + private fun restartApplication() { + val intentForPackage = packageManager.getLaunchIntentForPackage(packageName) + if (intentForPackage == null) { + finishAffinity() + return + } + val mainIntent = Intent.makeRestartActivityTask(intentForPackage.component) + startActivity(mainIntent) + exitProcess(0) + } + + companion object { + private const val KEY_ERROR = "error" + + fun newIntent( + context: Context, + error: Throwable, + ): Intent = + Intent(context, ErrorActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + putExtra(KEY_ERROR, error) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt new file mode 100644 index 00000000..b579a147 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt @@ -0,0 +1,18 @@ +package com.daedan.festabook.presentation.error + +import android.app.Application +import android.os.Process +import kotlin.system.exitProcess + +class FestabookGlobalExceptionHandler( + private val application: Application, +) : Thread.UncaughtExceptionHandler { + override fun uncaughtException( + t: Thread, + e: Throwable, + ) { + application.startActivity(ErrorActivity.newIntent(application, e)) + Process.killProcess(Process.myPid()) + exitProcess(-1) + } +} diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_uncaught_error.xml b/composeApp/src/commonMain/composeResources/drawable/ic_uncaught_error.xml index 6f8ce19f..69f51349 100644 --- a/composeApp/src/commonMain/composeResources/drawable/ic_uncaught_error.xml +++ b/composeApp/src/commonMain/composeResources/drawable/ic_uncaught_error.xml @@ -5,9 +5,9 @@ android:viewportHeight="24"> + android:fillColor="#ffffff"/> diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/error/ErrorScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/error/ErrorScreen.kt new file mode 100644 index 00000000..d5735f90 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/error/ErrorScreen.kt @@ -0,0 +1,78 @@ +package com.daedan.festabook.presentation.error + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.ButtonColors +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.ic_uncaught_error +import festabookkmp.composeapp.generated.resources.uncaught_exception_confirm +import festabookkmp.composeapp.generated.resources.uncaught_exception_message +import festabookkmp.composeapp.generated.resources.uncaught_exception_title +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ErrorScreen( + onRestart: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = festabookSpacing.paddingScreenGutter), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(Res.drawable.ic_uncaught_error), + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = FestabookColor.gray400, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody5)) + Text( + text = stringResource(Res.string.uncaught_exception_title), + style = FestabookTypography.displaySmall, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody2)) + Text( + text = stringResource(Res.string.uncaught_exception_message), + style = FestabookTypography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody5)) + Button( + onClick = onRestart, + colors = + ButtonColors( + containerColor = FestabookColor.black, + contentColor = FestabookColor.white, + disabledContainerColor = FestabookColor.gray400, + disabledContentColor = FestabookColor.white, + ), + ) { + Text( + text = stringResource(Res.string.uncaught_exception_confirm), + style = FestabookTypography.bodyMedium, + ) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt index eb68eb1c..f17662e7 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt @@ -7,20 +7,25 @@ import com.daedan.festabook.delegate.DefaultFirebaseMessagingDelegate import com.daedan.festabook.delegate.DefaultUserNotificationDelegate import com.daedan.festabook.delegate.FestabookAppDelegate import com.daedan.festabook.di.IosAppGraph +import com.daedan.festabook.logging.FirebaseCrashlytics import dev.zacsweers.metro.createGraph import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.staticCFunction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import platform.Foundation.NSData +import platform.Foundation.NSException +import platform.Foundation.NSSetUncaughtExceptionHandler import platform.UIKit.UIApplication import platform.UIKit.UIBackgroundFetchResult +import platform.UIKit.UIUserInterfaceStyle +import platform.UIKit.UIWindow import platform.UserNotifications.UNUserNotificationCenter -import kotlin.experimental.ExperimentalObjCName -@OptIn(ExperimentalForeignApi::class, ExperimentalObjCName::class) +@OptIn(ExperimentalForeignApi::class) object DefaultFestabookAppDelegate : FestabookAppDelegate { override val appGraph: IosAppGraph = createGraph() private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -37,12 +42,14 @@ object DefaultFestabookAppDelegate : FestabookAppDelegate { application: UIApplication, launchOptions: Map?, ): Boolean { + setGlobalExceptionHandler() FIRApp.configure() setupNapier() NMFAuthManager.shared().ncpKeyId = BuildKonfig.NAVER_MAP_CLIENT_ID FIRMessaging.messaging().delegate = firebaseMessagingDelegate val center = UNUserNotificationCenter.currentNotificationCenter() center.delegate = userNotificationDelegate + setLightTheme(application) return true } @@ -68,4 +75,20 @@ object DefaultFestabookAppDelegate : FestabookAppDelegate { Napier.base(DebugAntilog()) } } + + @Suppress("UNCHECKED_CAST") + private fun setLightTheme(application: UIApplication) { + UIWindow.appearance().overrideUserInterfaceStyle = + UIUserInterfaceStyle.UIUserInterfaceStyleLight + } + + private fun setGlobalExceptionHandler() { + NSSetUncaughtExceptionHandler( + staticCFunction { exception: NSException? -> + val message = + "${exception?.name ?: "Unknown"}: ${exception?.reason ?: "Unknown error"}" + FirebaseCrashlytics.recordException(Exception(message)) + }, + ) + } } diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index e389672f..57a0d92a 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -10,6 +10,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { ) -> Bool { return festabookAppDelegate.application(application: application, launchOptions: launchOptions) } + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return .portrait + } func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { From c3acd4ef135f5d83ba9e5f55887974d69f6e0618 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:22:01 +0900 Subject: [PATCH 09/54] =?UTF-8?q?[Test]=20ViewModelTest=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 축제 알림 동기화 로직 구현 및 PlaceMap 핸들러 리팩터링 * **feat: 축제 알림 상태 동기화 기능 추가** * `FestivalNotificationRepository`에 서버와 알림 설정을 동기화하는 `syncFestivalNotificationIsAllow` 메서드를 추가했습니다. * `SettingViewModel` 초기화 시 `syncFestivalNotificationIsAllow`를 호출하여 서버 상태를 로컬에 반영하도록 수정했습니다. * 알림 설정 변경 시 `updateNotificationIsAllowed`를 호출하여 UI 상태를 즉시 업데이트하도록 개선했습니다. * `FestivalNotificationRemoteDataSource` 및 `FestivalNotificationService`에 특정 디바이스의 알림 정보를 조회하는 API를 추가했습니다. * 서버 응답 처리를 위한 `RegisteredFestivalNotificationResponse` 데이터 모델을 정의했습니다. * **refactor: PlaceMap 관련 이벤트 핸들러 추상화 및 구현 분리** * `MapControlEventHandler`, `SelectEventHandler`, `FilterEventHandler`를 인터페이스로 정의하고 기존 구현체를 `Impl` 클래스로 변경했습니다. * 인터페이스 기반의 의존성 주입을 위해 `@ContributesBinding` 설정을 업데이트했습니다. * **chore: 기타 변경 사항** * `FestivalNotificationRepositoryImpl` 내 예외 메시지 관리를 위한 `companion object` 상수를 추가했습니다. * `FestabookApp.kt` 내 불필요한 임포트 정리를 수행했습니다. * test: Compose Multiplatform 프로젝트 전 계층에 대한 단위 테스트 추가 * **test: `ViewModel` 및 `ActionHandler` 단위 테스트 구현** * `HomeViewModel`, `ScheduleViewModel`, `NewsViewModel`, `SplashViewModel`, `ExploreViewModel`, `SettingViewModel`, `PlaceDetailViewModel` 및 `PlaceMapViewModel`에 대한 테스트 케이스를 추가하여 각 화면의 상태 관리 및 비즈니스 로직을 검증합니다. * `SelectEventHandler`, `FilterEventHandler`, `MapControlEventHandler` 등 `PlaceMap` 도메인의 세부 액션 핸들러 로직에 대한 테스트를 추가했습니다. * **test: 테스트 전용 픽스처(Fixtures) 및 확장 함수 추가** * 테스트 코드의 재사용성을 높이기 위해 `FAKE_` 접두사를 가진 도메인 모델 픽스처 파일들을 추가했습니다 (`PlaceMapTestFixture`, `ScheduleTestFixtures` 등). * `Flow` 이벤트 검증을 위한 `observeEvent`, `observeMultipleEvent` 확장 함수를 구현하여 비동기 스트림 테스트 편의성을 개선했습니다. * **test: `Repository` 계층 테스트 추가** * `FestivalNotificationRepositoryImpl` 테스트를 통해 알림 권한 동기화, 알림 ID 저장 및 삭제 로직과 서버 실패 시 로컬 상태 원복 로직을 검증합니다. * **chore: 테스트 환경 설정** * `dev.mokkery`를 사용한 모킹(Mocking) 및 `kotlinx-coroutines-test`를 활용한 코루틴 테스트 환경을 구축했습니다. * test: MainViewModel 유닛 테스트 추가 및 기존 테스트 코드 정리 * **test: `MainViewModelTest` 클래스 추가** * 뒤로 가기 버튼 연속 클릭 시 종료 이벤트 발생 로직에 대한 테스트 케이스를 구현했습니다. * `FestivalRepository`를 통한 첫 방문 여부 확인 로직에 대한 테스트 케이스를 추가했습니다. * **refactor: `ScheduleViewModelTest` 내 불필요한 타입 캐스팅 제거** * `ScheduleUiState.Content.Success` 상태 확인 시 중복되는 명시적 타입 변환 로직을 제거하여 코드를 간소화했습니다. * **style: 테스트 메서드 한글 설명 문구 수정** * `FilterActionHandlerTest`, `PlaceDetailViewModelTest` 등 주요 테스트 파일 내 메서드 명칭의 조사 및 어법을 자연스럽게 수정했습니다. --- .../FestivalNotificationRemoteDataSource.kt | 3 + ...estivalNotificationRemoteDataSourceImpl.kt | 6 + .../RegisteredFestivalNotificationResponse.kt | 16 + .../FestivalNotificationRepositoryImpl.kt | 40 +++ .../service/FestivalNotificationService.kt | 7 + .../FestivalNotificationRepository.kt | 2 + .../intent/handler/FilterEventHandler.kt | 20 +- ...ndler.kt => MapControlEventHandlerImpl.kt} | 6 +- ...ntHandler.kt => SelectEventHandlerImpl.kt} | 6 +- .../presentation/setting/SettingViewModel.kt | 10 +- .../festabook/viewModel/FlowExtensions.kt | 41 +++ .../viewModel/explore/ExploreViewModelTest.kt | 193 ++++++++++++ .../viewModel/home/HomeTestFixture.kt | 38 +++ .../viewModel/home/HomeViewModelTest.kt | 159 ++++++++++ .../viewModel/main/MainViewModelTest.kt | 87 ++++++ .../viewModel/news/NewsTestFixture.kt | 59 ++++ .../viewModel/news/NewsViewModelTest.kt | 244 +++++++++++++++ .../placeDetail/PlaceDetailTestFixture.kt | 54 ++++ .../placeDetail/PlaceDetailViewModelTest.kt | 139 +++++++++ .../viewModel/placeMap/PlaceMapTestFixture.kt | 178 +++++++++++ .../placeMap/PlaceMapViewModelTest.kt | 286 ++++++++++++++++++ .../handler/FilterActionHandlerTest.kt | 228 ++++++++++++++ .../handler/MapEventActionHandlerTest.kt | 163 ++++++++++ .../handler/SelectActionHandlerTest.kt | 232 ++++++++++++++ .../schedule/ScheduleTestFixtures.kt | 52 ++++ .../schedule/ScheduleViewModelTest.kt | 132 ++++++++ .../FestivalNotificationRepositoryTest.kt | 199 ++++++++++++ .../viewModel/setting/SettingViewModelTest.kt | 135 +++++++++ .../viewModel/splash/SplashViewModelTest.kt | 96 ++++++ 29 files changed, 2817 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/{MapControlEventHandler.kt => MapControlEventHandlerImpl.kt} (95%) rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/{SelectEventHandler.kt => SelectEventHandlerImpl.kt} (97%) create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/FlowExtensions.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsTestFixture.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailTestFixture.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapTestFixture.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/FilterActionHandlerTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/MapEventActionHandlerTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleTestFixtures.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/FestivalNotificationRepositoryTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/SettingViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/splash/SplashViewModelTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSource.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSource.kt index b5435189..aca75c4c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSource.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSource.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.data.datasource.remote.festival import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse interface FestivalNotificationRemoteDataSource { suspend fun saveFestivalNotification( @@ -10,4 +11,6 @@ interface FestivalNotificationRemoteDataSource { ): ApiResult suspend fun deleteFestivalNotification(festivalNotificationId: Long): ApiResult + + suspend fun getFestivalNotification(deviceId: Long): ApiResult> } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt index 4fa70238..2d3ba3b1 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.data.datasource.remote.festival import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse import com.daedan.festabook.data.service.FestivalNotificationService import com.daedan.festabook.data.service.platform.festivalNotificationPlatform import dev.zacsweers.metro.AppScope @@ -30,4 +31,9 @@ class FestivalNotificationRemoteDataSourceImpl( ApiResult.toApiResult { festivalNotificationService.deleteFestivalNotification(festivalNotificationId) } + + override suspend fun getFestivalNotification(deviceId: Long): ApiResult> = + ApiResult.toApiResult { + festivalNotificationService.getFestivalNotification(deviceId) + } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt new file mode 100644 index 00000000..19769514 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.data.model.response.festival + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisteredFestivalNotificationResponse( + @SerialName("festivalNotificationId") + val festivalNotificationId: Long, + @SerialName("festivalId") + val festivalId: Long, + @SerialName("organizationName") + val organizationName: String, + @SerialName("festivalName") + val festivalName: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt index 468e2cd7..3409856b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt @@ -78,6 +78,39 @@ class FestivalNotificationRepositoryImpl( } } + override suspend fun syncFestivalNotificationIsAllow(): Result { + val deviceId = + deviceLocalDataSource.getDeviceId().firstOrNull() ?: return Result.failure( + IllegalArgumentException(NO_DEVICE_ID_EXCEPTION), + ) + val festivalId = + festivalLocalDataSource.getFestivalId().firstOrNull() ?: return Result.failure( + IllegalArgumentException(NO_FESTIVAL_ID_EXCEPTION), + ) + + return festivalNotificationRemoteDataSource + .getFestivalNotification(deviceId) + .toResult() + .mapCatching { response -> + val notificationId = + response.find { it.festivalId == festivalId }?.festivalNotificationId + val isAllowed = notificationId != null + festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed( + festivalId, + isAllowed, + ) + if (isAllowed) { + festivalNotificationLocalDataSource.saveFestivalNotificationId( + festivalId, + notificationId, + ) + } else { + festivalNotificationLocalDataSource.deleteFestivalNotificationId(festivalId) + } + isAllowed + } + } + @OptIn(ExperimentalCoroutinesApi::class) override fun getFestivalNotificationIsAllow(): Flow = festivalLocalDataSource.getFestivalId().flatMapLatest { festivalId -> @@ -103,4 +136,11 @@ class FestivalNotificationRepositoryImpl( ) } } + + companion object { + private val NO_FESTIVAL_ID_EXCEPTION = + "${::FestivalNotificationRepositoryImpl.name}: FestivalId가 없습니다." + private val NO_DEVICE_ID_EXCEPTION = + "${::FestivalNotificationRepositoryImpl.name}: DeviceId가 없습니다." + } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt index a62f7db6..0128285a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/FestivalNotificationService.kt @@ -2,9 +2,11 @@ package com.daedan.festabook.data.service import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse import de.jensklingenberg.ktorfit.Response import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.POST import de.jensklingenberg.ktorfit.http.Path @@ -20,4 +22,9 @@ interface FestivalNotificationService { suspend fun deleteFestivalNotification( @Path("festivalNotificationId") id: Long, ): Response + + @GET("festivals/notifications/{deviceId}") + suspend fun getFestivalNotification( + @Path("deviceId") id: Long, + ): Response> } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt index edad3d2a..a16cb8b3 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt @@ -7,6 +7,8 @@ interface FestivalNotificationRepository { suspend fun deleteFestivalNotification(): Result + suspend fun syncFestivalNotificationIsAllow(): Result + fun getFestivalNotificationIsAllow(): Flow suspend fun saveFestivalNotificationIsAllow(isAllowed: Boolean): Result diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt index 11f58a8a..56a1deb5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt @@ -20,12 +20,16 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +interface FilterEventHandler : EventHandler { + fun updatePlacesByTimeTag(timeTagId: Long) +} + @Inject @ContributesBinding(PlaceMapViewModelScope::class) -class FilterEventHandler( +class FilterEventHandlerImpl( private val context: EventHandlerContext, // private val logger: DefaultFirebaseLogger, -) : EventHandler { +) : FilterEventHandler { override val uiState: StateFlow = context.uiState override val onUpdateState = context.onUpdateState @@ -78,12 +82,7 @@ class FilterEventHandler( } } - private fun unselectPlace() { - onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } - context.mapControlSideEffect.trySend(MapControlSideEffect.UnselectMarker) - } - - fun updatePlacesByTimeTag(timeTagId: Long) { + override fun updatePlacesByTimeTag(timeTagId: Long) { val filteredPlaces = if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { context.cachedPlaces.value @@ -96,6 +95,11 @@ class FilterEventHandler( context.onUpdateCachedPlace(filteredPlaces) } + private fun unselectPlace() { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } + context.mapControlSideEffect.trySend(MapControlSideEffect.UnselectMarker) + } + private fun updatePlacesByCategories(category: List) { if (category.isEmpty()) { clearPlacesFilter() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandlerImpl.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandlerImpl.kt index bd28dd44..01cfde14 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandlerImpl.kt @@ -13,12 +13,14 @@ import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +interface MapControlEventHandler : EventHandler + @Inject @ContributesBinding(PlaceMapViewModelScope::class) -class MapControlEventHandler( +class MapControlEventHandlerImpl( private val context: EventHandlerContext, // private val logger: DefaultFirebaseLogger, -) : EventHandler { +) : MapControlEventHandler { override val uiState: StateFlow = context.uiState override val onUpdateState = context.onUpdateState diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandlerImpl.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandlerImpl.kt index 6a071b92..fe80666b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandlerImpl.kt @@ -16,14 +16,16 @@ import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +interface SelectEventHandler : EventHandler + @Inject @ContributesBinding(PlaceMapViewModelScope::class) -class SelectEventHandler( +class SelectEventHandlerImpl( private val context: EventHandlerContext, private val filterActionHandler: FilterEventHandler, // private val logger: DefaultFirebaseLogger, private val placeDetailRepository: PlaceDetailRepository, -) : EventHandler { +) : SelectEventHandler { override val uiState: StateFlow = context.uiState override val onUpdateState = context.onUpdateState diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt index 1db1a844..10c231c7 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -39,8 +39,10 @@ class SettingViewModel( init { viewModelScope.launch { festivalNotificationRepository - .getFestivalNotificationIsAllow() - .collect { _isAllowed.value = it } + .syncFestivalNotificationIsAllow() + .onSuccess { + _isAllowed.emit(it) + } } } @@ -76,6 +78,7 @@ class SettingViewModel( // Optimistic UI 적용, 요청 실패 시 원복 viewModelScope.launch { saveNotificationIsAllowed(true) + updateNotificationIsAllowed(true) val result = festivalNotificationRepository.saveFestivalNotification() @@ -86,6 +89,7 @@ class SettingViewModel( }.onFailure { _error.emit(it) saveNotificationIsAllowed(false) + updateNotificationIsAllowed(false) // Timber.e(it, "${this::class.java.simpleName} NotificationId 저장 실패") }.also { _isLoading.value = false @@ -100,6 +104,7 @@ class SettingViewModel( // Optimistic UI 적용, 요청 실패 시 원복 viewModelScope.launch { saveNotificationIsAllowed(false) + updateNotificationIsAllowed(false) val result = festivalNotificationRepository.deleteFestivalNotification() @@ -107,6 +112,7 @@ class SettingViewModel( .onFailure { _error.emit(it) saveNotificationIsAllowed(true) + updateNotificationIsAllowed(true) // Timber.e(it, "${this::class.java.simpleName} NotificationId 삭제 실패") }.also { _isLoading.value = false diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/FlowExtensions.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/FlowExtensions.kt new file mode 100644 index 00000000..16f9d7bf --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/FlowExtensions.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeEvent(flow: Flow): Deferred { + val event = + backgroundScope.async { + withTimeout(3000) { + flow.first() + } + } + advanceUntilIdle() + return event +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeMultipleEvent( + flow: Flow, + result: MutableList, +) { + backgroundScope.launch(UnconfinedTestDispatcher()) { + flow + .timeout(3.seconds) + .collect { + result.add(it) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt new file mode 100644 index 00000000..18b0f06d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt @@ -0,0 +1,193 @@ +package com.daedan.festabook.explore + +import com.daedan.festabook.domain.model.FestivalSearchItem +import com.daedan.festabook.domain.repository.ExploreRepository +import com.daedan.festabook.presentation.explore.ExploreSideEffect +import com.daedan.festabook.presentation.explore.ExploreViewModel +import com.daedan.festabook.presentation.explore.SearchUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.explore.model.toUiModel +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ExploreViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var exploreRepository: ExploreRepository + private lateinit var exploreViewModel: ExploreViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + exploreRepository = mock(MockMode.autofill) + everySuspend { exploreRepository.search(any()) } returns Result.success(emptyList()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `뷰모델을 생성하면 저장된 축제 id가 있는지 확인한다`() = + runTest { + // given + everySuspend { exploreRepository.getFestivalId() } returns 1L + + // when + exploreViewModel = ExploreViewModel(exploreRepository) + advanceUntilIdle() + + // then + verifySuspend { exploreRepository.getFestivalId() } + assertTrue(exploreViewModel.uiState.value.hasFestivalId) + } + + @Test + fun `검색어가 변경되면 query 상태가 업데이트된다`() = + runTest { + // given + exploreViewModel = ExploreViewModel(exploreRepository) + val query = "테스트" + + // when + exploreViewModel.onTextInputChanged(query) + advanceUntilIdle() + + // then + assertEquals(query, exploreViewModel.uiState.value.query) + } + + @Test + fun `검색어가 변경되고 일정 시간이 지나면 검색을 수행하고 성공 시 결과를 업데이트한다`() = + runTest { + // given + exploreViewModel = ExploreViewModel(exploreRepository) + val query = "테스트" + val universities = + listOf( + FestivalSearchItem( + festivalId = 1L, + organizationName = "테스트대학교", + festivalName = "테스트축제", + startDate = "2024-05-01", + endDate = "2024-05-03", + ), + FestivalSearchItem( + festivalId = 2L, + organizationName = "테스트대학교2", + festivalName = "테스트축제2", + startDate = "2024-09-01", + endDate = "2024-09-03", + ), + ) + val uiModels = universities.map { it.toUiModel() } + + everySuspend { exploreRepository.search(query) } returns Result.success(universities) + + // when + exploreViewModel.onTextInputChanged(query) + advanceTimeBy(500L) + advanceUntilIdle() + + // then + verifySuspend { exploreRepository.search(query) } + val searchState = exploreViewModel.uiState.value.searchState + assertIs(searchState) + assertEquals(uiModels, searchState.universitiesFound) + } + + @Test + fun `검색어가 비어있으면 Idle 상태로 변경된다`() = + runTest { + // given + exploreViewModel = ExploreViewModel(exploreRepository) + exploreViewModel.onTextInputChanged("이전검색어") + advanceTimeBy(500L) + advanceUntilIdle() + + // when + exploreViewModel.onTextInputChanged("") + advanceTimeBy(500L) + advanceUntilIdle() + + // then + assertEquals(SearchUiState.Idle, exploreViewModel.uiState.value.searchState) + } + + @Test + fun `검색 실패 시 Error 상태로 업데이트된다`() = + runTest { + // given + exploreViewModel = ExploreViewModel(exploreRepository) + val query = "에러발생" + val exception = Exception("Network Error") + everySuspend { exploreRepository.search(query) } returns Result.failure(exception) + + // when + exploreViewModel.onTextInputChanged(query) + advanceTimeBy(500L) + advanceUntilIdle() + + // then + verifySuspend { exploreRepository.search(query) } + val searchState = exploreViewModel.uiState.value.searchState + assertIs(searchState) + assertEquals(exception, searchState.throwable) + } + + @Test + fun `대학교가 선택되었을 때 축제 Id를 저장하고 Main으로 이동하는 이벤트를 발생시킨다`() = + runTest { + // given + exploreViewModel = ExploreViewModel(exploreRepository) + val searchResult = + SearchResultUiModel( + 1L, + "테스트대학교", + "테스트축제", + ) + val collectedSideEffects = mutableListOf() + + val job = + launch(UnconfinedTestDispatcher(testScheduler)) { + exploreViewModel.sideEffect.collect { + collectedSideEffects.add(it) + } + } + + // when + exploreViewModel.onUniversitySelected(searchResult) + advanceUntilIdle() + + // then + verifySuspend { exploreRepository.saveFestivalId(searchResult.festivalId) } + assertEquals(1, collectedSideEffects.size) + assertEquals( + ExploreSideEffect.NavigateToMain(searchResult), + collectedSideEffects.first(), + ) + + job.cancel() + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt new file mode 100644 index 00000000..301953ac --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt @@ -0,0 +1,38 @@ +package com.daedan.festabook.home + +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.LineupItem +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.domain.model.Poster +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime + +val FAKE_ORGANIZATION = + Organization( + id = 1, + organizationName = "하버드 대학교", + festival = + Festival( + festivalName = "하버드 대동제", + festivalImages = + listOf( + Poster( + id = 1, + imageUrl = "", + sequence = 1, + ), + ), + startDate = LocalDate(2025, 1, 1), + endDate = LocalDate(2025, 1, 3), + ), + ) + +val FAKE_LINEUP = + listOf( + LineupItem( + id = 1L, + imageUrl = "aa.com", + name = "SINGER", + performanceAt = LocalDateTime(2025, 9, 16, 0, 0, 0), + ), + ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt new file mode 100644 index 00000000..ec38ba52 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt @@ -0,0 +1,159 @@ +package com.daedan.festabook.home + +import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.presentation.home.FestivalUiState +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel +import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.toUiModel +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var homeViewModel: HomeViewModel + private lateinit var festivalRepository: FestivalRepository + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + festivalRepository = mock() + everySuspend { festivalRepository.getFestivalInfo() } returns + Result.success( + FAKE_ORGANIZATION, + ) + everySuspend { festivalRepository.getLineUpGroupByDate() } returns + Result.success( + mapOf( + FAKE_LINEUP[0].performanceAt.date to FAKE_LINEUP, + ), + ) + + homeViewModel = HomeViewModel(festivalRepository) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `축제 정보를 불러올 수 있다`() = + runTest { + // given + val expect = FestivalUiState.Success(FAKE_ORGANIZATION) + + // when + homeViewModel.loadFestival() + advanceUntilIdle() + + // then + val actual = homeViewModel.festivalUiState.value + assertIs(actual) + assertEquals(expect, actual) + } + + @Test + fun `연예인 정보를 불러올 수 있다`() = + runTest { + // given + val expectedLineup = + listOf( + LineUpItemOfDayUiModel( + id = 0, + date = FAKE_LINEUP[0].performanceAt.date, + isDDay = false, + lineupItems = FAKE_LINEUP.map { it.toUiModel() }, + ), + ) + + // when + advanceUntilIdle() + + // then + val actual = homeViewModel.lineupUiState.value + assertIs(actual) + + val actualItems = actual.lineups + assertEquals(expectedLineup.size, actualItems.size) + expectedLineup.zip(actualItems).forEach { (expected, actualItem) -> + assertEquals(expected.date, actualItem.date) + assertEquals(expected.isDDay, actualItem.isDDay) + assertEquals(expected.lineupItems, actualItem.lineupItems) + } + } + + @Test + fun `축제 정보를 불러오는 동안은 Loading 상태로 전환한다`() = + runTest { + // given + val results = mutableListOf() + val job = + launch(UnconfinedTestDispatcher()) { + homeViewModel.festivalUiState.collect { results.add(it) } + } + + // when + homeViewModel.loadFestival() + + // then + testScheduler.runCurrent() + assertTrue(FestivalUiState.Loading in results) + + advanceUntilIdle() + assertIs(results.last()) + + job.cancel() + } + + @Test + fun `축제 정보를 불러오는 데 실패하면 Error 상태로 전환한다`() = + runTest { + // given + val exception = Throwable("Network Error") + everySuspend { festivalRepository.getFestivalInfo() } returns Result.failure(exception) + + // when + homeViewModel.loadFestival() + advanceUntilIdle() + + // then + assertIs(homeViewModel.festivalUiState.value) + } + + @Test + fun `스케줄 이동 이벤트를 발생시킬 수 있다`() = + runTest { + // given + val events = mutableListOf() + val job = + launch(UnconfinedTestDispatcher()) { + homeViewModel.navigateToScheduleEvent.collect { events.add(it) } + } + + // when + homeViewModel.navigateToScheduleClick() + + // then + assertEquals(1, events.size) + + job.cancel() + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt new file mode 100644 index 00000000..1014d50d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt @@ -0,0 +1,87 @@ +package com.daedan.festabook.main + +import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.observeMultipleEvent +import com.daedan.festabook.presentation.main.MainViewModel +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var festivalRepository: FestivalRepository + private lateinit var mainViewModel: MainViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + festivalRepository = mock(MockMode.autofill) + every { festivalRepository.getIsFirstVisit() } returns flowOf(false) + mainViewModel = MainViewModel(festivalRepository) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `뒤로 가기를 두 번 빠르게 두 번 클릭했을 때 종료 이벤트가 발생한다`() = + runTest { + // given + val events = mutableListOf() + observeMultipleEvent(mainViewModel.backPressEvent, events) + + // when + mainViewModel.onBackPressed() + mainViewModel.onBackPressed() + advanceUntilIdle() + + // then + assertTrue(events.last()) + } + + @Test + fun `뒤로 가기를 한 번만 클릭했을 때 종료 이벤트가 발생하지 않는다`() = + runTest { + // given + val events = mutableListOf() + observeMultipleEvent(mainViewModel.backPressEvent, events) + + // when + mainViewModel.onBackPressed() + advanceUntilIdle() + + // then + assertFalse(events.last()) + } + + @Test + fun `축제 페이지의 첫 방문 여부를 확인할 수 있다`() = + runTest { + // given + every { festivalRepository.getIsFirstVisit() } returns flowOf(true) + + // when + mainViewModel = MainViewModel(festivalRepository) + advanceUntilIdle() + + // then + assertTrue(mainViewModel.isFirstVisit.value) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsTestFixture.kt new file mode 100644 index 00000000..36b2cc15 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsTestFixture.kt @@ -0,0 +1,59 @@ +package com.daedan.festabook.news + +import com.daedan.festabook.domain.model.FAQItem +import com.daedan.festabook.domain.model.Lost +import com.daedan.festabook.domain.model.LostItemStatus +import com.daedan.festabook.domain.model.Notice +import com.daedan.festabook.presentation.news.lost.model.toLostGuideItemUiModel +import com.daedan.festabook.presentation.news.lost.model.toLostItemUiModel +import kotlinx.datetime.LocalDateTime + +val FAKE_NOTICES = + listOf( + Notice( + id = 1, + title = "테스트 1", + content = "테스트 1", + isPinned = false, + createdAt = LocalDateTime(2025, 1, 1, 0, 0, 0), + ), + Notice( + id = 2, + title = "테스트 2", + content = "테스트 2", + isPinned = true, + createdAt = LocalDateTime(2025, 1, 1, 0, 0, 0), + ), + ) + +val FAKE_FAQS = + listOf( + FAQItem( + questionId = 1, + question = "테스트 질문 1", + answer = "테스트 답변 1", + sequence = 1, + ), + ) + +val FAKE_LOST_ITEM = + listOf( + Lost.Guide( + guide = "테스트 가이드", + ), + Lost.Item( + lostItemId = 1, + imageUrl = "테스트 이미지 주소", + storageLocation = "테스트 장소", + status = LostItemStatus.PENDING, + createdAt = LocalDateTime(2025, 1, 1, 0, 0, 0), + ), + ) + +val FAKE_LOST_ITEM_UI_MODEL = + FAKE_LOST_ITEM.map { + when (it) { + is Lost.Guide -> it.toLostGuideItemUiModel() + is Lost.Item -> it.toLostItemUiModel() + } + } diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt new file mode 100644 index 00000000..4de4c8fa --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt @@ -0,0 +1,244 @@ +package com.daedan.festabook.news + +import com.daedan.festabook.domain.model.Lost +import com.daedan.festabook.domain.repository.FAQRepository +import com.daedan.festabook.domain.repository.LostItemRepository +import com.daedan.festabook.domain.repository.NoticeRepository +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.faq.FAQUiState +import com.daedan.festabook.presentation.news.faq.model.toUiModel +import com.daedan.festabook.presentation.news.lost.LostUiState +import com.daedan.festabook.presentation.news.lost.model.LostUiModel +import com.daedan.festabook.presentation.news.lost.model.toLostGuideItemUiModel +import com.daedan.festabook.presentation.news.lost.model.toLostItemUiModel +import com.daedan.festabook.presentation.news.notice.NoticeUiState +import com.daedan.festabook.presentation.news.notice.NoticeUiState.Companion.DEFAULT_POSITION +import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.news.notice.model.toUiModel +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class NewsViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var noticeRepository: NoticeRepository + private lateinit var faqRepository: FAQRepository + private lateinit var lostItemRepository: LostItemRepository + private lateinit var newsViewModel: NewsViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + noticeRepository = mock() + faqRepository = mock() + lostItemRepository = mock() + everySuspend { noticeRepository.fetchNotices() } returns Result.success(FAKE_NOTICES) + everySuspend { faqRepository.getAllFAQ() } returns Result.success(FAKE_FAQS) + everySuspend { lostItemRepository.getLost() } returns FAKE_LOST_ITEM + + newsViewModel = + NewsViewModel( + noticeRepository, + faqRepository, + lostItemRepository, + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `공지사항을 불러올 수 있다`() = + runTest { + // given + everySuspend { noticeRepository.fetchNotices() } returns Result.success(FAKE_NOTICES) + + // when + newsViewModel.loadAllNotices(NoticeUiState(content = NoticeUiState.Content.InitialLoading)) + + // then + val expected = FAKE_NOTICES.map { it.toUiModel() } + val actual = newsViewModel.noticeUiState.value + verifySuspend { noticeRepository.fetchNotices() } + assertEquals( + NoticeUiState(content = NoticeUiState.Content.Success(expected, DEFAULT_POSITION)), + actual, + ) + } + + @Test + fun `분실물을 불러올 수 있다`() = + runTest { + // given + val expected = + LostUiState( + content = + LostUiState.Content.Success( + listOf( + (FAKE_LOST_ITEM[0] as Lost.Guide).toLostGuideItemUiModel(), + (FAKE_LOST_ITEM[1] as Lost.Item).toLostItemUiModel(), + ), + ), + ) + + // when + newsViewModel.loadAllLostItems(LostUiState(content = LostUiState.Content.InitialLoading)) + + // then + val actual = newsViewModel.lostUiState.value + verifySuspend { lostItemRepository.getLost() } + assertEquals(expected, actual) + } + + @Test + fun `공지사항 로드에 실패하면 에러 상태를 표시한다`() = + runTest { + // given + val exception = Throwable("테스트") + everySuspend { noticeRepository.fetchNotices() } returns Result.failure(exception) + + // when + newsViewModel.loadAllNotices(NoticeUiState(content = NoticeUiState.Content.InitialLoading)) + + // then + val expected = NoticeUiState(content = NoticeUiState.Content.Error(exception)) + val actual = newsViewModel.noticeUiState.value + verifySuspend { noticeRepository.fetchNotices() } + assertEquals(expected, actual) + } + + @Test + fun `뷰모델을 생성하면 FAQ를 불러올 수 있다`() = + runTest { + // given + everySuspend { faqRepository.getAllFAQ() } returns Result.success(FAKE_FAQS) + + // when + newsViewModel = NewsViewModel(noticeRepository, faqRepository, lostItemRepository) + + // then + val expected = FAKE_FAQS.map { it.toUiModel() } + val actual = newsViewModel.faqUiState.value + verifySuspend { faqRepository.getAllFAQ() } + assertEquals(FAQUiState.Success(expected), actual) + } + + @Test + fun `FAQ 로드에 실패하면 에러 상태를 표시한다`() = + runTest { + // given + val exception = Throwable("테스트") + everySuspend { faqRepository.getAllFAQ() } returns Result.failure(exception) + + // when + newsViewModel = NewsViewModel(noticeRepository, faqRepository, lostItemRepository) + + // then + val expected = FAQUiState.Error(exception) + val actual = newsViewModel.faqUiState.value + verifySuspend { faqRepository.getAllFAQ() } + assertEquals(expected, actual) + } + + @Test + fun `공지사항 요소를 펼치게 할 수 있다`() = + runTest { + // given + val notice = FAKE_NOTICES.first().toUiModel() + + // when + newsViewModel.toggleNotice(notice) + + // then + val notices = + listOf( + notice.copy(isExpanded = true), + NoticeUiModel( + id = 2, + title = "테스트 2", + content = "테스트 2", + isPinned = true, + createdAt = kotlinx.datetime.LocalDateTime(2025, 1, 1, 0, 0, 0), + ), + ) + val actual = newsViewModel.noticeUiState.value + val expected = + NoticeUiState(content = NoticeUiState.Content.Success(notices, DEFAULT_POSITION)) + assertEquals(expected, actual) + } + + @Test + fun `FAQ 요소를 펼치게 할 수 있다`() = + runTest { + // given + val faq = FAKE_FAQS.first().toUiModel() + + // when + newsViewModel.toggleFAQ(faq) + + // then + val expected = listOf(faq.copy(isExpanded = true)) + val actual = newsViewModel.faqUiState.value + assertEquals(FAQUiState.Success(expected), actual) + } + + @Test + fun `처음 로드했을 때 펼처질 공지사항을 지정할 수 있다`() = + runTest { + // given + everySuspend { noticeRepository.fetchNotices() } returns Result.success(FAKE_NOTICES) + val notices = + listOf( + FAKE_NOTICES.first().toUiModel(), + FAKE_NOTICES[1].toUiModel().copy(isExpanded = true), + ) + + // when + newsViewModel.expandNotice(2) + + // then + val actual = newsViewModel.noticeUiState.value + val expected = NoticeUiState(content = NoticeUiState.Content.Success(notices, 1)) + verifySuspend { noticeRepository.fetchNotices() } + assertEquals(expected, actual) + } + + @Test + fun `분실물 가이드라인을 펼칠 수 있다`() = + runTest { + // given + val expected = + LostUiState( + content = + LostUiState.Content.Success( + FAKE_LOST_ITEM_UI_MODEL.map { + when (it) { + is LostUiModel.Guide -> it.copy(isExpanded = true) + else -> it + } + }, + ), + ) + + // when + newsViewModel.toggleLostGuide() + + // then + val actual = newsViewModel.lostUiState.value + assertEquals(expected, actual) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailTestFixture.kt new file mode 100644 index 00000000..63072aaa --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailTestFixture.kt @@ -0,0 +1,54 @@ +package com.daedan.festabook.placeDetail + +import com.daedan.festabook.domain.model.Place +import com.daedan.festabook.domain.model.PlaceCategory +import com.daedan.festabook.domain.model.PlaceDetail +import com.daedan.festabook.domain.model.PlaceDetailImage +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.news.FAKE_NOTICES +import com.daedan.festabook.placeMap.FAKE_PLACES +import kotlinx.datetime.LocalTime + +val FAKE_PLACE_DETAIL = + PlaceDetail( + id = 1, + place = FAKE_PLACES.first(), + notices = FAKE_NOTICES, + host = "테스트 1", + startTime = LocalTime(9, 0, 0), + endTime = LocalTime(18, 0, 0), + images = + listOf( + PlaceDetailImage( + id = 1, + imageUrl = "", + sequence = 1, + ), + ), + ) + +val FAKE_ETC_PLACE_DETAIL = + PlaceDetail( + id = 1, + place = + Place( + id = 1, + title = "화장실", + category = PlaceCategory.TOILET, + imageUrl = null, + description = null, + location = null, + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), + ), + notices = emptyList(), + host = null, + startTime = null, + endTime = null, + images = emptyList(), + ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailViewModelTest.kt new file mode 100644 index 00000000..f97313fa --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailViewModelTest.kt @@ -0,0 +1,139 @@ +package com.daedan.festabook.placeDetail + +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.news.FAKE_NOTICES +import com.daedan.festabook.placeMap.FAKE_PLACES +import com.daedan.festabook.presentation.news.notice.model.toUiModel +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState +import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify.VerifyMode +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaceDetailViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var placeDetailRepository: PlaceDetailRepository + private lateinit var placeDetailViewModel: PlaceDetailViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + placeDetailRepository = mock() + everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + placeDetailViewModel = + PlaceDetailViewModel(placeDetailRepository, FAKE_PLACES.first().toUiModel(), null) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `플레이스 상세 정보를 불러올 수 있다`() = + runTest { + // given + everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + + // when + placeDetailViewModel.loadPlaceDetail(1) + advanceUntilIdle() + + // then + val expected = FAKE_PLACE_DETAIL.toUiModel() + val actual = placeDetailViewModel.placeDetail.value + verifySuspend { placeDetailRepository.getPlaceDetail(FAKE_PLACES.first().id) } + assertEquals(PlaceDetailUiState.Success(expected), actual) + } + + @Test + fun `플레이스 상세 정보 로드에 실파하면 에러 상태를 표시한다`() = + runTest { + // given + val exception = Throwable("테스트") + everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns Result.failure(exception) + + // then + placeDetailViewModel.loadPlaceDetail( + placeId = 1, + ) + advanceUntilIdle() + + // then + val expected = PlaceDetailUiState.Error(exception) + val actual = placeDetailViewModel.placeDetail.value + verifySuspend { placeDetailRepository.getPlaceDetail(FAKE_PLACES.first().id) } + assertEquals(expected, actual) + } + + @Test + fun `처음 뷰모델 생성 시에 플레이스 상세 정보가 있다면 서버에 요청하지 않는다`() = + runTest { + // given + val expected = FAKE_PLACE_DETAIL.toUiModel() + val placeDetailRepository = mock() + + // when + placeDetailViewModel = + PlaceDetailViewModel(placeDetailRepository, null, expected) + + // then + verifySuspend(VerifyMode.exactly(0)) { placeDetailRepository.getPlaceDetail(any()) } + val actual = placeDetailViewModel.placeDetail.value + assertEquals( + PlaceDetailUiState.Success(expected), + actual, + ) + } + + @Test + fun `플레이스 공지사항을 펼칠 수 있다`() = + runTest { + // given + val noticeItem = FAKE_NOTICES.first().toUiModel() + val expected = + listOf( + noticeItem.copy(isExpanded = true), + FAKE_NOTICES[1].toUiModel(), + ) + + // when + placeDetailViewModel.toggleNoticeExpanded(noticeItem) + + // then + val actual = + placeDetailViewModel.placeDetail + .value + .let { + (it as? PlaceDetailUiState.Success) + ?: fail("PlaceDetailUiState 가 성공 상태가 아님") + }.placeDetail + .notices + + assertEquals(expected, actual) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapTestFixture.kt new file mode 100644 index 00000000..954f8724 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapTestFixture.kt @@ -0,0 +1,178 @@ +package com.daedan.festabook.placeMap + +import com.daedan.festabook.domain.model.Coordinate +import com.daedan.festabook.domain.model.OrganizationGeography +import com.daedan.festabook.domain.model.Place +import com.daedan.festabook.domain.model.PlaceCategory +import com.daedan.festabook.domain.model.PlaceGeography +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeMap.model.CoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel + +val FAKE_PLACES = + listOf( + Place( + id = 1, + imageUrl = null, + category = PlaceCategory.FOOD_TRUCK, + title = "테스트 1", + description = "설명 1", + location = "위치 1", + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + Place( + id = 2, + imageUrl = null, + category = PlaceCategory.FOOD_TRUCK, + title = "테스트 2", + description = "설명 2", + location = "위치 2", + timeTags = + listOf( + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + ) + +val FAKE_PLACES_CATEGORY_FIXTURE = + listOf( + Place( + id = 1, + imageUrl = null, + category = PlaceCategory.FOOD_TRUCK, + title = "테스트 1", + description = "설명 1", + location = "위치 1", + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + Place( + id = 2, + imageUrl = null, + category = PlaceCategory.BAR, + title = "테스트 2", + description = "설명 2", + location = "위치 2", + timeTags = + listOf( + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + ) + +val FAKE_PLACE_GEOGRAPHIES = + listOf( + PlaceGeography( + id = 1, + category = PlaceCategory.FOOD_TRUCK, + Coordinate( + latitude = 1.0, + longitude = 1.0, + ), + "푸드트럭", + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), + ), + PlaceGeography( + id = 1, + category = PlaceCategory.BOOTH, + Coordinate( + latitude = 1.0, + longitude = 1.0, + ), + "부스", + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), + ), + PlaceGeography( + id = 1, + category = PlaceCategory.BAR, + Coordinate( + latitude = 1.0, + longitude = 1.0, + ), + "주점", + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), + ), + ) + +val FAKE_ORGANIZATION_GEOGRAPHY = + OrganizationGeography( + zoom = 15, + initialCenter = + Coordinate( + latitude = 1.0, + longitude = 1.0, + ), + polygonHoleBoundary = + listOf( + Coordinate( + latitude = 1.0, + longitude = 1.0, + ), + ), + ) + +val FAKE_TIME_TAG = + TimeTag( + timeTagId = 1, + name = "테스트1", + ) + +val FAKE_INITIAL_MAP_SETTING = + InitialMapSettingUiModel( + zoom = 10, + initialCenter = + CoordinateUiModel( + latitude = 10.0, + longitude = 10.0, + ), + border = + listOf( + CoordinateUiModel( + latitude = 10.0, + longitude = 10.0, + ), + ), + ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt new file mode 100644 index 00000000..294da693 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt @@ -0,0 +1,286 @@ +package com.daedan.festabook.placeMap + +import com.daedan.festabook.di.placeMapHandler.PlaceMapHandlerGraph +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceListRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaceMapViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + private val handlerGraphFactory = mock(MockMode.autofill) + private lateinit var placeListRepository: PlaceListRepository + private lateinit var placeMapViewModel: PlaceMapViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + placeListRepository = mock(MockMode.autofill) + everySuspend { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) + everySuspend { placeListRepository.getPlaceGeographies() } returns + Result.success( + FAKE_PLACE_GEOGRAPHIES, + ) + everySuspend { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + everySuspend { placeListRepository.getTimeTags() } returns + Result.success( + listOf( + FAKE_TIME_TAG, + ), + ) + + placeMapViewModel = + PlaceMapViewModel( + placeListRepository, + handlerGraphFactory, + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `뷰모델을 생성했을 때 전체 타임태그와 선택된 타임태그를 불러올 수 있다`() = + runTest { + // given - when + placeMapViewModel = + PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val actualAllTimeTag = uiState.timeTags + val actualSelectedTimeTag = uiState.selectedTimeTag + assertEquals( + LoadState.Success(listOf(FAKE_TIME_TAG)), + actualAllTimeTag, + ) + assertEquals( + LoadState.Success(FAKE_TIME_TAG), + actualSelectedTimeTag, + ) + } + + @Test + fun `뷰모델을 생성했을 때 모든 플레이스 정보를 불러올 수 있다`() = + runTest { + // given + everySuspend { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_PLACES.map { it.toUiModel() } + val uiState = placeMapViewModel.uiState.value + val actual = uiState.places + verifySuspend { placeListRepository.getPlaces() } + assertEquals(ListLoadState.PlaceLoaded(expected), actual) + } + + @Test + fun `뷰모델을 생성했을 때 타임 태그가 없다면 빈 리스트와 Empty타임 태그를 불러온다`() = + runTest { + // given + everySuspend { + placeListRepository.getTimeTags() + } returns Result.success(emptyList()) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val actualAllTimeTag = uiState.timeTags + val actualSelectedTimeTag = uiState.selectedTimeTag + assertEquals( + LoadState.Success(emptyList()), + actualAllTimeTag, + ) + assertEquals(LoadState.Empty, actualSelectedTimeTag) + } + + @Test + fun `뷰모델을 생성했을 때 모든 플레이스의 지도 좌표 정보를 불러올 수 있다`() = + runTest { + // given + everySuspend { placeListRepository.getPlaceGeographies() } returns + Result.success( + FAKE_PLACE_GEOGRAPHIES, + ) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_PLACE_GEOGRAPHIES.map { it.toUiModel() } + val uiState = placeMapViewModel.uiState.value + val actual = uiState.placeGeographies + verifySuspend { placeListRepository.getPlaceGeographies() } + assertEquals(LoadState.Success(expected), actual) + } + + @Test + fun `뷰모델을 생성했을 때 초기 학교 지리 정보를 불러올 수 있다`() = + runTest { + // given + everySuspend { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_ORGANIZATION_GEOGRAPHY.toUiModel() + val uiState = placeMapViewModel.uiState.value + val actual = uiState.initialMapSetting + assertEquals(LoadState.Success(expected), actual) + } + + @Test + fun `뷰모델을 생성했을 때 정보 로드에 실패하면 독립적으로 에러 상태를 표시한다`() = + runTest { + // given + val exception = Throwable("테스트") + everySuspend { placeListRepository.getPlaces() } returns Result.failure(exception) + everySuspend { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + everySuspend { placeListRepository.getPlaceGeographies() } returns Result.failure(exception) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + assertEquals(LoadState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()), uiState.initialMapSetting) + assertEquals(LoadState.Error(exception), uiState.placeGeographies) + } + + @Test + fun `특정 액션을 받으면 액션 핸들러가 호출된다`() = + runTest { + // given + val fakeHandlerGraph = mock(MockMode.original) + every { fakeHandlerGraph.filterEventHandler } returns mock(MockMode.autofill) + every { fakeHandlerGraph.selectEventHandler } returns mock(MockMode.autofill) + every { fakeHandlerGraph.mapControlEventHandler } returns mock(MockMode.autofill) + every { + handlerGraphFactory.create(any()) + } returns fakeHandlerGraph + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onPlaceMapEvent(FilterEvent.OnPlaceLoad) + placeMapViewModel.onPlaceMapEvent(MapControlEvent.OnMapDrag) + advanceUntilIdle() + + // then + verify(VerifyMode.exactly(1)) { fakeHandlerGraph.filterEventHandler } + verify(VerifyMode.exactly(1)) { fakeHandlerGraph.selectEventHandler } + verify(VerifyMode.exactly(1)) { fakeHandlerGraph.mapControlEventHandler } + } + + @Test + fun `메뉴 아이템 재클릭 이벤트를 발송할 수 있다`() = + runTest { + // given + val event = observeEvent(placeMapViewModel.placeMapSideEffect) + + // when + placeMapViewModel.onMenuItemReClicked() + val result = event.await() + advanceUntilIdle() + + // then + assertIs(result) + } + + @Test + fun `LoadState가 하나라도 에러가 있다면 에러 이벤트를 발송할 수 있다`() = + runTest { + // given + val throwable = Throwable() + everySuspend { placeListRepository.getPlaceGeographies() } returns Result.failure(throwable) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + val event = observeEvent(placeMapViewModel.placeMapSideEffect) + advanceUntilIdle() + + // then + val result = event.await() + advanceUntilIdle() + + assertEquals( + PlaceMapSideEffect.ShowErrorSnackBar(LoadState.Error(throwable)), + result, + ) + } + + @Test + fun `ListLoadState가 하나라도 에러가 있다면 에러 이벤트를 발송할 수 있다`() = + runTest { + // given + val throwable = Throwable() + everySuspend { placeListRepository.getPlaces() } returns Result.failure(throwable) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + val event = observeEvent(placeMapViewModel.placeMapSideEffect) + advanceUntilIdle() + + // then + val result = event.await() + advanceUntilIdle() + + assertEquals( + PlaceMapSideEffect.ShowErrorSnackBar(LoadState.Error(throwable)), + result, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/FilterActionHandlerTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/FilterActionHandlerTest.kt new file mode 100644 index 00000000..daa39ef5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/FilterActionHandlerTest.kt @@ -0,0 +1,228 @@ +package com.daedan.festabook.placeMap.handler + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.observeMultipleEvent +import com.daedan.festabook.placeMap.FAKE_PLACES_CATEGORY_FIXTURE +import com.daedan.festabook.placeMap.FAKE_TIME_TAG +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.FilterEventHandlerImpl +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import dev.mokkery.MockMode +import dev.mokkery.mock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class FilterActionHandlerTest { + private val testDispatcher = StandardTestDispatcher() + + private lateinit var filterActionHandler: FilterEventHandlerImpl + + private lateinit var cachedPlaces: MutableStateFlow> + + private lateinit var uiState: MutableStateFlow + + private val cachedPlaceByTimeTag = + MutableStateFlow(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + uiState = MutableStateFlow(PlaceMapUiState()) + cachedPlaces = MutableStateFlow(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + + filterActionHandler = + FilterEventHandlerImpl( + EventHandlerContext( + uiState = uiState, + mapControlSideEffect = mapControlUiEvent, + onUpdateState = { uiState.update(it) }, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + scope = CoroutineScope(testDispatcher), + placeMapSideEffect = mock(MockMode.autofill), + ), + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `선택된 카테고리 값을 선택하면 카테고리 필터 이벤트가 방출되고 카테고리를 필터링 할 수 있다`() = + runTest { + // given + val categories = setOf(PlaceCategoryUiModel.BOOTH) + val eventResult = mutableListOf() + observeMultipleEvent(mapControlUiEvent.consumeAsFlow(), eventResult) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterEvent.OnCategoryClick(categories)) + + // then + advanceUntilIdle() + + assertEquals(categories, uiState.value.selectedCategories) + + assertEquals( + listOf( + MapControlSideEffect.UnselectMarker, + MapControlSideEffect.FilterMapByCategory(categories.toList()), + ), + eventResult, + ) + assertEquals( + ListLoadState.Success(emptyList()), + uiState.value.places, + ) + } + + @Test + fun `선택된 카테고리가 부스 주점 푸드트럭에 해당되지 않을 때 전체 목록을 불러온다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.SMOKING_AREA, PlaceCategoryUiModel.TOILET) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterEvent.OnCategoryClick(targetCategories)) + advanceUntilIdle() + + // then + val expected = + ListLoadState.Success( + FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }, + ) + assertEquals(expected, uiState.value.places) + } + + @Test + fun `기타 카테고리만 선택되었다면 전체 목록을 불러온다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.TOILET, PlaceCategoryUiModel.SMOKING_AREA) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterEvent.OnCategoryClick(targetCategories)) + advanceUntilIdle() + + // then + val expected = + ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + assertEquals(expected, uiState.value.places) + } + + @Test + fun `필터링을 해제하면 전체 목록을 반환한다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + filterActionHandler(FilterEvent.OnCategoryClick(targetCategories)) + + // when + filterActionHandler(FilterEvent.OnCategoryClick(emptySet())) + + // then + val expected = FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() } + assertEquals(ListLoadState.Success(expected), uiState.value.places) + } + + @Test + fun `타임 태그를 기준으로 필터링 할 수 있다`() = + runTest { + // given + val expected = + listOf( + FAKE_PLACES_CATEGORY_FIXTURE.first().toUiModel(), + ) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler.updatePlacesByTimeTag(FAKE_TIME_TAG.timeTagId) + + // then + assertEquals(ListLoadState.Success(expected), uiState.value.places) + } + + @Test + fun `타임 태그가 없을 때 전체 목록을 반환한다`() = + runTest { + // given + val expected = FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() } + val emptyTimeTag = TimeTag.EMPTY + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler.updatePlacesByTimeTag(emptyTimeTag.timeTagId) + advanceUntilIdle() + + // then + assertEquals(ListLoadState.Success(expected), uiState.value.places) + } + + @Test + fun `플레이스가 로드가 완료되었을 때 선택된 타임 태그로 필터링할 수 있다`() = + runTest { + // given + val expected = + listOf( + FAKE_PLACES_CATEGORY_FIXTURE.first().toUiModel(), + ) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { + it.copy( + places = places, + selectedTimeTag = LoadState.Success(FAKE_TIME_TAG), + ) + } + + // when + filterActionHandler(FilterEvent.OnPlaceLoad) + advanceUntilIdle() + + // then + assertEquals(ListLoadState.Success(expected), uiState.value.places) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/MapEventActionHandlerTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/MapEventActionHandlerTest.kt new file mode 100644 index 00000000..bd8b435f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/MapEventActionHandlerTest.kt @@ -0,0 +1,163 @@ +package com.daedan.festabook.placeMap.handler + +import com.daedan.festabook.observeEvent +import com.daedan.festabook.observeMultipleEvent +import com.daedan.festabook.placeMap.FAKE_INITIAL_MAP_SETTING +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlEventHandlerImpl +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class MapEventActionHandlerTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mapEventActionHandler: MapControlEventHandlerImpl + + private lateinit var uiState: MutableStateFlow + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val placeMapUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + uiState = MutableStateFlow(PlaceMapUiState()) + mapEventActionHandler = + MapControlEventHandlerImpl( + EventHandlerContext( + uiState = uiState, + onUpdateState = { uiState.update(it) }, + mapControlSideEffect = mapControlUiEvent, + placeMapSideEffect = placeMapUiEvent, + scope = CoroutineScope(testDispatcher), + cachedPlaces = MutableStateFlow(emptyList()), + cachedPlaceByTimeTag = MutableStateFlow(emptyList()), + onUpdateCachedPlace = {}, + ), + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `초기 위치로 돌아가기 버튼 클릭 시 이벤트가 방출된다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnBackToInitialPositionClick) + + val event = eventResult.await() + advanceUntilIdle() + + // then + assertEquals(MapControlSideEffect.BackToInitialPosition, event) + } + + @Test + fun `지도가 준비되었을 때 지도 관련 로직 초기화 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = mutableListOf() + observeMultipleEvent(mapControlUiEvent.receiveAsFlow(), eventResult) + + val initialSetting = FAKE_INITIAL_MAP_SETTING + uiState.update { + it.copy(initialMapSetting = LoadState.Success(initialSetting)) + } + + // when + mapEventActionHandler(MapControlEvent.OnMapReady) + advanceUntilIdle() + + // then + assertEquals( + listOf( + MapControlSideEffect.InitMap, + MapControlSideEffect.InitMapManager(initialSetting), + ), + eventResult, + ) + } + + @Test + fun `플레이스 로딩이 완료되었을 때 프리로드 이미지 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnPlaceLoadFinish(emptyList())) + + // then + val event = eventResult.await() + advanceUntilIdle() + assertEquals(PlaceMapSideEffect.PreloadImages(emptyList()), event) + } + + @Test + fun `초기 위치로 돌아갔을 때 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnBackToInitialPositionClick) + val event = eventResult.await() + advanceUntilIdle() + + // then + assertEquals(MapControlSideEffect.BackToInitialPosition, event) + } + + @Test + fun `지도가 드래그 되었을 때 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnMapDrag) + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertEquals(PlaceMapSideEffect.MapViewDrag(false), event) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt new file mode 100644 index 00000000..b6a8f264 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt @@ -0,0 +1,232 @@ +package com.daedan.festabook.placeMap.handler + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.placeDetail.FAKE_ETC_PLACE_DETAIL +import com.daedan.festabook.placeDetail.FAKE_PLACE_DETAIL +import com.daedan.festabook.placeMap.FAKE_TIME_TAG +import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.SelectEventHandlerImpl +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class SelectActionHandlerTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var selectActionHandler: SelectEventHandlerImpl + + private lateinit var uiState: MutableStateFlow + + private lateinit var placeDetailRepository: PlaceDetailRepository + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val placeMapUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + placeDetailRepository = mock() + uiState = MutableStateFlow(PlaceMapUiState()) + + selectActionHandler = + SelectEventHandlerImpl( + EventHandlerContext( + mapControlSideEffect = mapControlUiEvent, + placeMapSideEffect = placeMapUiEvent, + uiState = uiState, + onUpdateState = { uiState.update(it) }, + scope = CoroutineScope(testDispatcher), + cachedPlaces = MutableStateFlow(emptyList()), + cachedPlaceByTimeTag = MutableStateFlow(emptyList()), + onUpdateCachedPlace = {}, + ), + filterActionHandler = mock(MockMode.autofill), + placeDetailRepository = placeDetailRepository, + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `플레이스의 아이디와 카테고리가 있으면 플레이스 상세를 선택할 수 있다`() = + runTest { + // given + everySuspend { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectEvent.OnPlaceClick(1)) + advanceUntilIdle() + + // then + verifySuspend { placeDetailRepository.getPlaceDetail(1) } + + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Success(FAKE_PLACE_DETAIL.toUiModel()) + assertEquals(expected, uiState.value.selectedPlace) + assertEquals(MapControlSideEffect.SelectMarker(expected), event) + } + + @Test + fun `카테고리가 기타시설일 떄에도 플레이스 상세를 선택할 수 있다`() = + runTest { + // given + everySuspend { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_ETC_PLACE_DETAIL, + ) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectEvent.OnPlaceClick(1)) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) + assertEquals(expected, uiState.value.selectedPlace) + assertEquals(MapControlSideEffect.SelectMarker(expected), event) + } + + @Test + fun `플레이스 상세 선택을 해제할 수 있다`() = + runTest { + // given + everySuspend { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + selectActionHandler(SelectEvent.OnPlaceClick(1)) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + selectActionHandler(SelectEvent.UnSelectPlace) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertEquals(LoadState.Empty, uiState.value.selectedPlace) + assertEquals(MapControlSideEffect.UnselectMarker, event) + } + + @Test + fun `학교로 돌아가기 버튼이 나타나지 않는 임계값을 넣을 수 있다`() = + runTest { + // given + val isExceededMaxLength = true + + // when + selectActionHandler(SelectEvent.ExceededMaxLength(isExceededMaxLength)) + advanceUntilIdle() + + // then + assertEquals(isExceededMaxLength, uiState.value.isExceededMaxLength) + } + + @Test + fun `현재 플레이스를 선택 후에 플레이스 상세로 이벤트를 발생시킬 수 있다`() = + runTest { + // given + everySuspend { + placeDetailRepository.getPlaceDetail(FAKE_PLACE_DETAIL.id) + } returns Result.success(FAKE_PLACE_DETAIL) + + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + val expected = LoadState.Success(FAKE_PLACE_DETAIL.toUiModel()) + uiState.update { + it.copy( + selectedPlace = expected, + selectedTimeTag = LoadState.Success(FAKE_TIME_TAG), + ) + } + + // when + selectActionHandler( + SelectEvent.OnPlacePreviewClick(expected), + ) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertEquals(PlaceMapSideEffect.StartPlaceDetail(expected), event) + } + + @Test + fun `타임태그가 선택되었음을 알리는 이벤트를 발생시킬 수 있다`() = + runTest { + // given + val expected = TimeTag(1, "테스트1") + + // when + selectActionHandler(SelectEvent.OnTimeTagClick(expected)) + advanceUntilIdle() + + // then + assertEquals(LoadState.Success(expected), uiState.value.selectedTimeTag) + } + + @Test + fun `뒤로가기가 클릭되었을 때 선택 해제 이벤트를 발생시킬 수 있다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectEvent.OnBackPress) + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertEquals(MapControlSideEffect.UnselectMarker, event) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleTestFixtures.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleTestFixtures.kt new file mode 100644 index 00000000..d54ca091 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleTestFixtures.kt @@ -0,0 +1,52 @@ +package com.daedan.festabook.schedule + +import com.daedan.festabook.domain.model.ScheduleDate +import com.daedan.festabook.domain.model.ScheduleEvent +import com.daedan.festabook.domain.model.ScheduleEventStatus +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import kotlinx.datetime.LocalDate + +val FAKE_SCHEDULE_EVENTS = + listOf( + ScheduleEvent( + id = 1L, + status = ScheduleEventStatus.ONGOING, + startTime = "10:00", + endTime = "11:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", + ), + ScheduleEvent( + id = 1L, + status = ScheduleEventStatus.UPCOMING, // 필요 시 enum 정의에 맞게 변경 + startTime = "10:00", + endTime = "11:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", + ), + ) +val FAKE_SCHEDULE_EVENTS_UI_MODELS = + listOf( + ScheduleEventUiModel( + id = 1L, + status = ScheduleEventUiStatus.ONGOING, // enum이나 클래스에 맞게 수정 + startTime = "10:00", + endTime = "11:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", + ), + ScheduleEventUiModel( + id = 1L, + status = ScheduleEventUiStatus.UPCOMING, // enum이나 클래스에 맞게 수정 + startTime = "10:00", + endTime = "11:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", + ), + ) + +val FAKE_SCHEDULE_DATES = + listOf( + ScheduleDate(id = 1L, date = LocalDate(2025, 7, 26)), + ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleViewModelTest.kt new file mode 100644 index 00000000..2ee4e666 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/schedule/ScheduleViewModelTest.kt @@ -0,0 +1,132 @@ +package com.daedan.festabook.schedule + +import com.daedan.festabook.domain.repository.ScheduleRepository +import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.model.toUiModel +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +@OptIn(ExperimentalCoroutinesApi::class) +class ScheduleViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + private val dateId = 1L + private lateinit var scheduleRepository: ScheduleRepository + private lateinit var scheduleViewModel: ScheduleViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + + scheduleRepository = mock() + + everySuspend { scheduleRepository.fetchAllScheduleDates() } returns + Result.success(FAKE_SCHEDULE_DATES) + everySuspend { scheduleRepository.fetchScheduleEventsById(dateId) } returns + Result.success(FAKE_SCHEDULE_EVENTS) + + scheduleViewModel = ScheduleViewModel(scheduleRepository) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `ViewModel이 생성되면 해당 날짜를 불러온다`() = + runTest { + // given + advanceUntilIdle() + + // then + val stateResult = scheduleViewModel.scheduleUiState.value.content + val expectedDate = FAKE_SCHEDULE_DATES.map { it.toUiModel() } + + verifySuspend { scheduleRepository.fetchAllScheduleDates() } + verifySuspend { scheduleRepository.fetchScheduleEventsById(dateId) } + assertTrue(stateResult is ScheduleUiState.Content.Success) + assertEquals(expectedDate, stateResult.dates) + } + + @Test + fun `ViewModel이 생성되면 날짜에 해당하는 일정들을 불러온다`() = + runTest { + // given + advanceUntilIdle() + + // when + val state = scheduleViewModel.scheduleUiState.value.content + + // then + val successState = + state as? ScheduleUiState.Content.Success + ?: fail("ScheduleUiState.Success 가 아님: $state") + + val eventsState = + successState.eventsUiStateByPosition[0]?.content as? ScheduleEventsUiState.Content.Success + ?: fail("ScheduleEventsUiState.Success 가 아님") + + verifySuspend { scheduleRepository.fetchAllScheduleDates() } + verifySuspend { scheduleRepository.fetchScheduleEventsById(dateId) } + assertEquals(FAKE_SCHEDULE_EVENTS_UI_MODELS, eventsState.events) + } + + @Test + fun `현재 진행중인 날짜의 인덱스를 불러올 수 있다`() = + runTest { + // given + advanceUntilIdle() + + // when + val state = scheduleViewModel.scheduleUiState.value.content + + // then + val successState = + state as? ScheduleUiState.Content.Success + ?: fail("ScheduleUiState.Success 가 아님: $state") + + verifySuspend { scheduleRepository.fetchAllScheduleDates() } + verifySuspend { scheduleRepository.fetchScheduleEventsById(dateId) } + assertEquals(0, successState.currentDatePosition) + } + + @Test + fun `현재 진행중인 일정의 인덱스를 불러올 수 있다`() = + runTest { + // given + advanceUntilIdle() + + // when + val state = scheduleViewModel.scheduleUiState.value.content + + // then + val successState = + state as? ScheduleUiState.Content.Success + ?: fail("ScheduleUiState.Success 가 아님: $state") + val eventsState = + successState.eventsUiStateByPosition[0]?.content as? ScheduleEventsUiState.Content.Success + ?: fail("ScheduleEventsUiState.Success 가 아님") + + verifySuspend { scheduleRepository.fetchAllScheduleDates() } + verifySuspend { scheduleRepository.fetchScheduleEventsById(dateId) } + assertEquals(0, eventsState.currentEventPosition) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/FestivalNotificationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/FestivalNotificationRepositoryTest.kt new file mode 100644 index 00000000..eedefeec --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/FestivalNotificationRepositoryTest.kt @@ -0,0 +1,199 @@ +package com.daedan.festabook.setting + +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.data.datasource.local.FestivalNotificationLocalDataSource +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.datasource.remote.festival.FestivalNotificationRemoteDataSource +import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse +import com.daedan.festabook.data.repository.FestivalNotificationRepositoryImpl +import com.daedan.festabook.domain.repository.FestivalNotificationRepository +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify.VerifyMode +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class FestivalNotificationRepositoryTest { + private lateinit var festivalNotificationRepository: FestivalNotificationRepository + private val festivalNotificationDataSource: FestivalNotificationRemoteDataSource = + mock(MockMode.autofill) + private val deviceLocalDataSource: DeviceLocalDataSource = + mock(MockMode.autofill) + private val festivalNotificationLocalDataSource: FestivalNotificationLocalDataSource = + mock(MockMode.autofill) + private val festivalLocalDataSource: FestivalLocalDataSource = + mock(MockMode.autofill) + + @BeforeTest + fun setup() { + everySuspend { + deviceLocalDataSource.getDeviceId() + } returns flowOf(1) + everySuspend { + festivalLocalDataSource.getFestivalId() + } returns flowOf(1) + + festivalNotificationRepository = + FestivalNotificationRepositoryImpl( + festivalNotificationDataSource, + deviceLocalDataSource, + festivalNotificationLocalDataSource, + festivalLocalDataSource, + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Notification ID를 저장할 수 있다 `() = + runTest { + // given + everySuspend { + festivalNotificationDataSource.saveFestivalNotification(any(), any()) + } returns + ApiResult.Success( + FestivalNotificationResponse(10), + ) + + // when + festivalNotificationRepository.saveFestivalNotification() + + // then + verifySuspend(VerifyMode.exactly(1)) { + festivalNotificationDataSource.saveFestivalNotification(1, 1) + festivalNotificationLocalDataSource.saveFestivalNotificationId(1, 10) + } + } + + @Test + fun `Notification ID를 삭제할 수 있다`() = + runTest { + // given + everySuspend { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns flowOf(10) + everySuspend { + festivalNotificationDataSource.deleteFestivalNotification(10) + } returns ApiResult.Success(Unit) + + // when + festivalNotificationRepository.deleteFestivalNotification() + + // then + verifySuspend(VerifyMode.exactly(1)) { + festivalNotificationDataSource.deleteFestivalNotification(10) + festivalNotificationLocalDataSource.deleteFestivalNotificationId(1) + } + } + + @Test + fun `서버에서 Notification ID 삭제에 실패하면 로컬에 ID를 삭제하지 않는다`() = + runTest { + // given + everySuspend { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns flowOf(10) + everySuspend { + festivalNotificationDataSource.deleteFestivalNotification(10) + } returns ApiResult.ServerError(500, "", "") + + // when + festivalNotificationRepository.deleteFestivalNotification() + + // then + verifySuspend(VerifyMode.exactly(0)) { + festivalNotificationLocalDataSource.deleteFestivalNotificationId(1) + } + } + + @Test + fun `서버에서 Notification ID 저장에 실패하면 로컬에 ID를 저장하지 않는다`() = + runTest { + // given + everySuspend { + festivalNotificationDataSource.saveFestivalNotification(any(), any()) + } returns + ApiResult.ServerError(500, "", "") + + // when + festivalNotificationRepository.saveFestivalNotification() + + // then + verifySuspend(VerifyMode.exactly(0)) { + festivalNotificationLocalDataSource.saveFestivalNotificationId(1, 10) + } + } + + @Test + fun `서버에 알람이 등록되어있지 않다면 로컬에 Notification 정보를 삭제할 수 있다`() = + runTest { + // given + everySuspend { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns flowOf(1) + + everySuspend { + festivalNotificationDataSource.getFestivalNotification(1) + } returns ApiResult.Success(listOf()) + + // when + val result = festivalNotificationRepository.syncFestivalNotificationIsAllow() + + // then + assertEquals(false, result.getOrNull()) + verifySuspend(VerifyMode.exactly(1)) { + festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed(1, false) + festivalNotificationLocalDataSource.deleteFestivalNotificationId(1) + } + } + + @Test + fun `서버에 알람이 등록되어 있다면 로컬에 Notification 정보를 저장할 수 있다`() = + runTest { + // given + everySuspend { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns flowOf(-1) + + everySuspend { + festivalNotificationDataSource.getFestivalNotification(1) + } returns + ApiResult.Success( + listOf( + RegisteredFestivalNotificationResponse( + festivalNotificationId = 10, + festivalId = 1, + organizationName = "test", + festivalName = "test", + ), + ), + ) + + // when + val result = festivalNotificationRepository.syncFestivalNotificationIsAllow() + + // then + assertEquals(true, result.getOrNull()) + verifySuspend(VerifyMode.exactly(1)) { + festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed(1, true) + festivalNotificationLocalDataSource.saveFestivalNotificationId(1, 10) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/SettingViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/SettingViewModelTest.kt new file mode 100644 index 00000000..54bfb3c2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/setting/SettingViewModelTest.kt @@ -0,0 +1,135 @@ +package com.daedan.festabook.setting + +import com.daedan.festabook.domain.repository.FestivalNotificationRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.presentation.setting.SettingViewModel +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + private lateinit var settingViewModel: SettingViewModel + + private lateinit var festivalNotificationRepository: FestivalNotificationRepository + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + festivalNotificationRepository = mock(MockMode.autofill) + everySuspend { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(false) + settingViewModel = SettingViewModel(festivalNotificationRepository) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `알림 허용을 클릭했을 때 알림이 허용이 안되있다면 권한 요청 이벤트를 발생시킨다`() = + runTest { + // given + everySuspend { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns flowOf(false) + everySuspend { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(false) + + settingViewModel = SettingViewModel(festivalNotificationRepository) + val event = observeEvent(settingViewModel.permissionCheckEvent) + + // when + settingViewModel.notificationAllowClick() + advanceUntilIdle() + + // then + val actual = event.await() + advanceUntilIdle() + assertEquals(Unit, actual) + } + + @Test + fun `알림 허용을 클릭했을 때 알림이 허용이 되있다면 알림id를 삭제한다`() = + runTest { + // given + everySuspend { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns flowOf(true) + everySuspend { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(true) + everySuspend { festivalNotificationRepository.deleteFestivalNotification() } returns + Result.success(Unit) + + // when + settingViewModel = SettingViewModel(festivalNotificationRepository) + advanceUntilIdle() + + settingViewModel.notificationAllowClick() + advanceUntilIdle() + + // then + val result = settingViewModel.isAllowed.value + verifySuspend { festivalNotificationRepository.saveFestivalNotificationIsAllow(false) } + verifySuspend { festivalNotificationRepository.deleteFestivalNotification() } + assertFalse(result) + } + + @Test + fun `알림 허용을 클릭했을 때 서버에 알림 정보 저장에 실패하면 이전 상태로 원복한다`() = + runTest { + // given + everySuspend { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns flowOf(true) + everySuspend { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(true) + everySuspend { festivalNotificationRepository.deleteFestivalNotification() } returns + Result.failure( + Throwable(), + ) + + // when + settingViewModel = SettingViewModel(festivalNotificationRepository) + advanceUntilIdle() + settingViewModel.notificationAllowClick() + advanceUntilIdle() + + // then + val result = settingViewModel.isAllowed.value + verifySuspend { festivalNotificationRepository.saveFestivalNotificationIsAllow(true) } + assertTrue(result) + } + + @Test + fun `알림을 허용했을 때 서버에 알림 정보 삭제에 실패하면 이전 상태로 원복한다`() = + runTest { + // given + everySuspend { festivalNotificationRepository.saveFestivalNotification() } returns + Result.failure( + Throwable(), + ) + + // when + settingViewModel.saveNotificationId() + advanceUntilIdle() + + // then + val result = settingViewModel.isAllowed.value + verifySuspend { festivalNotificationRepository.saveFestivalNotificationIsAllow(false) } + assertFalse(result) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/splash/SplashViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/splash/SplashViewModelTest.kt new file mode 100644 index 00000000..664e9ebe --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/splash/SplashViewModelTest.kt @@ -0,0 +1,96 @@ +package com.daedan.festabook.splash + +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.presentation.splash.SplashUiState +import com.daedan.festabook.presentation.splash.SplashViewModel +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class SplashViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var festivalLocalDataSource: FestivalLocalDataSource + private lateinit var splashViewModel: SplashViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + festivalLocalDataSource = mock(MockMode.autofill) + splashViewModel = SplashViewModel(festivalLocalDataSource, iODispatcher = testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `앱 업데이트가 있다면 업데이트 다이얼로그를 표시한다`() = + runTest { + // given + val updateResult = Result.success(true) + + // when + splashViewModel.handleVersionCheckResult(updateResult) + + // then + assertEquals(SplashUiState.ShowUpdateDialog, splashViewModel.uiState.value) + } + + @Test + fun `앱 업데이트 확인에 실패하면 네트워크 에러 다이얼로그를 표시한다`() = + runTest { + // given + val updateResult = Result.failure(Exception("Network Error")) + + // when + splashViewModel.handleVersionCheckResult(updateResult) + + // then + assertEquals(SplashUiState.ShowNetworkErrorDialog, splashViewModel.uiState.value) + } + + @Test + fun `앱 업데이트가 없고 접속한 대학교가 있다면 MainActivity로 이동한다`() = + runTest { + every { festivalLocalDataSource.getFestivalId() } returns flowOf(1L) + val updateResult = Result.success(false) + + // when + splashViewModel.handleVersionCheckResult(updateResult) + + // then + assertEquals(SplashUiState.NavigateToMain(1L), splashViewModel.uiState.value) + verify(VerifyMode.exactly(1)) { festivalLocalDataSource.getFestivalId() } + } + + @Test + fun `앱 업데이트가 없고 접속한 대학교가 없다면 ExploreActivity로 이동한다`() = + runTest { + // given + every { festivalLocalDataSource.getFestivalId() } returns flowOf(null) + val updateResult = Result.success(false) + + // when + splashViewModel.handleVersionCheckResult(updateResult) + + // then + assertEquals(SplashUiState.NavigateToExplore, splashViewModel.uiState.value) + verify(VerifyMode.exactly(1)) { festivalLocalDataSource.getFestivalId() } + } +} From 8a71e44a2948c8d4a25641495ee27cd714cb2608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 1 Apr 2026 16:39:43 +0900 Subject: [PATCH 10/54] =?UTF-8?q?[Fix]=20=EC=B2=AB=EB=B0=A9=EB=AC=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EB=A1=9C=EA=B7=B8=EA=B0=80=20?= =?UTF-8?q?=EB=82=98=ED=83=80=EB=82=98=EC=9E=90=EB=A7=88=EC=9E=90=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: MainViewModel 내 초기 방문 여부 확인 로직 개선 * `MainViewModel` 초기화 시 `festivalRepository.getIsFirstVisit()`를 지속적으로 `collect` 하던 방식에서 `firstOrNull()`을 사용하여 최초 1회만 확인하도록 변경했습니다. * 값이 존재하지 않을 경우에 대비한 null 처리 로직 및 관련 TODO 주석을 추가했습니다. * refactor: 패키지 경로 수정 --- .../com/daedan/festabook/presentation/main/MainViewModel.kt | 6 +++++- .../daedan/festabook/viewModel/main/MainViewModelTest.kt | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt index 40a25f60..d6f2d42b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlin.time.Clock @@ -42,8 +43,11 @@ class MainViewModel( init { viewModelScope.launch { - festivalRepository.getIsFirstVisit().collect { + val isFirstVisit = festivalRepository.getIsFirstVisit().firstOrNull() + isFirstVisit?.let { _isFirstVisit.value = it + } ?: run { + // TODO 로그 } } } diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt index 1014d50d..6724001d 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/main/MainViewModelTest.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.main +package com.daedan.festabook.viewModel.main import com.daedan.festabook.domain.repository.FestivalRepository import com.daedan.festabook.observeMultipleEvent From b1282240064d932fa159e840062d01accfd2d1e2 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:17:24 +0900 Subject: [PATCH 11/54] =?UTF-8?q?[Fix]=20=EC=B6=95=EC=A0=9C=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=EC=97=85=20=EB=B6=88=EB=9F=AC=EC=98=A4=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: LineupResponse의 날짜 파싱 로직 수정 * **refactor: LineupResponse 내 날짜 변환 로직 간소화** * `LineupResponse.kt`: `performanceAt` 문자열을 `Instant`를 거쳐 UTC 기준의 `LocalDateTime`으로 변환하던 방식을 `LocalDateTime.parse()`를 직접 사용하도록 수정했습니다. --- .../festabook/data/model/response/lineup/LineupResponse.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/lineup/LineupResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/lineup/LineupResponse.kt index dfcd5815..14ad5462 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/lineup/LineupResponse.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/lineup/LineupResponse.kt @@ -1,12 +1,10 @@ package com.daedan.festabook.data.model.response.lineup import com.daedan.festabook.domain.model.LineupItem -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.time.ExperimentalTime -import kotlin.time.Instant @Serializable data class LineupResponse( @@ -26,5 +24,5 @@ fun LineupResponse.toDomain(): LineupItem = id = lineupId, imageUrl = imageUrl, name = name, - performanceAt = Instant.parse(performanceAt).toLocalDateTime(TimeZone.UTC), + performanceAt = LocalDateTime.parse(performanceAt), ) From c4db22e61ffed3e0bbe551bd8de42801e4b0a644 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:08:51 +0900 Subject: [PATCH 12/54] =?UTF-8?q?[Refactor]:=20PlaceDetail=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20PlaceMap=20=ED=95=98=EC=9C=84=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: PlaceDetail 패키지 PlaceMap 하위 패키지로 이동 Co-Authored-By: Claude Sonnet 4.6 * chore: test파일 ktlint format --------- Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 2 ++ .../festabook/presentation/main/FestabookRoute.kt | 2 +- .../placeMap/component/PlaceDetailPreviewScreen.kt | 2 +- .../component/PlaceDetailPreviewSecondaryScreen.kt | 2 +- .../placeMap/intent/event/SelectEvent.kt | 2 +- .../intent/handler/SelectEventHandlerImpl.kt | 2 +- .../intent/sideEffect/MapControlSideEffect.kt | 2 +- .../intent/sideEffect/PlaceMapSideEffect.kt | 2 +- .../presentation/placeMap/intent/state/LoadState.kt | 2 +- .../placeMap/intent/state/PlaceMapUiState.kt | 2 +- .../placeMap/navigation/PlaceMapNavigation.kt | 6 +++--- .../placeDetail/PlaceDetailViewModel.kt | 10 +++++----- .../placeDetail/component/PlaceDetailScreen.kt | 10 +++++----- .../placeDetail/model/ImageUiModel.kt | 2 +- .../placeDetail/model/PlaceDetailUiModel.kt | 2 +- .../placeDetail/model/PlaceDetailUiState.kt | 2 +- .../placeMap/handler/SelectActionHandlerTest.kt | 6 +++--- .../placeDetail/PlaceDetailTestFixture.kt | 2 +- .../placeDetail/PlaceDetailViewModelTest.kt | 13 ++++++++----- 19 files changed, 39 insertions(+), 34 deletions(-) rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/{ => placeMap}/placeDetail/PlaceDetailViewModel.kt (90%) rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/{ => placeMap}/placeDetail/component/PlaceDetailScreen.kt (97%) rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/{ => placeMap}/placeDetail/model/ImageUiModel.kt (82%) rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/{ => placeMap}/placeDetail/model/PlaceDetailUiModel.kt (95%) rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/{ => placeMap}/placeDetail/model/PlaceDetailUiState.kt (79%) rename composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/{ => placeMap}/placeDetail/PlaceDetailTestFixture.kt (96%) rename composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/{ => placeMap}/placeDetail/PlaceDetailViewModelTest.kt (92%) diff --git a/.gitignore b/.gitignore index 8b8d7dbf..aa2579e1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,8 @@ gen/ # Gradle files .gradle .gradle/ +.claude/ +.omc/ build/ # Local configuration file diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index 1ced5839..a5f0db8d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.main -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel import kotlinx.serialization.Serializable @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index 1cc2d19f..b7db1bd6 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -15,10 +15,10 @@ import androidx.compose.ui.text.style.TextOverflow import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.URLText import com.daedan.festabook.presentation.common.convertImageUrl -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt index 242feb53..00b32f49 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -12,12 +12,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.getIconId import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography import com.daedan.festabook.presentation.theme.festabookShapes diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt index 0a98f437..c6547dd3 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.presentation.placeMap.intent.event import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel sealed interface SelectEvent : PlaceMapEvent { data class OnPlaceClick( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandlerImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandlerImpl.kt index fe80666b..60290587 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandlerImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandlerImpl.kt @@ -3,7 +3,6 @@ package com.daedan.festabook.presentation.placeMap.intent.handler import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.domain.repository.PlaceDetailRepository -import com.daedan.festabook.presentation.placeDetail.model.toUiModel import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect @@ -11,6 +10,7 @@ import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState import com.daedan.festabook.presentation.placeMap.intent.state.await import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.toUiModel import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt index 6ec25e0a..fdca9b09 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt @@ -1,11 +1,11 @@ package com.daedan.festabook.presentation.placeMap.intent.sideEffect import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel sealed interface MapControlSideEffect { data object InitMap : MapControlSideEffect diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt index bf630d3a..92b133ff 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.presentation.placeMap.intent.sideEffect -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel sealed interface PlaceMapSideEffect { data class StartPlaceDetail( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt index dcb25a7a..d1beefe4 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.placeMap.intent.state -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel sealed interface LoadState { data object Loading : LoadState diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt index 95b9d657..519d1e3c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt @@ -1,11 +1,11 @@ package com.daedan.festabook.presentation.placeMap.intent.state import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel data class PlaceMapUiState( val initialMapSetting: LoadState = LoadState.Loading, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index e44a8740..ad03d894 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -17,10 +17,10 @@ import androidx.savedstate.read import androidx.savedstate.write import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.MainTabRoute -import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel -import com.daedan.festabook.presentation.placeDetail.component.PlaceDetailRoute -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeMap.placeDetail.component.PlaceDetailRoute +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import kotlinx.serialization.json.Json import kotlin.reflect.typeOf diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt index c4e513db..c1f8709e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt @@ -1,15 +1,15 @@ -package com.daedan.festabook.presentation.placeDetail +package com.daedan.festabook.presentation.placeMap.placeDetail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel -import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState -import com.daedan.festabook.presentation.placeDetail.model.toUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.ImageUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt index d75784b1..013d106b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeDetail.component +package com.daedan.festabook.presentation.placeMap.placeDetail.component import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring @@ -52,13 +52,13 @@ import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.URLText -import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel -import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.ImageUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/ImageUiModel.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/ImageUiModel.kt index 0b97878b..47c71b65 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/ImageUiModel.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeDetail.model +package com.daedan.festabook.presentation.placeMap.placeDetail.model import com.daedan.festabook.domain.model.PlaceDetailImage import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt index 7c3155c9..e1723089 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeDetail.model +package com.daedan.festabook.presentation.placeMap.placeDetail.model import com.daedan.festabook.domain.model.PlaceDetail import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiState.kt similarity index 79% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiState.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiState.kt index 1f45837b..18578ea5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiState.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeDetail.model +package com.daedan.festabook.presentation.placeMap.placeDetail.model sealed interface PlaceDetailUiState { data object Loading : PlaceDetailUiState diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt index b6a8f264..aea7006a 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/handler/SelectActionHandlerTest.kt @@ -3,10 +3,9 @@ package com.daedan.festabook.placeMap.handler import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.observeEvent -import com.daedan.festabook.placeDetail.FAKE_ETC_PLACE_DETAIL -import com.daedan.festabook.placeDetail.FAKE_PLACE_DETAIL import com.daedan.festabook.placeMap.FAKE_TIME_TAG -import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.placeMap.placeDetail.FAKE_ETC_PLACE_DETAIL +import com.daedan.festabook.placeMap.placeDetail.FAKE_PLACE_DETAIL import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext import com.daedan.festabook.presentation.placeMap.intent.handler.SelectEventHandlerImpl @@ -14,6 +13,7 @@ import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSi import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.toUiModel import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.everySuspend diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt similarity index 96% rename from composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailTestFixture.kt rename to composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt index 63072aaa..73767169 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.placeDetail +package com.daedan.festabook.placeMap.placeDetail import com.daedan.festabook.domain.model.Place import com.daedan.festabook.domain.model.PlaceCategory diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt similarity index 92% rename from composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailViewModelTest.kt rename to composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt index f97313fa..89154621 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeDetail/PlaceDetailViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt @@ -1,13 +1,13 @@ -package com.daedan.festabook.placeDetail +package com.daedan.festabook.placeMap.placeDetail import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.news.FAKE_NOTICES import com.daedan.festabook.placeMap.FAKE_PLACES import com.daedan.festabook.presentation.news.notice.model.toUiModel -import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState -import com.daedan.festabook.presentation.placeDetail.model.toUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.toUiModel import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.matcher.any @@ -75,7 +75,10 @@ class PlaceDetailViewModelTest { runTest { // given val exception = Throwable("테스트") - everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns Result.failure(exception) + everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns + Result.failure( + exception, + ) // then placeDetailViewModel.loadPlaceDetail( From 62af0c8e07335d5f7d2f0fc5c233e43f14794cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 10 Apr 2026 22:42:22 +0900 Subject: [PATCH 13/54] =?UTF-8?q?[Feat]=20=ED=83=90=EC=83=89=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B5=9C=EA=B7=BC=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 탐색 화면 내 최근 검색어 UI 및 검색 랜딩 콘텐츠 리팩터링 * **feat: 최근 검색어 기능 및 UI 추가** * `ExploreUiState`에 `recentSearches` 필드를 추가하여 최근 검색 기록을 관리할 수 있도록 개선했습니다. * `RecentSearchTitle` 컴포넌트를 추가하고, 검색어가 없는 `Idle` 상태일 때 `ExploreSearchResultList`에서 최근 검색어 목록을 표시하도록 수정했습니다. * 관련 문자열 리소스(`recent_searches`, `clear_all`)를 추가했습니다. * **refactor: 탐색 화면 컴포넌트 구조 개선** * `ExploreLandingContent.kt`를 신설하여 랜딩 화면의 검색바와 검색 결과 애니메이션 로직을 분리했습니다. * `ExploreSearchResultList` 등 하위 컴포넌트가 `SearchUiState` 대신 `ExploreUiState`를 직접 전달받아 필요한 데이터를 참조하도록 변경했습니다. * `ExploreSearchResultList` 내 `LazyColumn` 아이템에 `key`를 지정하여 리스트 렌더링 성능을 최적화했습니다. * **chore: 코드 정리** * `SearchUiState` 내 불필요한 주석을 제거했습니다. * `ExploreScreen` 내 가독성 향상을 위해 상태 변수명을 `uiState`에서 `exploreUiState`로 변경했습니다. * feat: 최근 축제 검색어 저장 및 조회 기능 구현 * **feat: 최근 검색어 데이터 모델 및 변환 로직 추가** * `FestivalSearchItemEntity` 클래스를 추가하고, 도메인 모델(`FestivalSearchItem`)과의 상호 변환을 위한 확장 함수를 구현했습니다. * **feat: 최근 검색어 로컬 저장소 로직 구현** * `FestivalLocalDataSource` 및 `FestivalLocalDataSourceImpl`에 최근 검색어를 `DataStore`에 JSON 형태로 저장하고 조회하는 기능을 추가했습니다. * 중복된 ID의 검색어는 제거하며, 최대 10개까지 최신 순으로 유지하도록 관리합니다. * **feat: 탐색 관련 Repository 및 ViewModel 기능 확장** * `ExploreRepository`에 최근 검색어 저장(`saveRecentFestivalSearch`) 및 조회(`getRecentFestivalSearches`) 인터페이스를 추가하고 구현했습니다. * `ExploreViewModel` 초기화 시 최근 검색어 목록을 관찰(Observe)하여 UI 상태(`ExploreUiState`)에 반영하도록 수정했습니다. * **style: 코드 가독성 개선** * `ExploreViewModel` 내 `_sideEffect` 선언부의 줄바꿈 등 코드 스타일을 정리했습니다. * feat: 최근 검색어 저장 및 전체 삭제 기능 구현 * **feat: 최근 검색어 관리 기능 추가** * 검색 결과에서 대학 선택 시 해당 정보를 최근 검색어 리스트에 저장하는 로직을 추가했습니다. * 최근 검색어 내역을 모두 지울 수 있는 `clearRecentFestivalSearches` 기능을 Repository 및 Data Source 계층에 구현했습니다. * 최근 검색어 최대 저장 개수를 10개에서 5개로 조정했습니다. * **refactor: Explore 도메인 및 UI 컴포넌트 고도화** * `ExploreViewModel`에 최근 검색어 삭제(`onClearRecentSearches`) 및 텍스트 변경 핸들러를 추가하고 관련 비즈니스 로직을 재구성했습니다. * `ExploreScreen`, `ExploreSearchResultList` 등 UI 컴포넌트에 최근 검색어 삭제 이벤트 콜백을 전달하도록 수정했습니다. * `SearchResultUiModel`을 도메인 모델인 `FestivalSearchItem`으로 변환하는 확장 함수를 추가했습니다. * 최근 검색어 목록 업데이트 확인을 위한 디버그 로그를 추가했습니다. * feat: 최근 검색어 삭제 기능 추가 및 검색 UI 개선 * **feat: 최근 검색어 개별 삭제 기능 구현** * `ExploreResultItem`에 삭제 버튼(X 아이콘)을 추가하고 `onDeleteClick` 콜백을 지원하도록 수정했습니다. * `ExploreViewModel`에 개별 검색 기록 삭제를 위한 `onRecentSearchDelete` 메서드를 추가했습니다. * `ExploreScreen`, `ExploreSearchContent` 등 관련 컴포넌트에 삭제 이벤트 전달 로직을 반영했습니다. * **refactor: 최근 검색어 영역 UI 로직 개선** * `RecentSearchTitle.kt`를 `RecentSearchMessageView.kt`로 이름을 변경하고, 검색 기록 유무에 따라 다른 UI를 보여주도록 개선했습니다. * 최근 검색어가 없을 경우 표시할 "최근 검색어 내역이 없습니다." 메시지 뷰(`NoRecentSearchTitle`)를 추가했습니다. * **chore: 리소스 추가** * `strings.xml`에 검색 결과 없음 메시지 및 닫기 버튼을 위한 `content_description` 문자열 리소스를 추가했습니다. * feat: 최근 검색 기록 개별 삭제 기능 구현 * **feat: 최근 검색어 삭제 로직 구현** * `ExploreViewModel` 내 `onRecentSearchDelete` 호출 시 `exploreRepository.deleteRecentFestivalSearch`를 수행하도록 구현했습니다. * `ExploreRepository` 및 `FestivalLocalDataSource` 인터페이스에 개별 검색 기록 삭제를 위한 `deleteRecentFestivalSearch` 메서드를 추가했습니다. * **refactor: 데이터 소스 및 저장소 구현** * `FestivalLocalDataSourceImpl`에서 DataStore에 저장된 최근 검색어 JSON 리스트를 파싱하여 특정 항목을 제거하고 다시 저장하는 로직을 구현했습니다. * `ExploreRepositoryImpl` 내 오타 수정(`entites` -> `entities`) 및 메서드 표현식을 간소화했습니다. * refactor: ExploreViewModel 로그 제거 및 LocalDataSource 로직 개선 * **refactor: `ExploreViewModel` 내 디버그 로그 및 미사용 임포트 제거** * 최근 검색어 목록을 `collect` 하는 과정에서 사용하던 `Napier` 로그 출력 코드와 관련 임포트 문을 삭제했습니다. * **refactor: `FestivalLocalDataSourceImpl` 내 조건문 로직 구조 변경** * `deleteFestivalSearchItem` 메서드에서 저장된 JSON 문자열의 빈 값 여부를 확인하는 `if` 문 구조를 `isEmpty()` 우선 확인 방식으로 변경하여 로직의 가독성을 개선했습니다. * style: 코드 포맷팅 * refactor: 테스트 코드 수정 * chore: iOS 프로젝트 빌드 설정 및 Info.plist 정리 * **chore: `Info.plist` 파일 구조 및 내용 정리** * 불필요하게 중복 정의되었던 `CADisableMinimumFrameDurationOnPhone` 키를 제거하고 포맷을 정리했습니다. * `CFBundleName`, `CFBundleShortVersionString`, `CFBundleVersion` 등 Xcode 프로젝트 설정에서 관리 가능한 하드코딩된 키들을 삭제했습니다. * **chore: Xcode 프로젝트 빌드 설정 업데이트** * **코드 서명 설정 추가**: Debug 빌드 구성에 `CODE_SIGN_IDENTITY`, `DEVELOPMENT_TEAM`, `PROVISIONING_PROFILE_SPECIFIER` 설정을 SDK(`iphoneos*`)별로 구체화하여 추가했습니다. * **Bundle Identifier 변경**: `PRODUCT_BUNDLE_IDENTIFIER`를 `eoehdeksruf.festabook.debug`에서 `debug.eoehdeksruf.festabook`으로 수정했습니다. * **앱 표시 이름 수정**: Debug 빌드 시 앱의 식별을 돕기 위해 `INFOPLIST_KEY_CFBundleDisplayName`을 `festabook(Debug)`로 변경했습니다. * `project.pbxproj` 내 불필요한 `exceptions` 항목을 제거했습니다. * refactor: ExploreLandingContent 내 Modifier 파라미터 추가 * **refactor: ExploreLandingContent 컴포넌트 확장성 개선** * `ExploreLandingContent` 함수에 `modifier` 파라미터를 추가하여 외부에서 레이아웃 속성을 제어할 수 있도록 개선했습니다. * 내부의 `ExploreSearchBar` 및 `AnimatedContent`에 전달받은 `modifier`를 적용하도록 수정했습니다. * refactor: 최근 검색 기록 저장 로직 수정 * **refactor: `FestivalLocalDataSourceImpl` 내 검색 기록 제한 방식 개선** * 새로운 검색 항목을 추가할 때 전체 목록을 먼저 생성한 뒤 `take(MAX_RECENT_SEARCH_COUNT)`를 호출하도록 수정하여, 결과 리스트의 최대 개수가 정확히 유지되도록 로직을 개선했습니다. * [Fix] ExploreViewModel 내 최근 검색어 관찰 및 저장 로직 개선 * **fix: 최근 검색어 Job 관리 및 실행 순서 보정** * `recentSearchJob` 필드를 추가하여 `observeRecentSearches`에서 시작된 코루틴을 관리하도록 수정했습니다. * `onUniversitySelected` 호출 시, 현재 진행 중인 최근 검색어 관찰 Job을 취소하여 불필요한 리소스 소모를 방지했습니다. * 검색 기록 저장(`saveRecentFestivalSearch`) 로직이 완료된 후 네비게이션 사이드 이펙트가 발생하도록 실행 순서를 조정했습니다. * refactor: FestivalLocalDataSourceImpl 내 JSON 역직렬화 로직 공통화 및 안정성 개선 * **refactor: `decodeFromStringOrDefault` 헬퍼 함수 도입** * JSON 문자열 파싱 실패 시 예외를 방지하고 기본값을 반환하도록 `runCatching` 기반의 인라인 함수를 추가했습니다. * `saveRecentSearch` 및 `deleteRecentSearch` 내 중복되던 수동 파싱 및 null 체크 로직을 해당 헬퍼 함수를 사용하도록 리팩터링했습니다. * refactor: `RecentSearchMessageView`의 콜백 파라미터 명칭 변경 * **refactor: `RecentSearchMessageView` 및 하위 컴포넌트 리팩터링** * 전체 삭제 기능을 명확하게 나타내기 위해 `onClick` 파라미터 이름을 `onClearAllClick`으로 변경했습니다. * `ExploreSearchResultList`에서 `RecentSearchMessageView` 호출 시 변경된 파라미터명을 반영했습니다. * chore: iOS Info.plist 버전 업데이트 로직 개선 * **chore: `updateIosVersion` 태스크 내 버전 주입 로직 고도화** * `CFBundleShortVersionString` 및 `CFBundleVersion` 키가 `.plist` 파일 내에 존재하지 않을 경우, `` 태그 직전에 해당 키와 값을 새로 추가하도록 로직을 보완했습니다. * 키가 이미 존재하는 경우에는 기존과 동일하게 정규표현식을 사용하여 값을 교체하도록 조건부 처리를 적용했습니다. * 태스크 구성 단계에서 불필요한 `inputs` 선언부를 제거했습니다. * refactor: ExploreLandingContent 레이아웃 수정 및 iOS 버전 정보 업데이트 * **refactor: ExploreLandingContent 컴포넌트 구조 개선** * `ExploreSearchBar`와 `AnimatedContent`를 `Column`으로 그룹화하여 레이아웃 구조를 안정화했습니다. * 외부에서 전달받은 `modifier`를 최상위 `Column`에 적용하고, 하위 컴포넌트의 `modifier` 설정을 이에 맞춰 조정했습니다. * **chore: iOS 앱 버전 정보 명시** * `Info.plist` 내에 `CFBundleShortVersionString`(2.1.0) 및 `CFBundleVersion`(20100) 정보를 추가했습니다. --- composeApp/build.gradle.kts | 46 +++++++---- .../composeResources/values/strings.xml | 4 + .../local/FestivalLocalDataSource.kt | 9 +++ .../local/FestivalLocalDataSourceImpl.kt | 67 ++++++++++++++++ .../model/entity/FestivalSearchItemEntity.kt | 31 ++++++++ .../data/repository/ExploreRepositoryImpl.kt | 17 +++++ .../domain/repository/ExploreRepository.kt | 9 +++ .../presentation/explore/ExploreUiState.kt | 3 + .../presentation/explore/ExploreViewModel.kt | 50 +++++++++--- .../presentation/explore/SearchUiState.kt | 1 - .../component/ExploreLandingContent.kt | 58 ++++++++++++++ .../explore/component/ExploreResultItem.kt | 59 +++++++++----- .../explore/component/ExploreScreen.kt | 71 +++++++++-------- .../explore/component/ExploreSearchContent.kt | 28 ++++--- .../component/ExploreSearchResultList.kt | 48 ++++++++++-- .../component/RecentSearchMessageView.kt | 76 +++++++++++++++++++ .../explore/model/SearchResultUiModel.kt | 9 +++ .../viewModel/explore/ExploreViewModelTest.kt | 16 +--- iosApp/iosApp.xcodeproj/project.pbxproj | 9 ++- iosApp/iosApp/Info.plist | 44 +++++------ 20 files changed, 518 insertions(+), 137 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/entity/FestivalSearchItemEntity.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/RecentSearchMessageView.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 027ccd93..cdfe64e2 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -281,29 +281,49 @@ val updateIosVersion by tasks.registering { val versionName = providers.gradleProperty("APP_VERSION_NAME") val versionCode = providers.gradleProperty("APP_VERSION_CODE") - inputs.file(plistFile) - inputs.property("versionName", versionName) - inputs.property("versionCode", versionCode) - doLast { val file = plistFile.asFile - var text = file.readText() - text = text.replace( - Regex("CFBundleShortVersionString\\s*.*"), - "CFBundleShortVersionString\n\t\t${versionName.get()}" - ) + // CFBundleShortVersionString + text = if (text.contains("CFBundleShortVersionString")) { + text.replace( + Regex("CFBundleShortVersionString\\s*.*"), + "CFBundleShortVersionString\n\t\t${versionName.get()}" + ) + } else { + text.replace( + "", + """ + CFBundleShortVersionString + ${versionName.get()} + + """.trimIndent() + ) + } - text = text.replace( - Regex("CFBundleVersion\\s*.*"), - "CFBundleVersion\n\t\t${versionCode.get()}" - ) + // CFBundleVersion + text = if (text.contains("CFBundleVersion")) { + text.replace( + Regex("CFBundleVersion\\s*.*"), + "CFBundleVersion\n\t\t${versionCode.get()}" + ) + } else { + text.replace( + "", + """ + CFBundleVersion + ${versionCode.get()} + + """.trimIndent() + ) + } file.writeText(text) } } + tasks.withType().configureEach { dependsOn(updateIosVersion) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 36200a17..f00e6e0b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -46,6 +46,10 @@ festabook_logo 철자가 맞는지 확인해주세요 오류가 발생했습니다: %1$s + 최근 검색어 + 전체삭제 + 최근 검색어 내역이 없습니다. + 닫기 버튼 일정 타임라인 diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSource.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSource.kt index d16c4d1f..00c6b704 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSource.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSource.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.data.datasource.local +import com.daedan.festabook.data.model.entity.FestivalSearchItemEntity import kotlinx.coroutines.flow.Flow interface FestivalLocalDataSource { @@ -7,5 +8,13 @@ interface FestivalLocalDataSource { fun getFestivalId(): Flow + suspend fun saveRecentFestivalSearch(festivalSearchItemEntity: FestivalSearchItemEntity) + + fun getRecentFestivalSearches(): Flow> + + suspend fun clearRecentFestivalSearches() + + suspend fun deleteRecentFestivalSearch(festivalSearchItemEntity: FestivalSearchItemEntity) + fun getIsFirstVisit(): Flow } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt index 0a5c3977..f46e1145 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt @@ -7,12 +7,15 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.daedan.festabook.data.model.entity.FestivalSearchItemEntity import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json @ContributesBinding(AppScope::class) @Inject @@ -31,6 +34,56 @@ class FestivalLocalDataSourceImpl( if (it is IOException) emit(emptyPreferences()) else throw it }.map { it[KEY_FESTIVAL_ID] } + override suspend fun saveRecentFestivalSearch(festivalSearchItemEntity: FestivalSearchItemEntity) { + dataStore.edit { preferences -> + val currentJson = preferences[FESTIVAL_SEARCH_ITEM] ?: "" + + val currentList = + decodeFromStringOrDefault>(currentJson, emptyList()) + + val updatedList = + ( + listOf(festivalSearchItemEntity) + + currentList.filter { it.festivalId != festivalSearchItemEntity.festivalId } + + ).take(MAX_RECENT_SEARCH_COUNT) + + preferences[FESTIVAL_SEARCH_ITEM] = Json.encodeToString(updatedList) + } + } + + override fun getRecentFestivalSearches(): Flow> = + dataStore.data + .catch { + if (it is IOException) emit(emptyPreferences()) else throw it + }.map { + val json = it[FESTIVAL_SEARCH_ITEM] ?: "" + if (json.isEmpty()) emptyList() else Json.decodeFromString(json) + } + + override suspend fun clearRecentFestivalSearches() { + dataStore.edit { preferences -> + preferences[FESTIVAL_SEARCH_ITEM] = + Json.encodeToString(emptyList()) + } + } + + override suspend fun deleteRecentFestivalSearch(festivalSearchItemEntity: FestivalSearchItemEntity) { + dataStore.edit { preferences -> + val currentJson = preferences[FESTIVAL_SEARCH_ITEM] ?: "" + + val festivalSearchItemEntities = + decodeFromStringOrDefault>( + currentJson, + mutableListOf(), + ) + + festivalSearchItemEntities.remove(festivalSearchItemEntity) + + preferences[FESTIVAL_SEARCH_ITEM] = Json.encodeToString(festivalSearchItemEntities) + } + } + override fun getIsFirstVisit(): Flow = getFestivalId().map { festivalId -> val key = booleanPreferencesKey("${KEY_IS_FIRST_VISIT}_$festivalId") @@ -45,6 +98,20 @@ class FestivalLocalDataSourceImpl( companion object { private const val KEY_IS_FIRST_VISIT = "is_first_visit" + private const val MAX_RECENT_SEARCH_COUNT = 5 + private val FESTIVAL_SEARCH_ITEM = stringPreferencesKey("festival_search_item") private val KEY_FESTIVAL_ID = longPreferencesKey("festival_id") + + private inline fun decodeFromStringOrDefault( + json: String, + default: T, + ): T = + runCatching { + Json.decodeFromString(json) + }.onFailure { + // TODO 실패 로그 필요 + }.getOrElse { + default + } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/entity/FestivalSearchItemEntity.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/entity/FestivalSearchItemEntity.kt new file mode 100644 index 00000000..83c17cfc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/entity/FestivalSearchItemEntity.kt @@ -0,0 +1,31 @@ +package com.daedan.festabook.data.model.entity + +import com.daedan.festabook.domain.model.FestivalSearchItem +import kotlinx.serialization.Serializable + +@Serializable +data class FestivalSearchItemEntity( + val festivalId: Long, + val organizationName: String, + val festivalName: String, + val startDate: String, + val endDate: String, +) + +fun FestivalSearchItemEntity.toDomain() = + FestivalSearchItem( + festivalId = festivalId, + organizationName = organizationName, + festivalName = festivalName, + startDate = startDate, + endDate = endDate, + ) + +fun FestivalSearchItem.toEntity() = + FestivalSearchItemEntity( + festivalId = festivalId, + organizationName = organizationName, + festivalName = festivalName, + startDate = startDate, + endDate = endDate, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt index 8b42d0a4..f8a44b9a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt @@ -2,6 +2,8 @@ package com.daedan.festabook.data.repository import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.data.datasource.remote.festival.FestivalRemoteDataSource +import com.daedan.festabook.data.model.entity.toDomain +import com.daedan.festabook.data.model.entity.toEntity import com.daedan.festabook.data.model.response.toDomain import com.daedan.festabook.data.util.toResult import com.daedan.festabook.data.util.withTimeoutOrNullFallback @@ -10,7 +12,9 @@ import com.daedan.festabook.domain.repository.ExploreRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map @ContributesBinding(AppScope::class) @Inject @@ -36,4 +40,17 @@ class ExploreRepositoryImpl( producer = { festivalLocalDataSource.getFestivalId().firstOrNull() }, onFallback = { /*TODO 로그 */ }, ) + + override suspend fun saveRecentFestivalSearch(festivalSearchItem: FestivalSearchItem) = + festivalLocalDataSource.saveRecentFestivalSearch(festivalSearchItem.toEntity()) + + override fun getRecentFestivalSearches(): Flow> = + festivalLocalDataSource.getRecentFestivalSearches().map { entities -> + entities.map { it.toDomain() } + } + + override suspend fun clearRecentFestivalSearches() = festivalLocalDataSource.clearRecentFestivalSearches() + + override suspend fun deleteRecentFestivalSearch(festivalSearchItem: FestivalSearchItem) = + festivalLocalDataSource.deleteRecentFestivalSearch(festivalSearchItem.toEntity()) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/ExploreRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/ExploreRepository.kt index 7f3ded8a..42efe8fe 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/ExploreRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/ExploreRepository.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.domain.repository import com.daedan.festabook.domain.model.FestivalSearchItem +import kotlinx.coroutines.flow.Flow interface ExploreRepository { suspend fun search(query: String): Result> @@ -8,4 +9,12 @@ interface ExploreRepository { suspend fun saveFestivalId(festivalId: Long) suspend fun getFestivalId(): Long? + + suspend fun saveRecentFestivalSearch(festivalSearchItem: FestivalSearchItem) + + fun getRecentFestivalSearches(): Flow> + + suspend fun clearRecentFestivalSearches() + + suspend fun deleteRecentFestivalSearch(festivalSearchItem: FestivalSearchItem) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt index 31254fed..948a2144 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreUiState.kt @@ -1,7 +1,10 @@ package com.daedan.festabook.presentation.explore +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel + data class ExploreUiState( val query: String = "", val searchState: SearchUiState = SearchUiState.Idle, val hasFestivalId: Boolean = false, + val recentSearches: List = emptyList(), ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt index c1ee7cb4..c8b946a3 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/ExploreViewModel.kt @@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.ExploreRepository import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.explore.model.toDomain import com.daedan.festabook.presentation.explore.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -32,14 +34,43 @@ class ExploreViewModel( private val _uiState = MutableStateFlow(ExploreUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _sideEffect = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + private val _sideEffect = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val sideEffect = _sideEffect.asSharedFlow() + private var recentSearchJob: Job? = null + init { checkFestivalId() + observeRecentSearches() observeSearchQuery() } + fun onUniversitySelected(university: SearchResultUiModel) { + recentSearchJob?.cancel() + viewModelScope.launch { + exploreRepository.saveFestivalId(university.festivalId) + exploreRepository.saveRecentFestivalSearch(university.toDomain()) + _sideEffect.emit(ExploreSideEffect.NavigateToMain(university)) + } + } + + fun onTextInputChanged(query: String) { + _uiState.update { it.copy(query = query) } + } + + fun onClearRecentSearches() { + viewModelScope.launch { + exploreRepository.clearRecentFestivalSearches() + } + } + + fun onRecentSearchDelete(university: SearchResultUiModel) { + viewModelScope.launch { + exploreRepository.deleteRecentFestivalSearch(university.toDomain()) + } + } + private fun checkFestivalId() { viewModelScope.launch { val festivalId = exploreRepository.getFestivalId() @@ -82,14 +113,13 @@ class ExploreViewModel( } } - fun onUniversitySelected(university: SearchResultUiModel) { - viewModelScope.launch { - exploreRepository.saveFestivalId(university.festivalId) - _sideEffect.emit(ExploreSideEffect.NavigateToMain(university)) - } - } - - fun onTextInputChanged(query: String) { - _uiState.update { it.copy(query = query) } + private fun observeRecentSearches() { + recentSearchJob = + viewModelScope.launch { + exploreRepository.getRecentFestivalSearches().collect { festivalSearchItems -> + val recentSearches = festivalSearchItems.map { it.toUiModel() } + _uiState.update { it.copy(recentSearches = recentSearches) } + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt index 86955e40..9bf99fcd 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/SearchUiState.kt @@ -13,7 +13,6 @@ sealed interface SearchUiState { data class Success( val universitiesFound: List = emptyList(), -// val selectedUniversity: University? = null, ) : SearchUiState { override val isEmptyResult: Boolean get() = universitiesFound.isEmpty() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt new file mode 100644 index 00000000..ad2d822d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt @@ -0,0 +1,58 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.ExploreUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel + +@Composable +fun ExploreLandingContent( + query: String, + onQueryChange: (String) -> Unit, + onUniversitySelect: (SearchResultUiModel) -> Unit, + onUniversityDelete: (SearchResultUiModel) -> Unit, + onClearRecentSearches: () -> Unit, + keyboardController: SoftwareKeyboardController?, + isError: Boolean, + isSearchMode: Boolean, + exploreUiState: ExploreUiState, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + ExploreSearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = { keyboardController?.hide() }, + isError = isError, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + AnimatedContent( + targetState = isSearchMode, + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(tween(200)), + initialContentExit = fadeOut(tween(200)), + ) + }, + ) { searching -> + if (searching) { + ExploreSearchResultList( + exploreUiState = exploreUiState, + onUniversitySelect = onUniversitySelect, + onClearRecentSearches = onClearRecentSearches, + onUniversityDelete = onUniversityDelete, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt index 1c57abab..9a8d42c3 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt @@ -1,17 +1,26 @@ package com.daedan.festabook.presentation.explore.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.explore.model.SearchResultUiModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.content_description_close +import festabookkmp.composeapp.generated.resources.ic_close +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -19,24 +28,39 @@ fun ExploreResultItem( university: SearchResultUiModel, onItemClick: (SearchResultUiModel) -> Unit, modifier: Modifier = Modifier, + onDeleteClick: (SearchResultUiModel) -> Unit = {}, + canDelete: Boolean = false, ) { - Column( - modifier = - modifier - .fillMaxWidth() - .clickable { onItemClick(university) } - .padding(vertical = 8.dp, horizontal = 4.dp), + Row( + modifier = modifier.fillMaxSize().padding(vertical = 8.dp, horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = university.universityName, - style = FestabookTypography.bodyLarge, - color = FestabookColor.gray800, - ) - Text( - text = university.festivalName, - style = FestabookTypography.bodySmall, - color = FestabookColor.gray600, - ) + Column( + modifier = + Modifier + .weight(1f) + .clickable { onItemClick(university) }, + ) { + Text( + text = university.universityName, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray800, + ) + Text( + text = university.festivalName, + style = FestabookTypography.bodySmall, + color = FestabookColor.gray600, + ) + } + if (canDelete) { + Icon( + painter = painterResource(Res.drawable.ic_close), + contentDescription = stringResource(Res.string.content_description_close), + tint = FestabookColor.gray600, + modifier = Modifier.clickable { onDeleteClick(university) }, + ) + } } } @@ -47,6 +71,7 @@ private fun ExploreResultItemPreview() { ExploreResultItem( university = SearchResultUiModel(1, "서울시립대학교", "2024 대동제"), onItemClick = {}, + onDeleteClick = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt index 58df6dda..7d014a61 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt @@ -1,10 +1,5 @@ package com.daedan.festabook.presentation.explore.component -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -27,6 +22,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.presentation.explore.ExploreSideEffect +import com.daedan.festabook.presentation.explore.ExploreUiState import com.daedan.festabook.presentation.explore.ExploreViewModel import com.daedan.festabook.presentation.explore.SearchUiState import com.daedan.festabook.presentation.explore.model.SearchResultUiModel @@ -44,7 +40,7 @@ fun ExploreScreen( onBackClick: () -> Unit, viewModel: ExploreViewModel, ) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val exploreUiState by viewModel.uiState.collectAsStateWithLifecycle() val keyboardController = LocalSoftwareKeyboardController.current val latestNavigateToMain by rememberUpdatedState(onNavigateToMain) @@ -61,20 +57,24 @@ fun ExploreScreen( } } - if (uiState.hasFestivalId) { + if (exploreUiState.hasFestivalId) { ExploreSearchScreen( - query = uiState.query, - searchState = uiState.searchState, + query = exploreUiState.query, + exploreUiState = exploreUiState, onQueryChange = viewModel::onTextInputChanged, onUniversitySelect = viewModel::onUniversitySelected, onBackClick = onBackClick, + onClearRecentSearches = viewModel::onClearRecentSearches, + onUniversityDelete = viewModel::onRecentSearchDelete, ) } else { ExploreLandingScreen( - query = uiState.query, - searchState = uiState.searchState, + query = exploreUiState.query, + exploreUiState = exploreUiState, onQueryChange = viewModel::onTextInputChanged, onUniversitySelect = viewModel::onUniversitySelected, + onClearRecentSearches = viewModel::onClearRecentSearches, + onUniversityDelete = viewModel::onRecentSearchDelete, ) } } @@ -82,9 +82,11 @@ fun ExploreScreen( @Composable fun ExploreSearchScreen( query: String, - searchState: SearchUiState, + exploreUiState: ExploreUiState, onQueryChange: (String) -> Unit, onUniversitySelect: (SearchResultUiModel) -> Unit, + onUniversityDelete: (SearchResultUiModel) -> Unit, + onClearRecentSearches: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -107,9 +109,11 @@ fun ExploreSearchScreen( ) { ExploreSearchContent( query = query, - searchState = searchState, + exploreUiState = exploreUiState, onQueryChange = onQueryChange, onUniversitySelect = onUniversitySelect, + onClearRecentSearches = onClearRecentSearches, + onUniversityDelete = onUniversityDelete, ) } } @@ -118,13 +122,15 @@ fun ExploreSearchScreen( @Composable fun ExploreLandingScreen( query: String, - searchState: SearchUiState, + exploreUiState: ExploreUiState, onQueryChange: (String) -> Unit, onUniversitySelect: (SearchResultUiModel) -> Unit, + onUniversityDelete: (SearchResultUiModel) -> Unit, + onClearRecentSearches: () -> Unit, modifier: Modifier = Modifier, ) { val keyboardController = LocalSoftwareKeyboardController.current - val isError = searchState.shouldShowErrorUi + val isError = exploreUiState.searchState.shouldShowErrorUi val isSearchMode = query.isNotBlank() @@ -166,31 +172,18 @@ fun ExploreLandingScreen( ) Spacer(modifier = Modifier.height(24.dp)) - ExploreSearchBar( + ExploreLandingContent( query = query, onQueryChange = onQueryChange, - onSearch = { keyboardController?.hide() }, + keyboardController = keyboardController, isError = isError, - modifier = Modifier.padding(horizontal = 20.dp), + isSearchMode = isSearchMode, + exploreUiState = exploreUiState, + onUniversitySelect = onUniversitySelect, + onClearRecentSearches = onClearRecentSearches, + onUniversityDelete = onUniversityDelete, ) - AnimatedContent( - targetState = isSearchMode, - transitionSpec = { - ContentTransform( - targetContentEnter = fadeIn(tween(200)), - initialContentExit = fadeOut(tween(200)), - ) - }, - ) { searching -> - if (searching) { - ExploreSearchResultList( - searchState = searchState, - onUniversitySelect = onUniversitySelect, - ) - } - } - Spacer(modifier = Modifier.weight(0.7f)) } } @@ -203,10 +196,12 @@ private fun ExploreSearchScreenPreview() { FestabookTheme { ExploreSearchScreen( query = "서울", - searchState = SearchUiState.Idle, + exploreUiState = ExploreUiState(searchState = SearchUiState.Idle), onQueryChange = {}, onUniversitySelect = {}, onBackClick = {}, + onClearRecentSearches = {}, + onUniversityDelete = {}, ) } } @@ -217,9 +212,11 @@ private fun ExploreLandingScreenPreview() { FestabookTheme { ExploreLandingScreen( query = "ㅇㄹㅇ", - searchState = SearchUiState.Idle, + exploreUiState = ExploreUiState(searchState = SearchUiState.Idle), onQueryChange = {}, onUniversitySelect = {}, + onClearRecentSearches = {}, + onUniversityDelete = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt index e18b5c5f..e1deebd7 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.ExploreUiState import com.daedan.festabook.presentation.explore.SearchUiState import com.daedan.festabook.presentation.explore.model.SearchResultUiModel import com.daedan.festabook.presentation.theme.FestabookTheme @@ -16,13 +17,15 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun ExploreSearchContent( query: String, - searchState: SearchUiState, + exploreUiState: ExploreUiState, onQueryChange: (String) -> Unit, onUniversitySelect: (SearchResultUiModel) -> Unit, + onUniversityDelete: (SearchResultUiModel) -> Unit, + onClearRecentSearches: () -> Unit, modifier: Modifier = Modifier, ) { val keyboardController = LocalSoftwareKeyboardController.current - val isError = searchState.shouldShowErrorUi + val isError = exploreUiState.searchState.shouldShowErrorUi Column( modifier = @@ -39,8 +42,10 @@ fun ExploreSearchContent( } ExploreSearchResultList( - searchState = searchState, + exploreUiState = exploreUiState, onUniversitySelect = onUniversitySelect, + onClearRecentSearches = onClearRecentSearches, + onUniversityDelete = onUniversityDelete, modifier = Modifier.weight(1f), ) } @@ -52,15 +57,20 @@ private fun ExploreSearchContentPreview() { FestabookTheme { ExploreSearchContent( query = "서울", - searchState = - SearchUiState.Success( - listOf( - SearchResultUiModel(1, "서울시립대학교", "2024 대동제"), - SearchResultUiModel(2, "서울대학교", "2024 봄축제"), - ), + exploreUiState = + ExploreUiState( + searchState = + SearchUiState.Success( + listOf( + SearchResultUiModel(1, "서울시립대학교", "2024 대동제"), + SearchResultUiModel(2, "서울대학교", "2024 봄축제"), + ), + ), ), onQueryChange = {}, onUniversitySelect = {}, + onClearRecentSearches = {}, + onUniversityDelete = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt index 8d954a2f..584b7dd9 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.ExploreUiState import com.daedan.festabook.presentation.explore.SearchUiState import com.daedan.festabook.presentation.explore.model.SearchResultUiModel import com.daedan.festabook.presentation.theme.FestabookColor @@ -19,8 +20,10 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun ExploreSearchResultList( - searchState: SearchUiState, + exploreUiState: ExploreUiState, onUniversitySelect: (SearchResultUiModel) -> Unit, + onUniversityDelete: (SearchResultUiModel) -> Unit, + onClearRecentSearches: () -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -29,7 +32,7 @@ fun ExploreSearchResultList( .fillMaxSize() .padding(horizontal = 20.dp), ) { - when (searchState) { + when (val searchUiState = exploreUiState.searchState) { is SearchUiState.Loading -> { item { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -42,7 +45,10 @@ fun ExploreSearchResultList( } is SearchUiState.Success -> { - items(searchState.universitiesFound) { university -> + items( + items = searchUiState.universitiesFound, + key = { it.festivalId }, + ) { university -> ExploreResultItem( university = university, onItemClick = onUniversitySelect, @@ -52,7 +58,26 @@ fun ExploreSearchResultList( is SearchUiState.Error -> {} - is SearchUiState.Idle -> {} + is SearchUiState.Idle -> { + item { + RecentSearchMessageView( + onClearAllClick = onClearRecentSearches, + modifier = Modifier.padding(bottom = 10.dp), + isExist = exploreUiState.recentSearches.isNotEmpty(), + ) + } + items( + items = exploreUiState.recentSearches, + key = { it.festivalId }, + ) { recentSearch -> + ExploreResultItem( + university = recentSearch, + onItemClick = onUniversitySelect, + canDelete = true, + onDeleteClick = onUniversityDelete, + ) + } + } } } } @@ -62,8 +87,10 @@ fun ExploreSearchResultList( private fun ExploreSearchResultListLoadingPreview() { FestabookTheme { ExploreSearchResultList( - searchState = SearchUiState.Loading, + exploreUiState = ExploreUiState(searchState = SearchUiState.Loading), onUniversitySelect = {}, + onClearRecentSearches = {}, + onUniversityDelete = {}, ) } } @@ -78,11 +105,16 @@ private fun ExploreSearchResultListSuccessPreview() { ) FestabookTheme { ExploreSearchResultList( - searchState = - SearchUiState.Success( - universitiesFound = fakeUniversities, + exploreUiState = + ExploreUiState( + searchState = + SearchUiState.Success( + universitiesFound = fakeUniversities, + ), ), onUniversitySelect = {}, + onClearRecentSearches = {}, + onUniversityDelete = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/RecentSearchMessageView.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/RecentSearchMessageView.kt new file mode 100644 index 00000000..016f5ec1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/RecentSearchMessageView.kt @@ -0,0 +1,76 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.clear_all +import festabookkmp.composeapp.generated.resources.no_recent_searches_message +import festabookkmp.composeapp.generated.resources.recent_searches +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun RecentSearchMessageView( + onClearAllClick: () -> Unit, + isExist: Boolean, + modifier: Modifier = Modifier, +) { + if (isExist) { + RecentSearchTitle(modifier = modifier, onClearAllClick = onClearAllClick) + } else { + NoRecentSearchTitle(modifier = modifier) + } +} + +@Composable +private fun RecentSearchTitle( + onClearAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(Res.string.recent_searches), + style = FestabookTypography.titleLarge, + color = FestabookColor.gray800, + ) + TextButton(onClick = onClearAllClick, contentPadding = PaddingValues(0.dp)) { + Text( + text = stringResource(Res.string.clear_all), + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray800, + ) + } + } +} + +@Composable +private fun NoRecentSearchTitle(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(Res.string.no_recent_searches_message), + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray800, + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun RecentSearchTitlePreview() { + RecentSearchMessageView(onClearAllClick = {}, isExist = false) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt index 7113ea52..77b5f2e9 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/model/SearchResultUiModel.kt @@ -14,3 +14,12 @@ fun FestivalSearchItem.toUiModel(): SearchResultUiModel = universityName = organizationName, festivalName = festivalName.replace("\n", " "), ) + +fun SearchResultUiModel.toDomain(): FestivalSearchItem = + FestivalSearchItem( + festivalId = festivalId, + organizationName = universityName, + festivalName = festivalName, + startDate = "", + endDate = "", + ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt index 18b0f06d..8fc2c5d9 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/explore/ExploreViewModelTest.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.explore +package com.daedan.festabook.viewModel.explore import com.daedan.festabook.domain.model.FestivalSearchItem import com.daedan.festabook.domain.repository.ExploreRepository @@ -15,11 +15,10 @@ import dev.mokkery.mock import dev.mokkery.verifySuspend import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -32,7 +31,7 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ExploreViewModelTest { - private val testDispatcher = StandardTestDispatcher() + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var exploreRepository: ExploreRepository private lateinit var exploreViewModel: ExploreViewModel @@ -41,6 +40,7 @@ class ExploreViewModelTest { Dispatchers.setMain(testDispatcher) exploreRepository = mock(MockMode.autofill) everySuspend { exploreRepository.search(any()) } returns Result.success(emptyList()) + everySuspend { exploreRepository.getRecentFestivalSearches() } returns flowOf(emptyList()) } @AfterTest @@ -53,10 +53,8 @@ class ExploreViewModelTest { runTest { // given everySuspend { exploreRepository.getFestivalId() } returns 1L - // when exploreViewModel = ExploreViewModel(exploreRepository) - advanceUntilIdle() // then verifySuspend { exploreRepository.getFestivalId() } @@ -72,7 +70,6 @@ class ExploreViewModelTest { // when exploreViewModel.onTextInputChanged(query) - advanceUntilIdle() // then assertEquals(query, exploreViewModel.uiState.value.query) @@ -108,7 +105,6 @@ class ExploreViewModelTest { // when exploreViewModel.onTextInputChanged(query) advanceTimeBy(500L) - advanceUntilIdle() // then verifySuspend { exploreRepository.search(query) } @@ -124,12 +120,10 @@ class ExploreViewModelTest { exploreViewModel = ExploreViewModel(exploreRepository) exploreViewModel.onTextInputChanged("이전검색어") advanceTimeBy(500L) - advanceUntilIdle() // when exploreViewModel.onTextInputChanged("") advanceTimeBy(500L) - advanceUntilIdle() // then assertEquals(SearchUiState.Idle, exploreViewModel.uiState.value.searchState) @@ -147,7 +141,6 @@ class ExploreViewModelTest { // when exploreViewModel.onTextInputChanged(query) advanceTimeBy(500L) - advanceUntilIdle() // then verifySuspend { exploreRepository.search(query) } @@ -178,7 +171,6 @@ class ExploreViewModelTest { // when exploreViewModel.onUniversitySelected(searchResult) - advanceUntilIdle() // then verifySuspend { exploreRepository.saveFestivalId(searchResult.festivalId) } diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3904e6db..ef0499be 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -38,8 +38,6 @@ }; 8E6C8D378651BF8144E66D20 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Configuration; sourceTree = ""; }; @@ -287,10 +285,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = iosApp/iosAppDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L8HZU9MP64; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -306,8 +306,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = eoehdeksruf.festabook.debug; + PRODUCT_BUNDLE_IDENTIFIER = debug.eoehdeksruf.festabook; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_development_debug_provisioning_profile; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -386,7 +387,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = festabook; + INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 53c8d262..0f8e5f40 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,30 +1,22 @@ - + + - - CFBundleName - festabook - - CADisableMinimumFrameDurationOnPhone - - - CFBundleShortVersionString + + CADisableMinimumFrameDurationOnPhone + + FirebaseAppDelegateProxyEnabled + + LSApplicationQueriesSchemes + + itms-apps + + UIBackgroundModes + + remote-notification + +CFBundleShortVersionString 2.1.0 - - CFBundleVersion +CFBundleVersion 20100 - - CADisableMinimumFrameDurationOnPhone - - LSApplicationQueriesSchemes - - itms-apps - - UIBackgroundModes - - remote-notification - - FirebaseAppDelegateProxyEnabled - - + From ac845775f4adad5d396e078ab22fc00f69b36ef5 Mon Sep 17 00:00:00 2001 From: Taek_2 Date: Sat, 11 Apr 2026 17:19:24 +0900 Subject: [PATCH 14/54] =?UTF-8?q?[Fix]=20Slack=20Reminder=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=98=B8=EC=B6=9C=20=EC=88=98=EC=A0=95=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: Slack Reminder 삭제 --- .../common-slack-review-reminder.yml | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/common-slack-review-reminder.yml diff --git a/.github/workflows/common-slack-review-reminder.yml b/.github/workflows/common-slack-review-reminder.yml deleted file mode 100644 index 42c0e8b2..00000000 --- a/.github/workflows/common-slack-review-reminder.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Slack Notification (Review Reminder) - -on: - schedule: - # 매일 한국 시간 오후 2시 (KST 14:00 == UTC 05:00) - - cron: '0 5 * * *' - workflow_dispatch: - -permissions: - pull-requests: read - -jobs: - - remind: - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Run Reminder Script - uses: actions/github-script@v7 - env: - SLACK_WEBHOOK_REVIEW: ${{ secrets.SLACK_WEBHOOK_REVIEW }} - with: - script: | - // .github/scripts/review-reminder.js 파일을 불러와서 실행 - const script = require('./.github/scripts/review-reminder.js'); - await script({ github, context, core }); From c624b2cc575bbaa229a7070fd7f7545eca9b2556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sun, 12 Apr 2026 20:20:41 +0900 Subject: [PATCH 15/54] =?UTF-8?q?[Fix]=20=EA=B3=B5=EC=A7=80=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20FCM=20=EC=95=8C=EB=A6=BC=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: FCM 알림 수신 시 축제 ID 저장 로직 구현 * **feat: FCM 알림 데이터 처리 및 로컬 저장 기능 추가** * `MyFirebaseMessagingService`에 `FestivalLocalDataSource`를 주입하여 알림 수신 시 전달받은 `festivalId`를 로컬에 저장하는 로직을 추가했습니다. * `onMessageReceived`에서 `festivalId` 데이터를 추출하고, `CoroutineScope(Dispatchers.IO)`를 통해 비동기적으로 저장하도록 구현했습니다. * **refactor: `MyFirebaseMessagingService` 의존성 주입 설정** * `onCreate`에서 `androidAppGraph.inject(this)`를 호출하여 의존성 주입이 가능하도록 수정했습니다. * `AndroidAppGraph` 인터페이스에 `FirebaseMessagingService` 인젝션을 위한 메서드를 추가했습니다. * chore: .gitignore 내 iOS Google Service 설정 파일 경로 수정 * **chore: GoogleService-Info.plist 무시 경로 구체화** * `.gitignore`에 등록된 `GoogleService-Info.plist`, `GoogleService-Info-DEBUG.plist`, `GoogleService-Info-RELEASE.plist` 파일의 경로를 실제 위치인 `iosApp/iosApp/` 하위로 수정했습니다. * [Config] iOS 디버그 빌드 설정 및 사이닝 구성 업데이트 * **build: iOS 프로젝트 빌드 및 사이닝 설정 수정** * `CODE_SIGN_IDENTITY`, `DEVELOPMENT_TEAM`, `PROVISIONING_PROFILE_SPECIFIER` 설정에 `iphoneos` SDK 전용 값을 추가하여 수동 사이닝 구성을 보완했습니다. * `PRODUCT_BUNDLE_IDENTIFIER`를 `eoehdeksruf.festabook.debug`에서 `debug.eoehdeksruf.festabook`으로 변경했습니다. * **chore: 앱 표시 명칭 및 프로젝트 구조 정리** * 디버그 빌드의 `CFBundleDisplayName`을 `festabook`에서 `festabook(Debug)`로 변경하여 설치된 앱의 식별력을 높였습니다. * `project.pbxproj` 내 불필요한 `exceptions` 항목을 제거했습니다. * [Feat] iOS 원격 알림 등록 로직 추가 * **feat: 앱 실행 시 원격 알림 등록 기능 추가** * `AppDelegate`의 `didFinishLaunchingWithOptions` 메서드 내에 `application.registerForRemoteNotifications()` 호출을 추가하여 앱 시작 시 원격 알림을 등록하도록 수정했습니다. * `festabookAppDelegate.application`의 반환 값을 변수로 받아 처리하도록 로직을 보완했습니다. * **style: AppDelegate 내 메서드 포맷팅 수정** * `didRegisterForRemoteNotificationsWithDeviceToken` 및 `didReceiveRemoteNotification` 등 주요 메서드의 파라미터 개행 및 인덴트를 정리하여 가독성을 개선했습니다. * chore: iosApp Info.plist 설정 정리 및 최적화 * **chore: Info.plist 내 불필요한 키 제거 및 중복 정리** * `CFBundleName`, `CFBundleShortVersionString`, `CFBundleVersion` 등 프로젝트 설정에서 관리되는 중복 키를 삭제했습니다. * 중복 선언되어 있던 `CADisableMinimumFrameDurationOnPhone` 항목을 정리했습니다. * `FirebaseAppDelegateProxyEnabled` 설정의 위치를 변경하고 형식을 통일했습니다. * refactor: MyFirebaseMessagingService 의존성 주입 방식 변경 * **refactor: MyFirebaseMessagingService 내 의존성 획득 방식 수정** * `@Inject` 필드 주입 및 `onCreate`에서의 명시적 `inject` 호출을 제거했습니다. * `festivalLocalDataSource`를 `application.androidAppGraph`에서 직접 가져오는 lazy 프로퍼티로 변경하여 의존성 획득 방식을 간소화했습니다. * **refactor: AndroidAppGraph 인터페이스 정리** * `FirebaseMessagingService`를 위한 `inject` 메서드를 제거했습니다. * 서비스 등에서 직접 접근할 수 있도록 `festivalLocalDataSource` 프로퍼티를 인터페이스에 노출했습니다. * feat: FestabookGlobalExceptionHandler 내 예외 스택 트레이스 로깅 추가 * **feat: 전역 예외 처리 시 로그 기록 로직 추가** * `FestabookGlobalExceptionHandler`에서 예외가 발생하여 `ErrorActivity`를 실행하기 전, `Napier`를 사용하여 예외의 스택 트레이스를 로그로 남기도록 수정했습니다. * refactor: iOS FCM 토큰 등록 로직 수정 * **refactor: `DefaultFirebaseMessagingDelegate` 내 토큰 획득 방식 변경** * 기존의 `didReceiveRegistrationToken` 인자를 직접 사용하는 대신, `FIRMessaging.messaging().tokenWithCompletion` 콜백을 통해 최신 FCM 토큰을 명시적으로 가져오도록 수정했습니다. * 디버깅을 위해 토큰 및 에러 정보를 출력하는 로그를 추가하고, 기존 `NSLog` 호출을 삭제했습니다. * refactor: iOS Delegate 의존성 주입 구조 개선 및 CoroutineScope 관리 방식 변경 * **refactor: iOS App Delegate 및 Delegate 클래스 리팩터링** * `DefaultFestabookAppDelegate`에서 직접 생성하던 `DefaultFirebaseMessagingDelegate`와 `DefaultUserNotificationDelegate`를 `IosAppGraph`를 통해 주입받도록 수정했습니다. * `DefaultFirebaseMessagingDelegate`와 `DefaultUserNotificationDelegate`에 `@Inject` 어노테이션을 추가하고, `DefaultFirebaseMessagingDelegate`가 `DeviceRepository`를 생성자 주입으로 받도록 개선했습니다. * **refactor: 의존성 주입(Metro) 설정 및 CoroutineScope 구분** * `FestabookAppGraph` 및 `IosAppGraph`에서 `CoroutineScope`를 `Dispatchers.IO`와 `Dispatchers.Main`으로 구분하여 제공하도록 `@Named` 한정자("IO", "Main")를 추가했습니다. * `DeviceRepositoryImpl`에서 `IO` 스코프를 사용하도록 수정하고, `DefaultFirebaseMessagingDelegate`에서 `Main` 스코프를 사용하도록 업데이트했습니다. * **chore: 기타 코드 정리** * `DefaultFirebaseMessagingDelegate` 내 불필요한 주석을 제거했습니다. * iOS 플랫폼 빌드를 위한 `IosAppGraph` 인터페이스에 필요한 속성 및 프로바이더를 추가했습니다. * feat: iOS 알림 처리 로직 개선 및 로컬 알림 기능 구현 * **feat: iOS `DefaultUserNotificationDelegate` 내 알림 처리 및 로컬 알림 연동** * 포그라운드 상태에서 수신된 알림 정보를 기반으로 커스텀 로컬 알림을 생성하여 표시하는 `showLocalNotificationWithImage` 메서드를 추가했습니다. * 알림 식별자가 `festabook_local`로 시작하는 경우 시스템 기본 옵션을 사용하도록 분기 로직을 구현했습니다. * 알림 클릭 응답 처리 시 `festivalId`를 로컬 데이터소스에 저장하는 비즈니스 로직을 추가했습니다. * `FestivalLocalDataSource` 및 `CoroutineScope`를 생성자 주입 방식으로 개선했습니다. * **refactor: Android `FestabookApp` 내 의존성 주입 수정** * `applicationScope` 필드에 `@Named("IO")` 한정자를 추가하여 의존성 주입 대상을 명확히 했습니다. * **chore: 기타 변경 사항** * iOS 알림 처리 과정에서 로깅을 위한 `Napier` 라이브러리를 적용했습니다. * `DefaultUserNotificationDelegate` 내 상수 관리를 위한 `companion object`를 추가했습니다. * refactor: 의존성 주입 구조 개선 및 iOS 알림/FCM 토큰 갱신 로직 최적화 * **refactor: Metro DI `@Named` 어노테이션 제거 및 CoroutineScope 주입 방식 단순화** * `FestabookAppGraph`, `IosAppGraph` 등 DI 설정에서 불필요한 `@Named("IO")`, `@Named("Main")` 구분을 제거하고 단일 `CoroutineScope`를 사용하도록 수정했습니다. * `DefaultUserNotificationDelegate`, `DeviceRepositoryImpl`, `FestabookApp` 등 관련 클래스의 생성자 및 필드 주입 로직을 업데이트했습니다. * **feat: iOS 알림 아이콘 설정 및 로컬 알림 로직 개선** * `DefaultUserNotificationDelegate`: 알림 발생 시 앱 내 로고 아이콘(`logo_festabook_icon.png`)이 표시되도록 `imagePath` 설정 로직을 추가했습니다. * `showLocalNotificationWithImage` 메서드 이름을 `showNotification`으로 변경하고, `festivalId` 및 `announcementId` 키 값을 상수로 관리하도록 개선했습니다. * **fix: DeviceRepository 기기 등록 및 토큰 저장 순서 조정** * `DeviceRepositoryImpl.registerDevice`: 서버 기기 등록 API 호출 성공 후에만 로컬 FCM 토큰을 저장하도록 로직 순서를 변경하여 데이터 정합성을 높였습니다. * **perf: iOS FCM 토큰 등록 로직 최적화** * `DefaultFirebaseMessagingDelegate`: `MutableSharedFlow`와 `debounce(3000L)`, `distinctUntilChanged`를 적용하여 단기간에 발생하는 중복 토큰 등록 요청을 방지하고 부하를 줄였습니다. * 기존 `FIRMessaging.messaging().tokenWithCompletion` 직접 호출 방식에서 `didReceiveRegistrationToken` 이벤트를 관찰하는 방식으로 변경했습니다. * [Chore] iOS 앱 아이콘 자산 업데이트 및 프로젝트 설정 변경 * **chore: 앱 아이콘 이미지 세트 교체 및 구성 최적화** * 기존 `app-icon-1024.png` 파일을 삭제하고, 다양한 해상도에 대응하는 개별 아이콘 파일들(16.png ~ 1024.png)을 새롭게 추가했습니다. * `AppIcon.appiconset/Contents.json`을 수정하여 다크 모드 및 틴티드(tinted) 외관 설정을 제거하고 단일 범용(universal) 이미지 구조로 단순화했습니다. * **chore: Xcode 프로젝트 빌드 설정 수정** * `ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS` 옵션을 `YES`로 설정하여 모든 아이콘 자산이 빌드에 포함되도록 했습니다. * 특정 빌드 구성에서 `ASSETCATALOG_COMPILER_APPICON_NAME` 값을 조정하여 아이콘 참조 방식을 최적화했습니다. * refactor: iOS 알림 내 이미지 첨부 로직 제거 * **refactor: `DefaultUserNotificationDelegate` 내 알림 이미지 설정 코드 삭제** * `showNotification` 메서드에서 `imagePath` 파라미터를 제거하고, `UNNotificationAttachment`를 통해 알림에 이미지를 첨부하던 로직을 삭제했습니다. * `userNotificationCenter`에서 메인 번들(Main Bundle)로부터 로고 이미지 경로를 가져오던 불필요한 로직을 제거했습니다. * 더 이상 사용하지 않는 `NSURL`, `UNNotificationAttachment` 등의 임포트를 정리했습니다. * refactor: FCM 관련 문자열 리소스를 공통 모듈로 이동 * **refactor: Android 전용 문자열 리소스를 `commonMain`으로 이전** * `androidMain`의 `strings.xml`에 정의되어 있던 FCM 기본 알림 제목(`default_notification_title`)과 내용(`default_notification_body`)을 삭제했습니다. * `commonMain`의 `composeResources` 내 `strings.xml`로 해당 리소스를 이동하며 키 이름을 각각 `notification_default_title` 및 `notification_default_body`로 변경했습니다. * refactor: 알림 처리 로직 개선 및 다국어 리소스 적용 * **feat: Android 및 iOS 알림 로직 개선** * `DefaultUserNotificationDelegate` (iOS) 및 `MyFirebaseMessagingService` (Android)에서 하드코딩된 문자열 대신 Compose Multiplatform 리소스(`Res.string`)를 사용하도록 수정했습니다. * Android 알림 처리 시 `ioCoroutineScope`를 사용하여 비동기 로직을 처리하고, 유효하지 않은 `festivalId`(-1)에 대한 예외 처리를 추가했습니다. * `AndroidAppGraph`에 공통으로 사용할 `ioCoroutineScope` 의존성을 추가했습니다. * **refactor: iOS FCM 토큰 등록 로직 수정** * `DefaultFirebaseMessagingDelegate`에서 불필요한 `distinctUntilChanged()` 호출을 제거했습니다. * **style: 코드 정리** * iOS 알림 델리게이트 내 코루틴 스코프 적용 범위를 수정하여 비동기 작업 안정성을 개선했습니다. * refactor: 알림 데이터 처리 로직 고도화 및 ID 타입 통일 * **refactor: Android 알림 서비스 내 ID 처리 방식 개선** * `MyFirebaseMessagingService` 및 `NotificationHelper`에서 `festivalId`와 `announcementId`를 `String` 대신 `Long` 타입으로 처리하도록 수정하여 타입 안정성을 강화했습니다. * 알림 데이터 파싱 시 매직 넘버(`"-1"`) 대신 `DeepLinkKeys.INITIALIZED_ID` 상수를 기본값으로 사용하도록 통일했습니다. * `handleMessageData` 내 불필요한 타입 변환 로직을 제거하고 상수 기반의 조건문으로 개선했습니다. * **refactor: iOS 알림 델리게이트 기본값 상수화** * `DefaultUserNotificationDelegate`에서 알림 수신 시 `userInfo` 내 ID 값이 없을 경우 `DeepLinkKeys.INITIALIZED_ID`를 할당하도록 수정했습니다. * refactor: iOS 알림 처리 로직 개선 및 딥링크 키 상수화 * **refactor: iOS `UNUserNotificationCenterDelegate` 구현 정리** * `DefaultUserNotificationDelegate`에서 불필요한 로컬 알림 재생성 로직(`showNotification`)을 제거하고 시스템 기본 알림 옵션을 사용하도록 수정했습니다. * 알림 응답(`didReceiveNotificationResponse`) 처리 시, 메인 스레드에서 `NSNotificationCenter` 이벤트를 포스트하도록 `withContext(Dispatchers.Main)`를 추가했습니다. * `festivalId` 및 `announcementId` 파싱 로직을 개선하고 `DeepLinkKeys` 상수를 사용하도록 변경했습니다. * **refactor: 딥링크 관련 상수 정의 및 적용** * `DeepLinkKeys` 내에 `KEY_FESTIVAL_ID`, `KEY_ANNOUNCEMENT_ID`, `KEY_FCM_NOTIFICATION` 상수를 추가했습니다. * iOS 플랫폼의 `RememberDeepLinkHandler`에서 하드코딩된 문자열을 정의된 상수로 교체했습니다. * **style: MainScreen 내 불필요한 어노테이션 제거** * `MainScreen` 컴포저블에서 더 이상 필요하지 않은 `@Suppress("ktlint:compose:vm-forwarding-check")` 설정을 삭제했습니다. * refactor: FCM 토큰 등록 로직 및 중복 호출 방지 방식 개선 * **refactor: `DeviceRepositoryImpl` 내 토큰 중복 검사 로직 추가** * `registerDevice` 메서드 호출 시, 전달받은 `fcmToken`이 기존에 캐싱된 토큰(`cachedFcmToken`)과 동일할 경우 서버 등록 과정을 생략하고 즉시 성공을 반환하도록 수정했습니다. * **refactor: `DefaultFirebaseMessagingDelegate` (iOS) 로직 간소화** * `DeviceRepository`에서 중복 검사를 수행함에 따라, `init` 블록 내에서 별도로 저장된 토큰을 조회하여 비교하던 불필요한 로직을 제거했습니다. * feat: iOS FCM 토큰 등록 로직 수정 및 최적화 * **feat: FCM 토큰 등록 방식 변경** * `DefaultFestabookAppDelegate`의 `didRegisterForRemoteNotificationsWithDeviceToken` 단계에서 `firebaseMessagingDelegate.registerFcmToken()`을 호출하여 토큰을 등록하도록 수정했습니다. * `DefaultFirebaseMessagingDelegate`에서 `MutableSharedFlow`와 `debounce`를 이용해 토큰을 수집하던 기존 로직을 제거하고, `FIRMessaging.tokenWithCompletion`을 통해 직접 토큰을 가져와 등록하는 방식으로 변경했습니다. * **chore: 로그 및 주석 추가** * `DefaultFestabookAppDelegate` 내 앱 실행 단계에 대한 주석을 추가했습니다. * 토큰 획득 실패 시 에러 로그를 출력하도록 `Napier`를 추가했습니다. * chore: bump iOS app version to 2.1.0 (20100) * **chore: iOS 버전 및 빌드 번호 업데이트** * `Info.plist` 파일에 `CFBundleShortVersionString` (2.1.0) 및 `CFBundleVersion` (20100) 설정을 추가했습니다. * feat: MainScreen 내 ktlint rule (vm-forwarding-check) 억제 어노테이션 추가 * **feat: `MainScreen` 함수에 `@Suppress("ktlint:compose:vm-forwarding-check")` 추가** * ViewModel 전달 관련 ktlint 규칙 체크를 무시하도록 설정했습니다. --------- Co-authored-by: oungsi2000 --- .gitignore | 6 +- .../daedan/festabook/di/AndroidAppGraph.kt | 6 ++ .../error/FestabookGlobalExceptionHandler.kt | 2 + .../service/MyFirebaseMessagingService.kt | 60 ++++++++++++++---- .../festabook/service/NotificationHelper.kt | 4 +- .../src/androidMain/res/values/strings.xml | 4 -- .../composeResources/values/strings.xml | 4 ++ .../data/repository/DeviceRepositoryImpl.kt | 4 +- .../daedan/festabook/di/FestabookAppGraph.kt | 2 +- .../platform/RememberDeepLinkHandler.kt | 3 + .../festabook/DefaultFestabookAppDelegate.kt | 15 ++--- .../DefaultFirebaseMessagingDelegate.kt | 29 ++++++--- .../DefaultUserNotificationDelegate.kt | 41 +++++++++--- .../com/daedan/festabook/di/IosAppGraph.kt | 5 ++ .../platform/RememberDeepLinkHandler.ios.kt | 4 +- iosApp/iosApp.xcodeproj/project.pbxproj | 4 ++ iosApp/iosApp/AppDelegate.swift | 24 ++++--- .../AppIcon.appiconset/100.png | Bin 0 -> 1434 bytes .../AppIcon.appiconset/102.png | Bin 0 -> 1401 bytes .../AppIcon.appiconset/1024.png | Bin 0 -> 25795 bytes .../AppIcon.appiconset/108.png | Bin 0 -> 1556 bytes .../AppIcon.appiconset/114.png | Bin 0 -> 1596 bytes .../AppIcon.appiconset/120.png | Bin 0 -> 1750 bytes .../AppIcon.appiconset/128.png | Bin 0 -> 1828 bytes .../AppIcon.appiconset/144.png | Bin 0 -> 2033 bytes .../AppIcon.appiconset/152.png | Bin 0 -> 2249 bytes .../Assets.xcassets/AppIcon.appiconset/16.png | Bin 0 -> 309 bytes .../AppIcon.appiconset/167.png | Bin 0 -> 2392 bytes .../AppIcon.appiconset/172.png | Bin 0 -> 2501 bytes .../AppIcon.appiconset/180.png | Bin 0 -> 2676 bytes .../AppIcon.appiconset/196.png | Bin 0 -> 2834 bytes .../Assets.xcassets/AppIcon.appiconset/20.png | Bin 0 -> 368 bytes .../AppIcon.appiconset/216.png | Bin 0 -> 3016 bytes .../AppIcon.appiconset/234.png | Bin 0 -> 3490 bytes .../AppIcon.appiconset/256.png | Bin 0 -> 3756 bytes .../AppIcon.appiconset/258.png | Bin 0 -> 3697 bytes .../Assets.xcassets/AppIcon.appiconset/29.png | Bin 0 -> 507 bytes .../Assets.xcassets/AppIcon.appiconset/32.png | Bin 0 -> 543 bytes .../Assets.xcassets/AppIcon.appiconset/40.png | Bin 0 -> 634 bytes .../Assets.xcassets/AppIcon.appiconset/48.png | Bin 0 -> 773 bytes .../Assets.xcassets/AppIcon.appiconset/50.png | Bin 0 -> 787 bytes .../AppIcon.appiconset/512.png | Bin 0 -> 9793 bytes .../Assets.xcassets/AppIcon.appiconset/55.png | Bin 0 -> 873 bytes .../Assets.xcassets/AppIcon.appiconset/57.png | Bin 0 -> 894 bytes .../Assets.xcassets/AppIcon.appiconset/58.png | Bin 0 -> 849 bytes .../Assets.xcassets/AppIcon.appiconset/60.png | Bin 0 -> 879 bytes .../Assets.xcassets/AppIcon.appiconset/64.png | Bin 0 -> 986 bytes .../Assets.xcassets/AppIcon.appiconset/66.png | Bin 0 -> 985 bytes .../Assets.xcassets/AppIcon.appiconset/72.png | Bin 0 -> 1042 bytes .../Assets.xcassets/AppIcon.appiconset/76.png | Bin 0 -> 1080 bytes .../Assets.xcassets/AppIcon.appiconset/80.png | Bin 0 -> 1194 bytes .../Assets.xcassets/AppIcon.appiconset/87.png | Bin 0 -> 1187 bytes .../Assets.xcassets/AppIcon.appiconset/88.png | Bin 0 -> 1302 bytes .../Assets.xcassets/AppIcon.appiconset/92.png | Bin 0 -> 1330 bytes .../AppIcon.appiconset/Contents.json | 24 +------ .../AppIcon.appiconset/app-icon-1024.png | Bin 67285 -> 0 bytes iosApp/iosApp/Info.plist | 40 ++++++------ 57 files changed, 175 insertions(+), 106 deletions(-) create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/102.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/108.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/114.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/144.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/152.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/216.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/234.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/258.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/57.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/60.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/64.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/66.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/72.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/80.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/88.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/92.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png diff --git a/.gitignore b/.gitignore index aa2579e1..b3c8505e 100644 --- a/.gitignore +++ b/.gitignore @@ -66,9 +66,9 @@ local.properties *.jks *.keystore google-services.json -GoogleService-Info.plist -GoogleService-Info-DEBUG.plist -GoogleService-Info-RELEASE.plist +iosApp/iosApp/GoogleService-Info-DEBUG.plist +iosApp/iosApp/GoogleService-Info-RELEASE.plist +iosApp/iosApp/GoogleService-Info.plist /iosApp/Configuration/Debug.xcconfig diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt index 6304d0fa..7d6b3d52 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.di import android.app.Application import android.content.Context import com.daedan.festabook.FestabookApp +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.presentation.splash.platform.AppVersionManager import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -10,11 +11,16 @@ import com.google.firebase.messaging.FirebaseMessaging import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.Provides +import kotlinx.coroutines.CoroutineScope @DependencyGraph(AppScope::class) interface AndroidAppGraph : FestabookAppGraph { val appVersionManagerFactory: AppVersionManager.Factory + val festivalLocalDataSource: FestivalLocalDataSource + + val ioCoroutineScope: CoroutineScope + @DependencyGraph.Factory fun interface Factory { fun create( diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt index b579a147..0ceaf7e8 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/error/FestabookGlobalExceptionHandler.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.presentation.error import android.app.Application import android.os.Process +import io.github.aakira.napier.Napier import kotlin.system.exitProcess class FestabookGlobalExceptionHandler( @@ -11,6 +12,7 @@ class FestabookGlobalExceptionHandler( t: Thread, e: Throwable, ) { + Napier.e(e.stackTraceToString()) application.startActivity(ErrorActivity.newIntent(application, e)) Process.killProcess(Process.myPid()) exitProcess(-1) diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt index 9086c655..439e774d 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt @@ -1,27 +1,63 @@ package com.daedan.festabook.service -import com.daedan.festabook.R +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.di.androidAppGraph +import com.daedan.festabook.presentation.platform.DeepLinkKeys import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.default_notification_body +import festabookkmp.composeapp.generated.resources.default_notification_title +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString class MyFirebaseMessagingService : FirebaseMessagingService() { + private val festivalLocalDataSource: FestivalLocalDataSource by lazy { + application.androidAppGraph.festivalLocalDataSource + } + + private val ioCoroutineScope: CoroutineScope by lazy { + application.androidAppGraph.ioCoroutineScope + } + override fun onNewToken(token: String) { // Timber.d("Refreshed token: $token") } override fun onMessageReceived(remoteMessage: RemoteMessage) { -// Timber.d("From: ${remoteMessage.from}") - - // remoteMessage.data if (remoteMessage.data.isNotEmpty()) { -// Timber.d("Data Payload: ${remoteMessage.data}") - val title = - remoteMessage.data["title"] ?: getString(R.string.default_notification_title) - val content = - remoteMessage.data["body"] ?: getString(R.string.default_notification_body) - val noticeIdToExpand = remoteMessage.data["announcementId"] ?: "-1" - - NotificationHelper.showNotification(this, title, content, noticeIdToExpand) + ioCoroutineScope.launch { + val title = + remoteMessage.data["title"] ?: getString(Res.string.default_notification_title) + val content = + remoteMessage.data["body"] ?: getString(Res.string.default_notification_body) + val noticeIdToExpand = + remoteMessage.data["announcementId"]?.toLongOrNull() + ?: DeepLinkKeys.INITIALIZED_ID + val festivalId = + remoteMessage.data["festivalId"]?.toLongOrNull() ?: DeepLinkKeys.INITIALIZED_ID + + handleMessageData(festivalId, title, content, noticeIdToExpand) + } } } + + private suspend fun handleMessageData( + festivalId: Long, + title: String, + content: String, + noticeIdToExpand: Long, + ) { + if (festivalId != DeepLinkKeys.INITIALIZED_ID) { + festivalLocalDataSource.saveFestivalId(festivalId) + } + + NotificationHelper.showNotification( + context = applicationContext, + title = title, + content = content, + announcementId = noticeIdToExpand, + ) + } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt index f8729c8f..d666a108 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt @@ -37,12 +37,12 @@ object NotificationHelper { context: Context, title: String, content: String, - announcementId: String, + announcementId: Long, ) { val intent = MainActivity.newIntent(context).apply { putExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, true) - putExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, announcementId.toLongOrNull()) + putExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, announcementId) } val pendingIntent = diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index b28b6ede..71a8245e 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -1,7 +1,3 @@ Festabookkmp - - - 알림 - 새로운 소식이 있습니다. \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f00e6e0b..4a467f56 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -2,6 +2,10 @@ festabook + + 알림 + 새로운 소식이 있습니다. + 현재 표시할 항목이 없습니다. 로딩 애니메이션 diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt index 3b8c0c63..f233372e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt @@ -41,8 +41,7 @@ class DeviceRepositoryImpl( ) override suspend fun registerDevice(fcmToken: String): Result { - fcmDataSource.saveFcmToken(fcmToken) - + if (cachedFcmToken.value == fcmToken) return Result.success(Unit) val deviceIdentifier = deviceLocalDataSource .getUuid() @@ -58,6 +57,7 @@ class DeviceRepositoryImpl( ).toResult() .onSuccess { saveDeviceId(it.id) + fcmDataSource.saveFcmToken(fcmToken) // Timber.d("기기 등록 성공! 서버에서 받은 ID: $id") }.onFailure { // Timber.e(throwable, "MainViewModel: 기기 등록 실패: ${throwable.message}") diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt index abfbe258..3cb61916 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt @@ -16,5 +16,5 @@ interface FestabookAppGraph { @Provides @SingleIn(AppScope::class) - fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun provideIOCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt index 32771215..750196ea 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt @@ -3,9 +3,12 @@ package com.daedan.festabook.presentation.platform import androidx.compose.runtime.Composable object DeepLinkKeys { + const val KEY_FESTIVAL_ID = "festivalId" + const val KEY_ANNOUNCEMENT_ID = "announcementId" const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" const val INITIALIZED_ID = -1L + const val KEY_FCM_NOTIFICATION = "fcmNewsNotification" } expect class Intent { diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt index f17662e7..6d334127 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/DefaultFestabookAppDelegate.kt @@ -13,9 +13,6 @@ import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.staticCFunction -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import platform.Foundation.NSData import platform.Foundation.NSException import platform.Foundation.NSSetUncaughtExceptionHandler @@ -28,16 +25,15 @@ import platform.UserNotifications.UNUserNotificationCenter @OptIn(ExperimentalForeignApi::class) object DefaultFestabookAppDelegate : FestabookAppDelegate { override val appGraph: IosAppGraph = createGraph() - private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private val firebaseMessagingDelegate by lazy { - DefaultFirebaseMessagingDelegate(appScope) + private val firebaseMessagingDelegate: DefaultFirebaseMessagingDelegate by lazy { + appGraph.firebaseMessagingDelegate } - - private val userNotificationDelegate by lazy { - DefaultUserNotificationDelegate() + private val userNotificationDelegate: DefaultUserNotificationDelegate by lazy { + appGraph.userNotificationDelegate } + // 앱 최초 실행 override fun application( application: UIApplication, launchOptions: Map?, @@ -58,6 +54,7 @@ object DefaultFestabookAppDelegate : FestabookAppDelegate { deviceToken: NSData, ) { FIRMessaging.messaging().APNSToken = deviceToken + firebaseMessagingDelegate.registerFcmToken() } // 백그라운드에서 실행 diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt index 5bffa936..0bf9527f 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt @@ -2,29 +2,38 @@ package com.daedan.festabook.delegate import cocoapods.FirebaseMessaging.FIRMessaging import cocoapods.FirebaseMessaging.FIRMessagingDelegateProtocol -import com.daedan.festabook.DefaultFestabookAppDelegate +import com.daedan.festabook.domain.repository.DeviceRepository +import dev.zacsweers.metro.Inject +import io.github.aakira.napier.Napier import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.launch -import platform.Foundation.NSLog import platform.darwin.NSObject -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, FlowPreview::class) +@Inject class DefaultFirebaseMessagingDelegate( private val scope: CoroutineScope, + private val deviceRepository: DeviceRepository, ) : NSObject(), FIRMessagingDelegateProtocol { - private val deviceRepository = DefaultFestabookAppDelegate.appGraph.deviceRepository - override fun messaging( messaging: FIRMessaging, didReceiveRegistrationToken: String?, ) { - // FCM 토큰 등록 - didReceiveRegistrationToken?.let { - scope.launch { - val result = deviceRepository.registerDevice(it) - NSLog(result.toString()) +// Napier.d("Refreshed token: $didReceiveRegistrationToken") + } + + fun registerFcmToken() { + FIRMessaging.messaging().tokenWithCompletion { token, error -> + if (error != null) Napier.e("fcmTokenError: $error") + Napier.d("$token") + + token?.let { + scope.launch { + deviceRepository.registerDevice(it) + } } } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt index ea6db06d..efbc5297 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt @@ -1,5 +1,12 @@ package com.daedan.festabook.delegate +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.presentation.platform.DeepLinkKeys +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import platform.Foundation.NSNotificationCenter import platform.UserNotifications.UNNotification import platform.UserNotifications.UNNotificationPresentationOptionAlert @@ -11,15 +18,17 @@ import platform.UserNotifications.UNUserNotificationCenterDelegateProtocol import platform.darwin.NSObject // 임시 -class DefaultUserNotificationDelegate : - NSObject(), +@Inject +class DefaultUserNotificationDelegate( + private val festivalLocalDataSource: FestivalLocalDataSource, + private val ioCoroutineScope: CoroutineScope, +) : NSObject(), UNUserNotificationCenterDelegateProtocol { override fun userNotificationCenter( center: UNUserNotificationCenter, willPresentNotification: UNNotification, withCompletionHandler: (UNNotificationPresentationOptions) -> Unit, ) { - // 포그라운드일 때 실행 val options = UNNotificationPresentationOptionAlert or UNNotificationPresentationOptionSound withCompletionHandler(options) } @@ -30,18 +39,30 @@ class DefaultUserNotificationDelegate : withCompletionHandler: () -> Unit, ) { val userInfo = didReceiveNotificationResponse.notification.request.content.userInfo + + val festivalId = + (userInfo[DeepLinkKeys.KEY_FESTIVAL_ID] as? String)?.toLongOrNull() + ?: DeepLinkKeys.INITIALIZED_ID + val announcementId = - userInfo["announcementId"] ?: run { + (userInfo[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String) ?: run { withCompletionHandler() return } - NSNotificationCenter.defaultCenter.postNotificationName( - aName = "fcmNewsNotification", - `object` = null, - userInfo = mapOf("announcementId" to announcementId), - ) + ioCoroutineScope.launch { + if (festivalId != DeepLinkKeys.INITIALIZED_ID) { + festivalLocalDataSource.saveFestivalId(festivalId) + } - withCompletionHandler() + withContext(Dispatchers.Main) { + NSNotificationCenter.defaultCenter.postNotificationName( + aName = DeepLinkKeys.KEY_FCM_NOTIFICATION, + `object` = null, + userInfo = mapOf(DeepLinkKeys.KEY_ANNOUNCEMENT_ID to announcementId), + ) + withCompletionHandler() + } + } } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt index 153af385..60561d90 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/di/IosAppGraph.kt @@ -2,6 +2,8 @@ package com.daedan.festabook.di import com.daedan.festabook.data.service.AppVersionService import com.daedan.festabook.data.service.createAppVersionService +import com.daedan.festabook.delegate.DefaultFirebaseMessagingDelegate +import com.daedan.festabook.delegate.DefaultUserNotificationDelegate import com.daedan.festabook.domain.repository.DeviceRepository import com.daedan.festabook.presentation.splash.platform.AppVersionManager import de.jensklingenberg.ktorfit.Ktorfit @@ -13,6 +15,9 @@ import dev.zacsweers.metro.Provides interface IosAppGraph : FestabookAppGraph { val appVersionManager: AppVersionManager + val firebaseMessagingDelegate: DefaultFirebaseMessagingDelegate + val userNotificationDelegate: DefaultUserNotificationDelegate + val deviceRepository: DeviceRepository @Provides diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt index 2d476e0b..44bfbfe5 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt @@ -28,14 +28,14 @@ actual fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { DisposableEffect(Unit) { val observer = NSNotificationCenter.defaultCenter.addObserverForName( - name = "fcmNewsNotification", // 커스텀 알림 이름 (AppDelegate에서 쏴줘야 함) + name = DeepLinkKeys.KEY_FCM_NOTIFICATION, // 커스텀 알림 이름 (AppDelegate에서 쏴줘야 함) `object` = null, queue = NSOperationQueue.mainQueue, ) { notification -> val notificationToExpand = notification ?.userInfo - ?.get("announcementId") + ?.get(DeepLinkKeys.KEY_ANNOUNCEMENT_ID) ?.toString() ?.toLongOrNull() ?: return@addObserverForName currentOnDeepLink( diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ef0499be..8d0387c0 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -283,6 +283,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = iosApp/iosAppDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -321,6 +322,7 @@ baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -380,6 +382,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -412,6 +415,7 @@ baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 57a0d92a..f0ea68c1 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -6,23 +6,31 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application( _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - return festabookAppDelegate.application(application: application, launchOptions: launchOptions) + let result = festabookAppDelegate.application(application: application, launchOptions: launchOptions) + + application.registerForRemoteNotifications() + + return result } func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return .portrait } - - func application(_ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { festabookAppDelegate.application(application: application, deviceToken: deviceToken) } - func application(_ application: UIApplication, - didReceiveRemoteNotification userInfo: [AnyHashable: Any], - fetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { festabookAppDelegate.application(application: application, userInfo: userInfo) { result in fetchCompletionHandler(UIBackgroundFetchResult(rawValue: UInt(result.value.uint64Value)) ?? .noData) } diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000000000000000000000000000000000000..4cabfb55e643b1bebec0ff811a057a4380875796 GIT binary patch literal 1434 zcmV;L1!ek)P)h|fib#>z@GbZtEZBC#h8;_m`4Fs8yJ3d}66&T>rLHT1 z)SwDM0%@GF=W!QD8u@ZxZhCQAb7tX1+C2Qrng4ms39;6Cxauv62oM&+LRbh3VIeF; zfUpo2!a`UG3t=GwgoUsW7Q#YU2n!J)EQGb{<=?aqWUV#ESZk|oC-Ud@yTj62E2Xs7 zfT;=vsKFH;r<77k8Do^vCfSjd_YIX&MNt@ID5bvdGsgbu{(+-0#ux)wJ~i3nrN^Ss$?279xGu(Z~fmzQA}Mo|PC(DS?x zA3hu$92^}Tef;?G`}gl-+OjOe zFl^yAG{(&5bH(vBSf|r%#`zX)1(JN>#=Cudc9qRZ8`Gy&wpxQAOJ#_@XFW zd~|+(zQ4bpBne|oDRl+yAVh23?RFiyB*~~q7dU{%x<&|6*{IisDu~;aj*4EbCchHhV+FI*GJ&ATI=a_ znx-i*diLzu+1Z&8;^gGy;^G2qttRwGj~>Nw3{gT^mTPNkFJHcdXm&cCW?2Sr(VhtY z?c28}Cnrgggke~p6d2dLcki^;082`_xw+Yxw66wMnx@@uH;!YM57%FvPRCVpilWG} ztl<)~-+akIYrUMrxIiD$9j-5n2GVF(Lh;X+sl%WHG!;QGl+6#QI;&sAw!4ogaz z=Q$w+>JZ))OVWTCf>J4E%VBM8ZRL3`gt)l4fDTRl=ia}6ADUf|8}|GCs&O@7XiX>5 zN-3ojW9-S3C$C?>?smI%0&$>oaBwgj4xtnS)hplkA)22|CM`*%tK9^5^ka$I6C8>7 z_t(GOut1*NSt2OZEmx&;{a2S-Iz~$<;0=YvIiJtxi^XC#n{8}tbUK}DTIhH@)>?DU zfjj412+@FXH32IKf=;IsMG@@2YZl9`wNVtop%Qp0##l=%3(DkhP>*x|zuc1V`%x4{ zQG^jZraK4=Vd1&~uvU0}{r16X!EaCDHbhtm3t=HF^oHmSaT_8mgysFVW4~?`v!-)s z;C$`Ph4tgdk1t=oz_f22vPDree9rF%!Fv4o@#f}c9LIH9)6nmNv)n7Rujb|rt+kWM zLpSUHl7jgbpQYW07*qoM6N<$f|@hE?f?J) literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/102.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/102.png new file mode 100644 index 0000000000000000000000000000000000000000..69a30a289896ea9420a3fb23d0f88dc6c491b260 GIT binary patch literal 1401 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 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/108.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/108.png new file mode 100644 index 0000000000000000000000000000000000000000..c97b253bdcdbef168c10e36836a2af668916cf1a GIT binary patch literal 1556 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 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000000000000000000000000000000000000..31d722879bfd995604d61fb37a417f32d497680e GIT binary patch literal 1750 zcmZ`)e>@Wi8=qP%#Avc}ix$d|pVKg6J64kzdDjrq?k&GUC?=YQD{rI1MP)X8=10tr zV_YPbZo5o=?Xp9ddY!Hem$qHDajxsX_w#-~pXYf#-{<-Bd7kgLfDmwWm$r#E007v9 zKjss>RWtq$(6+7Isacf|0BADsKA4bg-4}v;1~jxW zJbPB6Ur#bnnc3Phpd_#%$l@6dXsgiBGy8T6FHzQh6Q=D&L8{ zv7*F)RS8e~;|A`z78N>1LpLa+O$mFxvJ%I+%qjK?g8YK~5bT<d$zSh>!#I9R=(Gy$Q)YIMBc>wzLgGx1&NrfSs7RF*AzIPTW z$qhk%bA43m;xMBwklD=PSmLpWe4*LxYmW#$PJDu&+aib9IFrcqpnMo-A3r}_p`oZR zwH~zm;RE4ra1qXY*o~mMzwt8|0oPMaeS>PoS{?aRTS+28qZWsdEeOj-m|k92RV2hE z`BEstlC0~W#m2;Q-neRtwP5++Q!SC%f38NiPbP4~z`ijYD&sMUQYml?6s5(+J`Vr@ z9bTT`@nWplu8Wd&5M106MO~Hf<_E6tga;4e8J*mN?d|Q8x7+HCBKC~pi-8zPw;d4L zl{nK)-Q2iiesmn^dF-?g=pK{VW#b(*VG#?i!!teUTU{Q>Y;;-qM89^sw{iK65~m+A2=*ccJj9M&Uz&6t z{xqVRz3S{Jei5pZxUsl+}HwPaVO}Gt0611z2V{EIy6ZR3qDy;T_4#py)}<2 zfc*FXo(C<&VVTpJCO!?*T1J}*vzop*&zm8Vy8n@Y6@R%d&!2!+DF za!GZQ7NXUTtI+**IZH@!fL5T>6DgEX5ZpOM{>$!)cOj&Ht-}va{7f+Q%uIX3FPxkV z<;eJazFdB}r~`|5nQ$1cue!)}ns_fG5{bgwkRlxCDI~~0oCO8&DTHO46O8s zrax-Fvb7y!`^aD3%L?S4AUGP0HnuxW3JqeIS~&v5>bd(uK`9LZ&&k* z{@A{5m;BH{@6~Y)u8%UVQ`u+8s=AjIA8&&%=A8ipuet0M`TZdli}S(J(5VJlb|10v z+QItK|9oesBe{_u9R=Z!il`4xst#y< z-+Ob<$>dk>z5hoW3t2%RlYf+Q)Cwp)voaI%u9FaucEGlATB`&8Hapf8CeQR#BJZnn zmt~m)^I!~GX)CTglD+<6L0E_*kI(ib%{^hbUwYV)4uNC|nU3cipdk4D7q!mP>B{W6 zTh*ZH%M7Mx^y_`5ljwP8{06YIk~d(Vo4eP_EXOHVnZ!t`>U6b&a#C zP(uo}1-_OGs6szisH@k%I=;?IEE$6=wj^u@@;JX`HdPz;TH?||Aw$2POs3L VPYNqfKyCRI0Ph>%(~Lbs`wMoYMN9wy literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png new file mode 100644 index 0000000000000000000000000000000000000000..1485eeac4107abd93fe78693153dfc12253a6d68 GIT binary patch literal 1828 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 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 0000000000000000000000000000000000000000..693210f3e6ddb325f6801a4af06eb9100dde1297 GIT binary patch literal 309 zcmV-50m}Y~P)qm|gOSWm(2#x+gT_dO!Iu4@>^MbiJ0{>=MA{Pzn#$PbitRv+a!00000NkvXX Hu0mjfyGDn` literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000000000000000000000000000000000000..520248a7aa2405fffba7e666245b0afbe5082af7 GIT binary patch literal 2392 zcmai0dpHwp8{b>JNpdK!Nsc9_PbfknBd5v8rpY-WkwXs4Oj8kFm5MdTIh1A==1g0x z53!<=Q;S(z4r2>>8QIXgzVG_}dEe{%zCWJ(xv%GXp69-=-+e#7-;;UH)p4(+k|Y2C z*b8>DgNkOt&mplxWQFm}mjJ+SHL#tH2XcI&*zMl%1dtXNoujpd2_qDz9X}N0ZewF3 zFXp+}T{Cy^J-%|uR;&Y9Hs|m@?X379mLx_iR`(DG!W{0^L~28#a)kQQapa1rP32@T zEuK1=wn{_~6%JT&N|F}I+Ahvvpd=UNR=|kHxeSoX4ur)2c6R;04gb2t3DeBj?szWT z*Z#5L!2Te8{vp27$euIa&boM)oIBgkfVyjUawG4?^(W_T7kv37Yf3AoJ*z;%z&Ufi z2psW;eb#P)cby6Kd^fJeSDm|0b#kDw{gB}%{XP>&T4YkTby>ab&7o5!5_1+6 zVM8~$seqA$tXA1WcMtXCM>6rywx4;6@0{Uxipx!J#VoyVwE zm$>r9Ft4wKb3!Q#UNsutTu9e)cNt5_(bVhoT!I1W@mGK0w>@%npB7T5%(2c#LWpstv+pV|{wE+Y{970y7re{@h#Dy~tr%D6|rPY>(d>|HY!VPI&eW{Iq0MX8;Pea4D1 zG!%siK|9TlOqo|yb#x7M*6-1%$fm`(v{|DCtKXNWfQJ6@7R5$Vx-S|deFARb;cLO~ z-R0e#bns33Rq(0>X6w>7c9*s*&K$uVA;)ne}`JfYhJvSt9J;Z@( zb@yr5^g~rEO6*LpKp+?z+6SQ&AWsN}jHFa(UKcK$Gq{a&)KK7cw;;R;5Uu?T#QA*4 zn^Mcf*ewAc)!u*d;ZvQ5wWOcGpTGiioc(iw78-}|h%zMmN-{h>J)aCbg;_0cK?Q1MDi*|4i_JXUv!8(SSDBbGxX zH!jqf*}}iyE2;;dZ0ae8K+C6~76(slT=T9Qv`w^BttF8tq@n;>LNb7Zvux|Et_*4~ zL9-9C0>)WTT{+2;Q^oz`wXNnC92f)5-mmn2t@eEbiFsBKaB{#V4umVRB*-;=HDKmFea&J7_U;F0P3GI|Mc-= zS%=+s6&jJj6VRTIK9@~dLx8M|r)?z$R0340Uuylcg?j(F zj=&R}k3|o(ifx2p8q>kPv1=PToST9=}8up3ZUi7|#&L0mvi+!JpG8GmP z&0sST1@fnP>8BN?3<9Wa4Gp$z<|HsA71fh5ELEe zId)e@@P}cw7s%yHjbkqK6XxRCS(TvP^({R9NH@|*5qBC*VJ<*XuFhUH@J7FC;~p(c z=919o*UT8@S7_nd^jnPHxai?!gm{gHW1YVQ^2nN9Kp-&8@q8((5n)8)acASHQbJYLOioo*dKfe?$9hC2;-2eOqM{ClHqHb*i82UKGg6ciY*8bb@#TFp z4XEPL=`X@$#q^%is}_lI4>ez9TwmS8W@ojz z*=A!gN1=QDT6P*p?67b<>8A+R_F=PEve+^yK_3sYG&RDIwkPehsDw zzv4L3<|%Qkz(rcc!tQ8MsPUjtk6O%?U^!Zu56{q9$101cXE^J z3uQ*w?7u7Q-r!&x7RW*2Z<_nF?#JIp_v_f@K7LRdlB4N{paJ+dJ#1v}I~-=V7|+{? zln7k)fJ^P_$U^_FQ_s5HSM9=JecUkFWPV0#9Y|9OS+PDri*>; zcmcA+Qx7YNt{5#WQmG4~JqXN*97y~=ww^7{_mh}_T3-Gf+q&srJ#m>~bjee&{SI<5 zz{!U&Yd5u=uVkhTKE885K>>z`5@p-FXR|2b*4lWFcFKx>rjzdZt7KcpC3`=z+%3D| z2-G>^Vs_l;X)n1xt9i3N2t6X^jj6a#hHRGzmD?6#|Ayx;PU?SI|Lc)Qft$O^%V(2! Tq=527KPCX!-qns|dokrtJBG4% literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 0000000000000000000000000000000000000000..9239b459ae910344c833642f94039999dcb07dcc GIT binary patch literal 2501 zcmb_e`#;l-7yp=*P0~boP(~M*Tylv`E;F~xwJo9$wumg*Ql_vbx}ZGaN##}&5~6I4 z$E^!JCYSOwCL;0SnOiQ?HuS8%-#_6wuh%)}bS%vBjyIFva7t-Ny&a^489%!-@aatQm+=e+(wA|Pn1g>} zXt*l<1bWQrY3(MMHcVNT0k@cnn%QY#?|$M|1}nF*tCKx_%z9NGv>b3u$R9#Ae2!MI z;0|;y-T}{bSrb>{Z8oVzqfr|WNID<|g&{G36aK-K^= zKVAy9K#pCj;qvuL;(r)_m?u#h3e6G4mr`uxsd64>ZhATRrQg@jf9NkiZnAcHPfLtO zd(2Xrk_mmpcz-hku0=pD%Gsq0&o=9g`xlLHN#EH0d53@xN(Kj5D9J79RK{_i# z$ccItnL^hqC;Ri!P zzMzz`*_`pvninfFlaT$%*owa+>y92$a@B*Uh1N`po94u4J?6&Ub#nCfOcJ`-{QB%9 zfDxjIEJ+{p!=X(~LaYFh(S|5at!;wndHpYAGMQ|)a8118S(>#A9=PAe`$&HJ88l zm(2|~iUl+GvTvCvqxy4DPQ}_3E|xZHc9KNY%1tk zXqz9(z&mAMz2Xha+%l4Pq{7DoNZ`ceWn^Ty+}vSkXh5T*`9{1Cf7Y zdt65I2u)KcPu0|{tgJ1XwgG;AMHr&LZ^O{(x}zbgVfbq;imT41xw~9%?RvDOC^KqM z@GQK0hjrZ3y1H%9*3M2jr?YzFNt3#SdT*oK@J$k65?A}D&UR}0;>IgZy>{M-fN6a} z2@;NjQlzb2!kIx

2X%>ge>e!8S_L@P&^6`i3IZzS7rCLHI!%f*{FBZGdoNB@pUk zKK)6f(r9eoiZ!-yWf4;V`c(LsUG;|izB7CzJyjinb$55qWyWZLUHJz%91fLA?KAAM zEvzM%9A*c|zv{V6ug%nmz2PDS`KNPIDLwKM*j;ExTX^VO5z{4eH|={<^g?*5bR1)J z+lD?B(}b3ZiHXJwD=+t1BgLH8iU;+Qk@E?5%T)_Gs#Y{(*)mIzR^YE-U}W@>zZ_bF znuPe_{40ImO%xg7M&YR{o60Opo4?f03Iu}p@81^<(FH#_P%c6g1oy{Y!s8q;zQ_Ij z%enklQ#^eMLvl~a(t{7eFOdi9Eiq`}JFGZHGMkOg&pP^tkFL_m z{d-*kcm7e$m@l>a7-btOx|ZVv-;d14QE>0&g1dMIk(?Yity#6;eAZ91PmS5_gt^(>FGZc*F?O! zQ_nAx{e6pjua2Q$49R}8!_`*K^ngQV76%TT!UhlP=8oRi*|SG%F~|%KSiFcg~S*D5~crt z^aNlCd7w3V&M>*IM~80q^lAH<&j%p*;?RwwW`e=pAJ12qAXOtzmt{v<+?(jqRwLyL zcf2~y8azLn!>^ZcN;?0lG%RuJ_8Z#|2)?}8WQz8v3=9akC>D@Q@PGZ&=G`8{n-NqV zV;Ke3J1#B|qLZnKqc2nJr_aeiBeF^Zs+ztA-Bh}XmgBR5zA-R@)RWj<_gfxs=3H@ak<7i^oxRJ?%vFi#>k8;( zdO_r^UGR7JZjQxKD3n}8&*pvko*?^g99&9OoEc&={K0rxC`lM8A74;&hf703}u);(dj{p04cw z(T#!J7?W=u6_F0hjLpI`6^v>PthQVM}mi_SK(t0l?6qkNm2B zp7_f?j*}{>r2yoG6bA6m4HDd@P01xJOC?2;@>Ydm* zCg)eBHE&%#5Jl-qLHOrLH&eX1v71sLFIz_)M(V3TT4T`7Do%*Alv<#Lse)GZZtdM* zN6(pc1ygG$ZOlAN3DWpu%DYVu0(py#{qG)`qHjITkKSy{B);{Xl)PtvtCOc=6*idh EFQ9^YegFUf literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000000000000000000000000000000000000..cf7453aeca7e38d8cd92acc1fd25f9abab89e117 GIT binary patch literal 2676 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 H&X4~M)f(SO literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e6e4609b8c16550037bb7c1632299ef93eb163 GIT binary patch literal 2834 zcmb`J`#%%x8^>4A99nb8B$*1Kl*eJnDastzX0#;DsWPYfZfs6ZPSs*~h|L@tLn>wt zdC&-r%4x`Xnv4{WQ$q0|hxA>~zwrIuuh)HFulu@xxelM}e!Y{N(e_dxB@h4rkU}`v zU_`d{*OuHXI-Ob%p9KIw?-4duE`N+J<~0UEkkEt;SmQz?0uGlw0GFUjItEx}IPB~* zw0VrMPoO?3B1xxPMWuU??5CGf51FAp()P3hQ0So|c@BiO%Ui{j3$Ui4Y z$_{_q4;gOnNHVTDmBd0Qg*%!$borCJoLOV4mbG*|Xq;fY!g0dE9ES`YyQ{%1e`$B` zoLJct+34%HT$wr$GLfXdwY6%y1Q?0tu3st zuP-eT8pn$z%v0%ov!^q=Mn*>Xd_L{Ced_oB>aDXX_>>CnJ%_%;IjyG4fpQkFs>=y=+dG@9 z_Gw4C(VHY6pB6oE->Qe82CH(&S)&?_2$|?Z(p=g*hAVYy4%7o^|u`l5Y9SP1`K> ziNeR9lQLUE;DVs?kb;8il`9<`9a5MZEkL&SqA$02Y|0-pLNtOR#jDfZ)U~380?QCF~LZY9Kt*WVZ#pBgzH^hU7TA2 zli7IqbxF+ukL=0R0SZ=?cE4_%AI>mI)Dsn~g{Wg{-vO`iRXB{rG zT>^loNp*@et$9-&$-Tqss&T$ogC?3hJv_9tyIZs})o4i#q|&a?!;z@e8pER{U&0$p zQLXe7PB}e(1?IlLaZjZqjtsj_f#k2fQ1Mq5^==Hf6><4`Enn%Y@i z?pO1kP&YMSj$zvjj`8^}PX-=|i3K)SKDN$z0uA1bj;+jkWCtQYaxIf;xCV2q@v1B^ zk60QkLY{ESf3BSzvb#FlS5Z+Rk{O%LPDIIP*$u1SMRAA5f{u;Nhhi%fj6Qt4f#G^J z6Q|mHa*em$pfoQ}H!wp^U-ql;2P%3(hI}pfc58LH-4o)cH}URugn=F=?>RB7g}h5Q zP#rqy``KXFJJv_Ie%@Zvap-SmUteE0UTn7;=grH=_`M$7@{uXHX~gl{`Zm>Z9tlyk zDZ(%qjQ69X>8sgX74kL=<2O1%UYynEPOR zqSaL(cTp!_sSW`S$v--%D!TB9=a+fFG8IU}J8X<1rulJ1rQai;EPkFCr3Td4xBhhn zc-gaq@!T^HZoWU%?8sV461-4vPO(40Fo5kpCeuM&$L5iF8FN76GiSm#S9}Fw)8VyE z%gwh}{en-o22NxqAJVmZcZE%3yOtd}7waKV(_Kr}id~gu4bVi&lU!0t4+&8I(HHMT<4Q|?Ips>F30PgyKOwK zJ#K4amz)7R+0(h$3?b1{r%Y1m-P4Lbv{CdD>3v8gQWRqW-+~&Lwi)M9h!^(is(rEt z#R;`KR}dehk=S=5r?clH?BfG#lM}bBoY1!zAj-W>l-(AkP2WNpcD?;D?t)h}5{>I} zNa36IXq5VqQSi=c(xE@l!LLfywb!ONhug#utd77P;(1oPr}Kn9@Svgcp0 zxr2*QO%qI;NWiLU&5i^BZ?r1I->&F zD<>i$=oJ9(>2Q|W;m((+DcgtJ-zQ#kPvQnE#97=wB#0=(Bdt>ngsdUuZZbk%v2^(J zR7YC8?EPd&aUdFvwu4^`zhGQU96u6h+dlH;i#AA;7pnKbnCWwwHWPaK)%G!^ET;4x ziZ~`%+zzf5cZMZ{(}#5$sLPS&#PUY?vkpXF1{pG^=)=K22ssM8Cp%f^I)IzFkHw-3 zY0~%`DFgF5quZ^|-x5PTlEx0S)h^x#UA%Jxia736AFOPjW}RF8a3;muABp)7m9+|< z2wZvi*hN8&PJvQ*&=AZM+Dm%_$~p}(JMN#p`VIy&yjzh1X#a(P)O~rio{ZSQmh>McX`sUnBn)_bx?c literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000000000000000000000000000000000000..dd7b98c983d595c67b6fb80aae5f65305b2081fb GIT binary patch literal 368 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 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/234.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/234.png new file mode 100644 index 0000000000000000000000000000000000000000..5b0f947cbf2c6e30a3e0515193078c8f58ed895f GIT binary patch literal 3490 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 TzuOnC_TsTHvAvBna!>sqJcv8A literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 0000000000000000000000000000000000000000..6c3c266c810b90288dc12b5d9c42dd713b91ea35 GIT binary patch literal 3756 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 UY_jS(zmfn`q{VgWRhOuL04{XN{{R30 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000000000000000000000000000000000000..e56082e8ff571e1a25d68f8560c091512b4885f4 GIT binary patch literal 507 zcmV6ooT)ZvHTsh(<_}&O#7ud=V>O!`BdfA;A~0v9=HdMT9gq{sbnIxmye@BJ3o% zyRgOHDulTocg~zS!-&ZGg#zn$>Yu9%>n~MG=?}&jW6Zpm)$v1FmH_a~A|f*y>p(f@ z_xpXj-98?VFbtC}_yWMOyJDpCPa|62KULazl z(fI5qBHOmx?Y7AYb+`av+x9b;bMAQ_=lo4B0OWZ-91iF6`R#V&oFn4(dL56)>-Bmt z7ziN%;GKC9(RJNUr}H_lQYr|7BuNm_ECrZFV2l+-@%&RtNkpFK8HTNuXD=qzH=;6C zYUQLJoH6#2KHth6rBt5hj^pT)e39i%E)fMm(ChV@&8B5pQ53~-Tqjy9loCReWhtf9 x+t>aD0M~WDmA~3d&z92YpLs9--}yhs{QxGM2dCh011|so002ovPDHLkV1mqG>{b8( literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 0000000000000000000000000000000000000000..a38c69a7d41dfa031dd64fe61c9c8233c1613e6b GIT binary patch literal 543 zcmV+)0^t3LP)61b7f<3r^aca&1wn!v4ch&&_01#3fAC#o25aOdpDPUS5vJBO#>SZVv_n$8jj7nx^GFRz-%0h`8VHCzDC5)iO=fG)>F0`u+al zaF9|;DRZ8)lEqmp7TfK%)9Ku^3ByoIS*z9V{dl}Yh{za=<9N5**|wdgX{A!(ocDUY z(P-3cHW4wuL~VQGWOZEK*7VfaiJB>C`Zc%ohMe zQ4~edX0sU%hxcobvxqTf+xG2t`xCfcuQwVE#@G`E4G~YLlVKRYvj*^eKS>fq{3-w< zT9!4R&)4g9Hp86r*=*MBb}h>iLgdd{!6e6V%oy{1-*sI?B!mz`E|<${wHlAdVHoPV zuIu{aEUH$k0B}B^-vk8^1VNgnjIn(0rFuXqrIfypix5rIKAt~WuFDAJKeKS)tvz|+ zD5W^(8H+gQ1!~+LyE_lWWfTov+zpk|f2e=2I(EO8Jha hPb<;S$Lqfb_yq(zGQR#c#3=v(002ovPDHLkV1nk@`Un63 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000000000000000000000000000000000000..eac36c2654902a45a1e2195f9121b9addb93e7cc GIT binary patch literal 634 zcmV-=0)_pFP)@2JVu@gVSZ}5x6Z?Rgz9}on=Qn1iMg=Eys-1~|Fk==c~ ziO=1AJiJwykes<^&YXLIh@77&=KN5-fM3A>A25ITORY5`I*tPXj4{Tt^#BhTNSdZZ zB!swMR7w#M=NtgO1o(VDd!FYw&T_fjY&Kf!D2i&eTCG+SLZoRbgs{S5gB7i zlC)Z_>2zu|c)45(g@TQ$ndK1*A&$r6{q=okYWw{@j$`{FVM}!`m-Bu9cRvMyFbvJ` z_-<8aopP_t_3vh``-Ej4EM@$+z*5RMj?M5l_t?aeF9aqc#@Ki~j^o&MT{Hd=(f9qV z5i?uZyI!xxm<;NTd9AgSGM~@C%bm|mnYXNSe|-VKb=^!e@xfgEy2mfz7x1U`3+61G UQT*}v3;+NC07*qoM6N<$g8!j52><{9 literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 0000000000000000000000000000000000000000..3bc27c6ec224d89169337d929590cee657d6b449 GIT binary patch literal 773 zcmV+g1N!`lP)3?eofjUWhKUtfE@9wNqZyu7^J zYPAYJr4(Zf02c1DF-^DIJv}|Wy}eD6#99jgQp$F_y}rJFa&oe^wuXqMfT}3uy&n#T zQp&Tlv%|wfLP#9Pj4?up_ug7-j2RAxH#aw(PG>Y4MNza^AVg%0^?JRXogG9JLRf3Z z`xFr^gt)xCY`5Fi+As_knD*ZLEXydR=jZ4Bem{yLW6b2KthFyMFG{IqvuTVen{MS| z$T??>5ke3`9v>eIzE1{H%H7@Fm6a7KrPg|}b;uYK1OXxB;o;%=`56GlKX z`NcW^KDP-WVHj2`MHRDbE;jY8qXzn8_cBT;V~kQdIlLB=_3r>>S(YRTrPNwGE{RE! zAmU8JLWTTH(^N`%e}Dh<^u#%zR0t8bwzgJRSMxk?Hk&^LRCMwocMx#%cjmg*ofGCQp1rw=%3)et3Pz_WA{l7i{5`>U(vxOF literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000000000000000000000000000000000000..7475484ce5f383cc9b61f0cd1cf5f6c5bf87221e GIT binary patch literal 787 zcmV+u1MK{XP)K#j1sHFDzQqe5^E&EexAa6Z;Wxy^?t+{ z6G9kcgb`KrV~kQdA+-RY)9EOs5JFd1SLf&Fx3{-x zno21b78V+f#>&dd>guYLGELL`*l^j4#mmc!wKk69-QC@}xw&e!8b#6c^mG(O)oOKi zb~XrtqoX4LXti3yCNHQ~mSv1FYwh0N-s$P-{QP_vhVS)&F{YHpaU29e{&PZ|m}Qv| z;`sRZ?CflDanU)Km(hCw0O!13uOoyAA(QAtV@#H1*4k#X$vOYngT|Q0$H#WN9fn~s zwO9e25JI(D4IvcAF{QM(modiH+L@V|gM$O5)cX3m_kKcHN~xQhn~RH!`}=#wSRYoF zWsOE-ZEY=0(>RX9F!bJ!y|%pfgpg*lxwEr#cXua*=*9BhE2S7_993g~r zKK7F5oO8~twfU$Yu$35Ngb-570$8k=X#v2u%_yb(YCRl_K@8BT?^t>BDY5>qdm?#@ zzAuGG;b^eh?RJtR0MP65Ns{E3$A5;kv9Y1Gu2d@TSd`M|=jY|+<=of`XD)K9_TGOx z=Kki)h}Fv97h0{>fGv+wTG--?zd_{t=V0_<-yKHwG*a4WiB)2iSS8k5`~lim^?RUV RpY#9#002ovPDHLkV1f#3g_!^V literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 0000000000000000000000000000000000000000..5bbed66aa6b0af7a2490b191f5aed18af0d3c104 GIT binary patch literal 9793 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 Z#L(pXBwO#ndjO6b*X~^XtYQB2e*mq?G=cyC literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 0000000000000000000000000000000000000000..2b67d472e5065bd0b361731fd76606b8f9f412ea GIT binary patch literal 873 zcmV-v1D5=WP)fNs=g~Fvf%sjPZ0R(=-)A7(UPQ?(gqC z&(m6S&KDLI78e(5wHg3GDV3(F@ov$SeZ)?ub9#DueSPhDUY2D50F1G1+jDbs^Yin2 zdwZ=`D@{{MY0k~YOR%0zVF}O-Zq=fR;wkY%$J>G zQ6@>k80&Vshlht!%6h#ngiuNio}rYwuG?rd%ws;v{7R&?&aw<+d~tE{@bKU`PL^du zoj?4v+1c6tV{yJzN}0+PLbTg$O6iab4b#WR$J^VR@B5tdqV>{nlQN z$cqS}IF6T>myeH+F~-ei697O6QA!n5<0jUOv74KlPN&oDb}6N!Q=XKv(P+eR97WOH z-QC*STJA{@=J--6_5S`oGc$8^baZlZvb40MwI2NsgfNcdBuRYVKR7rzKR@?nMu)UB&kHcDvo?oDY@=A>{e_`Rwd$cXxNt_l&WEln6pd z2+eZSZ1#c@1D>3Lop$5W>4UpYHFI~yAt zmSqi6VvK_zSY2J!T932G%HM4m%d#xXveEU`Xg?oC(U_BNeoE6BB}vj>1^(?GXN*~v zH66e6vu7HcA5l8%K#ZbVnU)4Ll@Y14IY6V7ABs7nU#?bF*3jtAhC5|VMVAEwIIc@ zonzm@w-w@}LP=>aS2*=r>V}uZ4 z7^Z2uzrTNadYY!`aHFlY0Dv)8N^NXxTwh-sW2RQGQcBLbwRUG`=j`k(Ns=%OhrQ)m z%JaOJmzUYuS&T6NFvh09YOU2;2SIRha&mETv9z?Ll=?Jtd7jVD&%1pn+*_seRVn2j ze13jjtJMJDljgwRLdZL* z9=6xf(UA~hVPRo+cNgR0ieB)SQp!BfqbSooc{N@-!gGET4E>-C#c6L`}FkG>-DDWU$?inTdmgR<)xJJoz6RbU0+|{ z+}xB>CP`BASp_vqDV68B^MP9HR;$(T_bH`sSEH16I-R|}y@P{;;YA??A;g^v6Ur;4 zRBQeC_*k#k`~Cii8pyH?06LuxLWpzD7<2z{a;$`qAPAfv7=2(8LOjo-ltxiRDRmzw zU9W@?&iOaJ2xH9m{lac=3_9@rT7UayTg@^m){6DNoM+)de7yM2v6?}=6((a00F2Q( zj)#>o#u)qP2RP^Dt>AcOZ)uuRN*QAh4-dwek2`LRnVXx7qcs^8f$<07*qoM6N<$f_JmIaR2}S literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000000000000000000000000000000000000..155b4ec404ae8eba2240ec6ee774f6a10aae50a3 GIT binary patch literal 849 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 IM6N<$f(f3)z5oCK literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/66.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/66.png new file mode 100644 index 0000000000000000000000000000000000000000..de80a97a9ab5be3a54fde5ed4f3a17f4909c5402 GIT binary patch literal 985 zcmV;~119{5P)jwY}vw(%gIJ>2N@@K4291BhG=|2|7#c%kXbg>^F*L^L zhQIp(Qp(RwGynhq@Yi5uS;jdRLS$Jcr33&d81T~|ga{!BA%q1)1SovokW$8RoMjmR zfKocZM?D%sNC+8miwedNLd0>55V9=GFpMaQZf|cN9vw2CyKR@qzo@ttD zNs=T|O66aQpvtn0F-8b6O|#u@pPrtcpPzR+9mZIeW%-A9U3YG7Zed|zdwaXpY9&d6 z5Gqk_Fvc*(rfFVWT+5>G-fFct=NRL%V5Dg(grJn( z-QDf%>@dbW&*Pj+DTNT9JrP0}<7&0Kyu6&3ud?Vyk|Ziq4-XH0->+0EX_|hScv4DL zzN*!#`Z;>Bf<;v)&beh-&(F`7mzS>Va?blXt2%30*7WprQM-Xh2T;|CbDm|{&CQMP z`;OzNoc_{YUtgt^X_~sOtNvm97)s+PrIb<#q3`=?n)Z7w008HFZ*Q;JY=&XDy1Hr@ zhHcwIh!KkwE<~+u+xC6G+wFEboqmB1A&jDEZEbCTf1ePNr;SRL@#lsrL}zDb>+9>s z$H%Vg{!*H9U_wZ{-QL*PXfzrtD=SAwNBQ!y6kH($V{AZXtDsWl3uCOJv2?8=001HM z+hom~!eN}pGj9ypp2?r4$0j}f4HNXk>J zeY0HehQ|1hu4(cnyjKOqoRm?R_k|c2)=ZBfUt+VQ|v{I>*Yd2KT=K(W-K|u;33K=sB#!y!l-ELQ% z_CK#hAgeECBckl@Mav zHe<}T?LuozYaPciZr5>~Y33`XPzsE(f@(}_9Yqo69Fcmx-tFz}y~B3ag4tfQDXoQMUmq;obx0}PEJlPE-qeQU$xe@ZU1x> zDW#Q4rBbP!otMRRi@Ih47RNEAlrc6K4EFc;yWMWH*=#nOgpeOC3O@mW5aRnj zUPsg-f@!U#l$A<_5OR2U*zI;(t(Fi%DfOu_5K^nv78Vx9UyBHqrYSm%hlhvD%ge>Z z#WYQ`Oq>v{wc|KwV*rqs#&QnOQcCpKS65e3%53qY2nZpb=Xsu&uf}MONKq8Uag3hu z>FJ4cuC>mfQcAtOz4^Ycl&aNgsIg)lMJc6}(s3NrKomtm5HQ9*izS2*DW&VWJ3Bj^ z^Lo9`IkzmUR4OT@a*5F_ucQe#ihDvwpCNln>Bq;%>FMe2?(Tn=Do{$5BuN~{jYi}7 z`MJ~SFvjlh@6nNDguBhnO~-KvA*0dg;NW0uYby*x&Uvu{GzusPg6r#R&-2RV@1ON=<1eW1p#% z$~|SJ`5r}6LH{{mxtGAjavtam&>u{kj6n|z=iKFzv%42U^!t4wM73I-xYzghccm0# ztk>&h=gPkmj8X~!Yin!g=jT$&$=ryHTRT?=G&k8HH)1^ z)?zXb17^Ssm;p0j2F&_znB5GXY0*6sFaYTH`#}(7mn~AtKdChU0IutHI-Mwr zGIC-7SXx?|U9%+5XV6Ni;c(b!H2&AW27^JVRLZLin~BD-lQSF+@fCoHBEz=NxT{m> z_dM_^+4RO^+qUbvxvOjDFR++6X21-X0W)9*%zzm%17^Ss*w>AJ0DmhVx?5%OivR!s M07*qoM6N<$f+jNc`2YX_ literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000000000000000000000000000000000000..9b208cc13ebdd7018e7e7382dccafb54d6b2b8f4 GIT binary patch literal 1080 zcmV-81jqY{P)J4LVN+F2#KBwAt6LVMF$0-fM_5@ ziXpMRYkT&J(M6}bBjn++pL3qusUpX+_ve|N+4=7hrIhi1H){L^rXe+?hSZQ6QbTG; z4XGhDq=wXx8ZvR?|Jj?OC;|Wg_^C}Ngb+gLcO!%({%Vr8GN6=_QpR!o6A%7{%mDBs zGh>W%j`s`zWE4e02*wy=ESJlDdOv(OrBp7LONukaa1FyyN@-aZ=iD%iPN#Eud3kkp zRj=1w*98FEwo9eb^z`)H+}!;9{Os&(p-@1ucDvnmcy=MthxvS-F%|^D-rnBf;o-x> zLl}k_u^6ssj4{V?hKGkMmCDY}4t^lpwlkf0lu|(uSeC^YdwF@;-rhbxKQ9)G#bS{V z@@wM3D^E{P*VosC5S*e3W}s9cg%E@g&bgGbTCHAOTue<(g<**4=eLH*W@cu_FpM~k zF-2v!PAa9sFyx#QLTa_z$;rv&#4bR5StO}yg!J{F7~gi^|K zxxBx>zp$|2c^-Pww(T$s0igE>`ZM7gMUhg95ORNi9|XbO-5q1BheIf(zVENBtV~Qy zG#U-g`SbI0xm@n%p3E)l0O0NItxza1#x^%M&(6+@#bQ4XM}IQLJkMKPTs%EJRZ6+8 zi;v1~o$U4#{XhZ$E>k(@Nl|64pG>2-(#n8_)R2Fl#?#d=`YCr^btk>-nhK=rx{XFd zDV5LXdsh)cNT<^gLQqOOoldLO8W_lGwYt8(esgnkbaXT{H1v@_5o~R3ZFzYarJeEd z@d1Fuj%j0K!!V5Fgdy+90$Fb*mDBJX@*5MhDf$<~&c%CPO=zsf-QfgTio`)F>$gdT> y0h^P0S*Ia2q=wXx8d5`QNDZkWHKc~@W#0fWWE+sZ@c^y>0000-u!<{}aHBG0wTJ>lkChFecd@DWwoX2!Rlyl+Ke&`@S!wv@DAd zqG=lEyi%!DDwTS@-syCrD58|6)9KaK)qFmm&*!t*tmk=}rV&Df5dD7NFpMc0IbPtm zw>K%JZQBT;APA0+kI&A|YPFj0`@?fPbYorDUDsV+Ue4um`}_Nwo11V7<|b%y&N=6< z>mr06A0KyjcW-WPJkPT%%XQt)QCdnFMbXR4%kAxLsZ`Q54bJ>@&#)d}Kn{YyG);t1 zyWK98O4Vw0Wo0D{Lm@;MhF?uI3}bO|F`Lc8Kbd;Cjw||yVJM}97<+Vdba!``$z&K~ zLWt25qbMSTEG#Sl@YGW@jxRD3LNLaSM&tbaJe^K6#>VFigoF^bZ6}k-c>;#h2;O*o zecfucCdoz^V@O?zL?V$$#N^WY)C-I-3_0gP5LlL_Xp z#`^!?2WE_6jBVStEGvqlD2kq+pE1Ux%NvaGU@+L&*x1_I;+!XwNl4m`C=?2D z)cJ809+c9hrKLn70SS5R9vOy_$zV~kDHoU?xTed{~<=i%f}vPVkdQNRjVQ>x`u zwX%N|DbD#M5eO0z1g)v-SU&;G7;840j4{V?48s`Bj1c0Sx7%$1ZnauL5X=oQ7-xNb z{ovrhw(X0Hi+a6on&wx`p#H3_trd$!XpaVi!Jj{WV$!RptXlSZJxb~OvY}KeU0zCc%-Ng5O5Cdk^!Wd&A1T+j_F3$N_LxD?!`U4s*Aq2#L5ADRv z0h^{d7!2C&wh$th%S|r9L68$dJUu-jgg`K`EX#46nFc0=c%DZo?f3iN5NM>7j^ntl z3vmwaC}tWMOsebpw_Op^($qb9b_8OdwY8liD%es#ar0K;X+{;zP`Su zQmJpu?Q}Yx=jpmWPZkEv6$IJ|ua|~0Z5&O1?7;BEu-EI2y$#nsy;*V`C%%`ZF}}6@ z@KW?Y2_~^x7^xpjD_{kzfEBO;R=^5a0V`kytbi4;0@k#D0F$9%ob7-AssI2007*qo IM6N<$f&rg1Gynhq literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000000000000000000000000000000000000..c9e28954303609c7fb9d15c414a65cf5cf555f95 GIT binary patch literal 1187 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 op&%55f>3ZE6oi6M5K0K+H_BYDFR-N&o-= literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 4e8d485b..cff1680b 100644 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,29 +1,7 @@ { "images" : [ { - "filename" : "app-icon-1024.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], + "filename" : "1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png deleted file mode 100644 index 53fc536fb9ac5c1dbb27c7e1da13db3760070a11..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67285 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 BXNCX( diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 0f8e5f40..b4f2fba7 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,22 +1,22 @@ - - + - - CADisableMinimumFrameDurationOnPhone - - FirebaseAppDelegateProxyEnabled - - LSApplicationQueriesSchemes - - itms-apps - - UIBackgroundModes - - remote-notification - -CFBundleShortVersionString - 2.1.0 -CFBundleVersion - 20100 - + + CADisableMinimumFrameDurationOnPhone + + FirebaseAppDelegateProxyEnabled + + LSApplicationQueriesSchemes + + itms-apps + + UIBackgroundModes + + remote-notification + + CFBundleShortVersionString + 2.1.0 + CFBundleVersion + 20100 + From a0917f53eb79f3446722f446ea01b8a765bb603c Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:52:07 +0900 Subject: [PATCH 16/54] =?UTF-8?q?[Chore]=20android,=20ios=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=84=A4=EC=A0=95=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Android 앱 아이콘 자산 교체 및 설정 업데이트 * **chore: Android 앱 아이콘 이미지 자산 교체 및 WebP 포맷 적용** * 기존 PNG 형식의 `ic_launcher` 및 `ic_launcher_round` 파일을 삭제하고, 최적화된 WebP 형식의 파일들로 교체했습니다. * 신규 로고 자산(`ic_festabook_logo`, `ic_festabook_logo_foreground`, `ic_festabook_logo_round`)을 해상도별(hdpi ~ xxxhdpi)로 추가했습니다. * **chore: 적응형 아이콘(Adaptive Icon) 설정 및 배경색 추가** * `ic_festabook_logo.xml` 및 `ic_festabook_logo_round.xml`: 배경(`ic_festabook_logo_background`)과 전경 자산을 결합한 적응형 아이콘 설정을 추가했습니다. * `ic_festabook_logo_background.xml`: 로고 배경색을 흰색(#FFFFFF)으로 정의했습니다. * 기존 `ic_launcher.xml` 및 `ic_launcher_round.xml` 설정 파일을 삭제했습니다. * **build: AndroidManifest.xml 내 앱 아이콘 참조 수정** * 애플리케이션의 기본 아이콘(`android:icon`)과 라운드 아이콘(`android:roundIcon`) 참조 경로를 새롭게 추가된 `@mipmap/ic_festabook_logo` 및 `@mipmap/ic_festabook_logo_round`로 변경했습니다. * fix: 설정화면 아이콘 색상, 첫 진입 시, 빈 검색일 때 최근 검색어 텍스트 잠깐 뜨는 버그해결 * **refactor: `ExploreSearchResultList` 내 최근 검색어 헤더 표시 로직 수정** * `isLanding` 파라미터를 추가하여 랜딩 페이지 진입 시에는 "최근 검색어" 메시지 뷰(`RecentSearchMessageView`)가 노출되지 않도록 분기 처리 로직을 추가했습니다. * `ExploreLandingContent`에서 검색 결과 리스트 호출 시 `isLanding = true` 옵션을 적용했습니다. * **style: `SettingScreen` 내 아이콘 색상 수정** * 설정 항목의 이동 화살표 아이콘(`ic_arrow_forward_right`)에 적용되어 있던 `Color.Unspecified` 틴트를 `FestabookColor.gray300`으로 변경하여 시각적 일관성을 높였습니다. --- composeApp/src/androidMain/AndroidManifest.xml | 4 ++-- .../res/mipmap-anydpi-v26/ic_festabook_logo.xml | 5 +++++ .../ic_festabook_logo_round.xml | 5 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 ----- .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 ----- .../res/mipmap-hdpi/ic_festabook_logo.webp | Bin 0 -> 638 bytes .../ic_festabook_logo_foreground.webp | Bin 0 -> 486 bytes .../mipmap-hdpi/ic_festabook_logo_round.webp | Bin 0 -> 1896 bytes .../androidMain/res/mipmap-hdpi/ic_launcher.png | Bin 3593 -> 0 bytes .../res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 5339 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../res/mipmap-mdpi/ic_festabook_logo.webp | Bin 0 -> 482 bytes .../ic_festabook_logo_foreground.webp | Bin 0 -> 332 bytes .../mipmap-mdpi/ic_festabook_logo_round.webp | Bin 0 -> 1186 bytes .../androidMain/res/mipmap-mdpi/ic_launcher.png | Bin 2636 -> 0 bytes .../res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 3388 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../res/mipmap-xhdpi/ic_festabook_logo.webp | Bin 0 -> 882 bytes .../ic_festabook_logo_foreground.webp | Bin 0 -> 766 bytes .../mipmap-xhdpi/ic_festabook_logo_round.webp | Bin 0 -> 2736 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 4926 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 7472 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../res/mipmap-xxhdpi/ic_festabook_logo.webp | Bin 0 -> 1348 bytes .../ic_festabook_logo_foreground.webp | Bin 0 -> 1230 bytes .../mipmap-xxhdpi/ic_festabook_logo_round.webp | Bin 0 -> 4298 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 7909 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 11873 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../res/mipmap-xxxhdpi/ic_festabook_logo.webp | Bin 0 -> 1962 bytes .../ic_festabook_logo_foreground.webp | Bin 0 -> 1960 bytes .../mipmap-xxxhdpi/ic_festabook_logo_round.webp | Bin 0 -> 6256 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 10652 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 16570 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../res/values/ic_festabook_logo_background.xml | 4 ++++ .../explore/component/ExploreLandingContent.kt | 1 + .../component/ExploreSearchResultList.kt | 16 ++++++++++------ .../setting/component/SettingScreen.kt | 3 +-- 44 files changed, 28 insertions(+), 20 deletions(-) create mode 100644 composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_festabook_logo.xml create mode 100644 composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_festabook_logo_round.xml delete mode 100644 composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo.webp create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo_foreground.webp create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo_round.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_festabook_logo.webp create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_festabook_logo_foreground.webp create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_festabook_logo_round.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo_foreground.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo_round.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_festabook_logo.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_festabook_logo_foreground.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_festabook_logo_round.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_festabook_logo.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_festabook_logo_foreground.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_festabook_logo_round.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/values/ic_festabook_logo_background.xml diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 5f96d576..42b4591e 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -8,9 +8,9 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_festabook_logo_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_festabook_logo_round.xml new file mode 100644 index 00000000..866ea47c --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_festabook_logo_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index eca70cfe..00000000 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index eca70cfe..00000000 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..b885f86acc2df303d8f29e5f216d20bd22b7359b GIT binary patch literal 638 zcmV-^0)hQfNk&F?0ssJ4MM6+kP&iC#0ssInN5Byfb;!B4?U1DJpUm2}ZQC}o`31{- zqD8cknc23P*`qe9zN#;pujcf-j-Gm}`>hD5*|sfPzE?in-73j9u*g9q(^=uKB$wna z>HnF*2GDhWIVS*r-%T)?WY`QQf@mBl2F!-pHOJ=A9F??i2vr~s5W|d^cVFZw{m6e$ zjUcu%0q{S3x=u1meW?f&Y)nRB)2Hi9(y!;d*iwbbRw`unsCae9>oXPXR7{+Eh=i`! zcafdSwHH*px;(p&1Jqv9dG-+G`xTw1kADF)DT*S80JOBCoUFWpC<>)461wO(vhswv zTrZ)BS(7NILdB6)sl2wwa$}7&$|S4oble-BtO6ohh9lagzbZ|(upXyT$;)x=(pDAA zYMfL=Bt>57r;6=PoZz4#DcW9DtoQIFxF~1^8EL$URV#;aBNC1*W}eGpiMt;Ajx&lV z6s7QG`wV4Ktz16GlU6`vvnm0X_;*(ClgTVj0%{R)q~uk2CW|YyJRq%zf|gY)!Ornb zy#^Vez*7?oFh&^*t(+wyUWw5oR(UGL@zOk*7$7EO& zYcQcw{0;hkN$2T9klHIMp*(*K^6k_|#H%~*Tm`wc?eXdw1^D%p{W~f@C~ZtoNlm`} zc9p$4!LmI3Q!}9nHaL>hz|@sze~pS|LT+D+KX36>O0-2mk;8 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo_foreground.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..137366930d117af5738533ac6a0feaa01ff33c7a GIT binary patch literal 486 zcmVMNk&HA0RRA3MM6+kP&iD|0RR9mp+G1QHAuC#O_7{SVZC8a6uI00x>%G^ ztm4?^tc_&mL@_gS(!-5KfBj+>YW8fE%+wdr{|Siw^s{&S_p%cPizTC_sJexOp=d+H z8d;=SQC1?x3VD{IsH8SF27}dQT1d-NCRODrN@;8mSW~6ZvQv^(G$CMhkr7Ya{_yni z%WwH)05WC$XLVL^_m>l!=M79ewtm~;E59dbhyXBo!2s@W7#x`P;&BF&0H88uK<^nG zaCwM9iG=U0Y!kz59dLV+q3X(elot#f7;t==AsYTuO=3J6a&?bUw6yPR*cH}=Hk)6?uK cZ%5i70I4$1977U^0c<4a7NIa}_OsE)J#6{yM*si- literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo_round.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_festabook_logo_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..35c722d190d95682aea9bf5e189fa0663625d9b0 GIT binary patch literal 1896 zcmV-u2bcI#Nk&Fs2LJ$9MM6+kP&iCe2LJ#sN5Byf)mXp&Uu_%dH}tlzW|^6pIm^t< z%*@Qp%*@Qp%*?!H<~C?u>G#5kbS>>4@bj4{qm0t=IlrBPPF>%fC`aCvd+W@|ySF_G zL`bl0+mNJ;j33G6EVXUhwr$(C?J3x{ZB3zU+tu-ZoC%;>qZXrm)HtR9$Ec&o$z`78 zaUja1q-u0x6gI*Z{Qq5TsxFh#Z+SvQ7{Ot<8*kt*Ou|B^%BdSa(J9OhpN_lsV}yv3 z+)qhNgt52+pJSebvT?1{0em*SpD0fJl9xo-4=-b}Qfw+GsSff&t zat%|HGS((UQB5Mkfb&wZFuCRtE9cdzghmY_!j71#glQ}GVG*&DI?{>IkcNV+G1E$z z?9+(t+WxVWNJ4|eOl>11`_`~v+Z>9dL}ZQDHims{l*NYe6iG;Eq$Ad{knC%tMrxOK zrNk533H@zlYfR2+Gx9w3n}$> z_+e%}{(*3&>8a3Lir&|d%BqBREO!u!WrjOwo^~%h%&*5{Cilce{g)dksY1Ow{8R}k zA*|5b3@@b*4;SS$i>*iyl9#>)JL+H~LXCb97CBrVqy`Vly_><>8NIHo?= zNCFELtPEL`bRi$bXs7qYh&(QWRp<@*PiRPGNndO#%7VA0{8eqLKHpkYa~iBvZ>fG& z=c-S)`DgI*E>-VuZk2N(=||jwn{r;(BaZ%j7 zjb5);LDq)+2Q{TQrsI7>37p9oVPSwa{~A~Cf*bv`dkYN-nO>Q^BusG}gLlVI7E(so z-o#owZDAdUCKC$@LOT5AANb>#PzU$=TXI-XpY*hlSeTGygg<`>?wkk(IR11{$>9on zywzE*E$d%Cy+0vB9mJO=C=OJJZ0C)$=f5e4VzA$($xt@iDoB}Lk$aQYt(b@wA+YWR zn;@C+XYV1nNJNx?=ZBj)3zDPy`Ow~dKQpRgFz(oHFrRG|oJ_AOynfe%aTR^U0pY*i zura~0W&W)P5I%uMMSb6^CRT7rgCZ=wnWxR%w>TO-+zD~ZKd}jl;h+A1dIYvH7s2=1 zS;MF_7`yc*p1ml0voRLk%LVm+|11<{Fl;mb*6XQzxya>5bnxAI;It}a{D~WJEnI#A zDMIPx<+m6HzGn$H^eh*|ee&oaj+9Yf%0Fo=EcnTde|Mn+nG2eAZ&k_(iTbHFt9C--a8|k=T&qD4cQol_;OONyc26=se2V(Cz zVO_a4?$~04hWn;rk3#ByRNQgO!Yp~es~;LZfB8nGjk{VKy-Gc84K%u|zbm18s~|Le z<)-yY?+*PEMtbJ?+x~OYP?u!3t{#Hs&sw>%jHg3?gwyIDB93_+=vibcKgKW3UR|`lY<6K;_MhSpfA!J3FO+l8`E+PJbu}O8VDokB{LZU~ zj&z^}w1tkxbvv%t+*LwVah=Y2aNoaC- ivUA|XOU5+jn=J|~XsteRIpfC8KG!J+_U&zTsm1_2uE;e2 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index a571e60098c92c2baca8a5df62f2929cbff01b52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3593 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 P00000NkvXXu0mjfP=x?Y diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 61da551c5594a1f9d26193983d2cd69189014603..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5339 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_festabook_logo.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_festabook_logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..a3166b005e4dcc9147991def0e8765e2c2214845 GIT binary patch literal 482 zcmV<80UiEQNk&H60RRA3MM6+kP&iD^0RR9mFTe{BRmicmJ<%KO#7q^lD`u$N*_HPI z;iO`wmYHDz%t_5yn&GDbY32(^PpQH##wr!hsu43D^T9H(L_re|+t@=hWcwmkHTSEj%;RwdzHm*kb@^u# zI3mI$_&mOVA@cYH9Gu5}bio0Kob$j8&Vxe8N!g}Cfjo{%3|W$eBH%lwP#)=!928u} zoI!cyV?r1NU*MB^l4_IA<8lyiS+8wSM$%@dk@VT?gftWx zS?^VnObVr$90Vq?Lgh>Ga2SLPRlpNT_OM5Wn@AF$NfzE5|`4+M#gzIzUVnAL1- z;N?3TgT#W2<<^TAU-RAN!BT&DpmnLgJXq*H`EI^qSwYUSy{_wt+IUT~?Yv=oYzz{q Y-RLlzVAI-Zbl91lJl1sd`XuDAJ)x zP;z!PLnC7-ZcI?@qKNd_t>bGqBq&mNmFGsQ$~-ggp%c)wU6kd*x03y4F1IRaX69A0 zM5NcM4c{awb~E!rg0ypWn8C^U#kJA|i4HC^xc0+H14Ux)GPu|Bp+8Pd584*UOHJ7$ er&mlCgm)L`_7}GSkR_lwv1bOP>+}C`_UkIoE}Az0 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_festabook_logo_round.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_festabook_logo_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b0569a860e2ea3fda938d24b8ca275fb5139ee0c GIT binary patch literal 1186 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

C%4 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

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..b742c99233afc590def8d6215bd06cd5a46e8c53 GIT binary patch literal 882 zcmV-&1C9JrNk&F$0{{S5MM6+kP&iCo0{{RoU%(d-bx_{^&yXbj{)_Qc_1L`Gwr;-8 z?jCtF&eqkoZQHimX+>l#Gjcb7t2#ygA|oZDG`^VmFN*SLXN{w=Qm!T5=jXnR!&KNo8!OOAO07?+G_9a>2e568QHeLe)Inv ziVgoA`+TPa5vlt#`Eic@<&d=W{rQPJ2t*`f)Q8RdEyw<0(Fi}iyS7J(SPH`D`uG=2 z2EJZB4xt=GkMyI`|K*Aah~yx?XAqVCuNO~2+^fc9Qi+&0#wvvolL&cy0c&le>$-^~ zG7K(E!V}Pn{x}tDJiY`bHsG^FL_ven5h z@ra*riBWhkp@5>795ho$(HX8mF+>s$h90*}hvfArga_k`&=)r7kbMXR91Iqj5e#>c zE~!_L&xJepy`VBnPa|1Vw~0t3JC`h z(XILvn&wh5E<{wjGAP7`5%Y?tov*9VQn!0r+kS;2qtHlfHq$g6g}Y|ZT3-Q`wK~eY zL@!y=YnpptLXpt*x(coFgOIf;G!mn1ilLhoTWc6)jI|LL4%Rd18$LPa zN8f3GQim86C}jr(N*Le>*_a|6h;f8U7!l*juYwCb2YRlaKgT~l*WbbCZdLdp5rm|% z@p-g#z>&g)5_Uj8$!1u9L4`6YY0D5UfARmr`oENN-w-POUoV|5;sNkb}JaV_!d}^56Uq z`gFh8%wJlgMo%> z2+L3qK~`Vom}Y2)Zs@5zL3`aomWH5Q11aQADD75tswdS{ywyfQ3Lylg8;Bu+wDN5Y Iq3SIK0FzL$Q~&?~ literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo_foreground.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..5820d3f8663e490b65507fcb840f2d4a557594bb GIT binary patch literal 766 zcmV2xs0l zWg%-9rU%=UQb<*6>j7WjbSX*~BHC&wE921xwl0(p(M{z5S>BXFhw+en_U22^KKJT7 zpZ#N}Xrw`wH%hoL`iDEu*=KITjfb6g+Y_IZV^pqj1-2AEw429|pPT8zF;_j`Dl%Mw zt8y3GuN^Qq`-56J;mS9;W@ffG&c!^nH4p|du6LH zZNL*4ISkDF#$4pvTfHHsd_8c1xjrD)y@472Qw~v9Wr3L=n~VKIV0JxBfflNDVzl=H zv+kE3Pre@L!2IWYv@Zj5om6N7vZ@b(`I))s{{&{0)JOtnb(Qr4Gv64m3Ct2p;F@xn z^_E&^MB639Ew~iCfGN^<$ncc-5(*O1q|8YV1iv| zcRGYg^TOarrW5QuyC=fLnb1f|T8~}N%!{+T==#xjAnf9~ZXUkUF)J3mVlgWgy>7^? zx#+FtnH7u1)K~8k(Wcow`0nWU5jJw?*?kcv##7q4To@dwtaC!*$W_8Lb!9r>Dz1sB z(Nb9yc|A=2`R#{Z2jq9Z{Qd|1`Oi|(_<$jBx-8Pa(hjIU`k7#ss&RlK25+@m$~#c~ zCmW-gt%rakX7){1bf8Wu4@4A}+%>ZLYst#yDfG=0&{lEW+`&Tb3Exy>Bs>sr~BuecA wzQ9}E)Nvcl9uUS1MC8YUCnE4f;E4!45djhLa^9T%-|z6>wuN^1f4l7g0Mf*K-v9sr literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo_round.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_festabook_logo_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..9916fb3f78e6414e60d8637e1e9dc7210b001c09 GIT binary patch literal 2736 zcmV;h3QzS?Nk&Gf3IG6CMM6+kP&iDR3IG5vU%(d-wfN)z&ur}f{~rImY;!Mrd|tL~ z+qP}nwr$%!wrv}0blWC*TwZC~%p|$I{+?&HY0l)NomV!S$w{$|pherZDM^Z_zzaLl z!Ln`Jwr#trZQHhO_gC4rZR_3p76eIh<2JJdK?2a_GHE4GVE;RW3aTB{%GBw2PNRc* zm@pG1Vc2oZ=aryQmO~R0Sb>$Xdd&}Hvn*9jJo`yPk1>W$tcv|`1-_5};5Iyl7x6mY zbf-K#>UZ&dT!H;$yY$duVlRff#$p_duj3}XhG+<(Qb|IH7H&*%@U-E{ZLCNbLqGP% zPw})Af^$`3o5RbZy zC03n7y+?{TQn6T3EJX2MP*`Y*CF+M#C>FOtRW1r2Ez&J$XfX6^f5 zR;3(tkvdLD_-50*Tlqvgk= zj1cGW8x5Z|!&)tjS))aiie$WJ6oEsH^#`?m8j7H+Xw*i@QKa%?7VzU**l@{0eFy#+ zGe1F+<{y+Tx@cT$@REi&&H<*5F+i)r?X)*6iH{Jv^XDT3t!{K zJe>P;AMQ*0VLXL5@MZqR9?nugCC?xU5sH+3mzGpzi*%DbD23cLD1z2+d!(5*RzQEHsOky#M%6waYi8^k3)|d`gFWx_mS!JDg=lgF5yKjnC8NC4Cmt%!BwI zzu~8>!tjkF|9xoeco)>9Y<@?hu#Sct{TTQtX557hlzpQIC_qfi4{y4$X`G@C%I?bQ zB~C*hoUbE_9*gOceiu;`eTI|p990VB*~>q!ldd<6oa`&PDk;#5zQ-j(-HL5+M$xHK z4)O1ipoGRu|5A-iwQYi`i%^cU?qNd$6%X{`cl1red2mhS+4>0>o>!^_=h&alL=_6V z{|6Hs9UV}f?3>ukZR8>aEq-|W-6Npr2cnjKGg&xjCsi7dV@Fg*u3-Swe^ym4Ih8Y)zf=!Y1AnchUUe#GuYIYp0nq+f+qrz931|j>Rh@qFOYTAa zmY6Z0yHT&#AH;5`Cm(NMPm@2=%vP#={Zydi^L9&~ozZTWcG6OXyucOdJYsqL((7B+4V?!D#D~{8625H2nf2Umo%PzB$z=SEe*ET1#>5)%?@A5&0em!p~|pct*s|3CD8J zxyQ7Pp63QfMbdlvp>*jp6J*vy;RpEXtI14d&)Acl5b3FLY`2PHIR#ZHY}nUZ4+MjUY1${>Hm7(bouR!m&Kb$LDt*9!Qy)K)-1Oa%;btKojJUh@x{fQTyXh zkn?7kDe0q4NZqdC;e)Pf5i&)_7{d_a2JXhgw{Du=&u8u#1NB~EDF5h~a~9zV^IYoV z3a1I-H#=>Zf3LY*{pzj5h9bfc=(mqU`pWfJuNnqrW*4zP#J|^G zsnPW9<1?TziuT_VKnChA{%ZgvcJ088Wgmb37aBmO4Tf$s4V<@1LKbTO<;yc5s^!{X zfjq@8zkxcCt<`m-Z1dt5q2~9VK76-;CNkIRs3zR~cfa<$fAO+%qe|W+dW{TJf5(K4 z2Zzvu7Dq3wIrN6f>Hm{b)`E7e;H)%MUC`oB-=Cm>CKX}Xz-=-27{C5;ek-UNTr*^e zF_wY`-nnH^fLb4#w|@6|G&C7ovP6QIA?cbu?^`efg}G&uH4-CT;NYH}ynYUQe>>xxtvKU6-GJ3mczgMZsu~0{cJ&Y3cO}}g=MD%W3oLf4F#xvx!McczI)y; zKVG?E&$-ibfHCqYXdng&x4<}qoR#~|uG-Lf_dG9!>cOmZdlC%8IqeyMd6Vc(O0SX4 zpvL8DU#{UpdcI@!x1Ra^7mNN|w`u$S-6sy5IdVP>_MB*4T>Rf3KL7SJ@0|6Jo?a@T zvVW7(1WbaF#g_vY_-+`VQ{IzV*qY7Bu}?>9j5+KjGRO^DRG90LH_rBDz6 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 6dba46dab19242bf475ddf2e0a10042de6a0be16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4926 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index da31a871c8dc461a1ea114510bfac640307b0a90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7472 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_festabook_logo_round.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_festabook_logo_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..7ba0380f7404a995e43ebaba92cdd74b4ef62f9e GIT binary patch literal 4298 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 15ac681720f3df43c647bf0c081f0314ee34eae1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7909 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 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..28d4b77f9f036a47549d47db79c16788749dca10 GIT binary patch literal 2884 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeN3+qvhaYpzcIg4~U(?UQZWwr$(C?aX#nbo%Y9>$v&7 zs$bQ;GwFV6dQiVr^+d?Iw(XFluJ0>XjcwbuZN-zBk?r{d%C+8TH?wUkvg1bSnE=Yu z{;zGLKc($5Gcz+Y^NDv)yr-SLGBYzXGcz;ut{RdQMe$F{$uRvXo$vCl^R=sAJ|nG0 z^Yduzkqi<2p8)=Eo0`#7vzkl&a_Gj5D~D`|!_tihS28o}K%Jt~Qa^Xh%%x_?X%d-r zwcgQp^%rf|ns%i;Yezjda(3Wo-Wnzqe!$;c^9hFjkt<(s< zZJ0KEjc1yqB;puE-3XIy<5aGn@#cMS;%JoeQ=LYbGLKr}Biz>|PjNKvWkLVg587xO zsE^A*vSzGgFG$6D2oy#%-%&7`O8JWRhhCGlkd_%CXHct*yA#%3G~C9B8d|XArks zm1G6ehP@=W^=RlOjG9@fJnlqM$BJ{66igQOlH7_0im{-D8|$cJ*E1#x3uTYQ9wc*; z$8JFr7P;0Lf|%P0NjPz$m{FO3ct+w6upySTG`jL0%`?Z*3lS~j!oT!D0 z-ba`0x!Tfm=hWC=a)s4Zu}VkgRb|EqL0Fj)1HiZY&YdImUI5;&qlzA4d^iDs?{?})ejY&9&SaL+t>`zruCb*SOxJzaKUNB+?Qz(|@?f<2MA zX2S-M?;L>B89H{W;H4d=X5=J%`;9faM>sy(};gr%Qg)W}07Ari}|;i<(X9H~&N zXGDq+W}_?_YPZ{ZF$tHZ?aB^SUR}N4@2@yV;tKt$vs8IuPGGP4&EWryoH0$N{zTRw zpD|6C5RE@*rl|4errmCLow*X$?pD2%D(aVRw|n%GNsXK$gz>b|}C_o2g4gL7_ryp?;N}sjA{3Z;)oiR+e zpo1^mcx-6qt3YJm&KSLBGtzcF^XUNggo=m055`Za?bho`l?oq(0GO5jDHy*-%CCsP zFYJJl85CXeF+Kl2&Lp4y%a{4PpC+G4K24aP$@kMg1L$`oMl_O6o1cSW5XNdCh~ChE zEFvDtvP628G~7q1|GNC@dzd=1`g%K$@mVfiz7~ zF+~cbNYi$6kY`L4uuyL9NY3jjV~_>R>`CULU)wN590Lm$Us7z%D{oZMo@By@Ic^d@km~jaq#4g@1MrI z_zu6Lt&#c)f8YmvfG2U~hkd36$*aX9(8e4L1qK0^!3UwsCoY?|C}UCH{&N>zy7BWp z|DSd}XFRK?{IkZ>+Wr4;_r6nZ=@P11x;6$B3=E zod{N7qXN>z&J=bOls6f3iY4J@35y~vwEj(vt w>J-oO+;(QOST$alx`@vkaFug0ew9IQ$=>^(?sKo+apYGK{ht6F{XhDD^#ADp(f_0WNB;-x zHlU9F8~r!>Z}i{jztMkVNc>6fF#IYYqU#N-es#y-5dj_A7)^}2e~i&ahlqfFbr;|f z0iBITD{N3k5qZxTWsD6f)M#vs4go!4iviCgf#9u%cWx8jX$7L{yF7tR_V)Nfm3#xR4DhZK8Du=n(^( zM{w5YMsQk_qNLJ5yKXcMr=*A_scOoEY*1-q9Rhm9VCE5=HA-??l?82Fdpe>PloSyw z8o7{-(#AUQh(XLFIIERpH7Tg}?dgn0Qc{$prd%}2SO*>hm`AWiNmiFcLOVp%yei6y za;c1i@Z}Ml)soj`rP={|BWX>AGR|Y*@(9i-K}o6F;X4_%g}~(ztZsN!pl>&!WQ}oQ z%cHYOGE&-hGqOtPu;tNNC1_FmcCBvlzaM}8gKvE4bDsWGeD;f8{f>`)>4$&6s)kcS zO0FyfERW7AQ5T|JWB$93JnfE?wVt^1rN`kX~^;jR*5=q+LJ85`N+xXd#k5@3-s#@9%u0DK=$q=8=Wu_SoS00^_w5ZzNf4;|Lm@n>9vz#{8hbpj& zmAUVuKX_s?(6@DoS;3TrFy+B&QDvs>SwDL+*iUq^dBK$Pfyx<4bJq4O9z7ZE*So4k z$wQR~t9Y6F9`rGj0so|{Tp-t>%4tz$P1|jsHyQHZyYhsDDi2n%BDOt0J{k0-UCV@s zQ026!mQ9bp?+t6#wMZ*rAVhhviWOPgUEgam=o3xXY#FSak#)vM0R~| zGWef&l?xK4z$#YGUDxY|_ph!jAwq{GzY0#vI$N~e_Zi+5U1@0Yh~UshEm@hQUCX`U zC0+Bt|-(>J7OxMwZa21X`f-|}ioL0qheo%IOXLxsLJN^Em3`*dP zY9wbhDY9hByFM|z2Y11ISukZi1|zEkwrhOD@SfTgqt0n#ArN_VT2ir&=L}`<9*>R> zf52UCu{U{TAGNHd2}f3O!q)MaVLbS|NqekaUUqq}v=Z8dA}|dr=sF%Sh&SqX%_<>5 zN!EF_j=K-xQKnsYvnY8$(xP6Hb=+wPe{9z`FPO3csY=&!#{t~go`P9e;v^|I!)w}8 zkq0F~(jMfB?%NIEgIApNK?wWNWTU>gPZ5}s^A4q30s>O91#+gW4@l`2D44PkkdiHs zGi7~1O1A{;T>Q=j0+G{#BwNA%dJb65l!ZX#v>?fr@PP=lAnBHX zfRt>3f+_0*Qo3W|B-ybDl5T-KSY7nN?pqAtSsy&<@AfHz5+_NIzIPnJx7+oHCb1Rr zv;5L6P=uu_B^z%Zl#HyiYSXEtwKTy9trAr&Iju=q zu-zVv^=170NgtjL=yvIjzcUadgwQn<2ga zV3uTgv07qPGog$F-v*-S!C9@OU{#(R-LM(5{n@gtC_9!5*=Xay2P6b^*64=U`Fwvf zIrSuc+^!~&VJ z;QO}Q@r-H}Y(w+uW*`Z2?R>j+&&aY}9_(D)%_NxZT&6dhNt}0x-|Qi=bK&Q_hzq{( zb6&(bFZ^7EeCLu~NC|n$FXrtfdZPR!e#+ z_Sd!FvA=?GI#2V8E8~0{zR+1ESykrCbTx^H5s!_C7-PiFh!_zuV#F8`5o1Keh={n% z3R3gNI0#_TgEOiTjMh~o7!fhzYDB~s5iuenM(iCCG2&`O#290&DVNGv@Ieg0S)&@1 zV#pGeHV!un8+gjPS zt(EPpY}>YN+sVahy3gEa{$2MCq{FVWn$<)dP1USAnrP~*;*L7%dU$mv>{Zvp%dU#( zzXL~-oTi2hcRFw&$o_!*cN79ep=cBnM6tXP3`We->E}dPFe2*}k>DDqwAq;>K|a4V zh>H}+fb1v{WuhF^;9p(VqW{S-eQ^z+ois@!L8r_KL`OQ5k9wjRXgfNO9-t5CAF4nh z6h=5A5RfjL^11(gcuTc$eRDCJ+UgQ5!T19YSwVk&S2t4SYek zVcS~n`G3?JnKsHy1f3Dd*dJl{b)#-*eKjg4oamx)8rdnt1cmd2kqx^-6ND4@R+nw+ zj&R>(@gvCYM>Lf7Ai9o92pe`fVh>YFEJwvr7Xn- zBVzMGEHoIsV)Znl2nsNV&&bNt(GbKIlZ}~C?24iHj2=ZADG)^XSoz-AE?E}I{~$rL zY}Rx^&v+`K6kUX5q0VO6925s@MVFb%%c%rZ(1;OaSVcfG6_S;qIzk#~pg?%BlaTw% z0beIr?6uV#}Od~=p56E&*xH3&Ae?mTO=#0LIcOH5EHwqa=3|9Urmsk4RKk-{15gcV=(392aipPD~2T>=43TqS%n8IX6W+R)nUu4LLDqY7VO5q=?5d z|HOtNC)Ql28kvl7IOYF9-*mW%(@X;kf81%3agCt7h)X}^r0B=o7uO3h zJw!(25Ph8Oia#2XVCHFvks~+`lta`KV(YR?0DQLPFnM)IHsf&6^d`h+mxQ2j?_wA? zB;>@9%M(x~LC7ui+-+CB<}N)VcP3NUMOf-kvXK<$VurKOJV%Z zklCObgoTnj2t&lYRM|>A)>_6GxozgboxMV949SJ&E9gI@0YBr__v|zDX`>2a4COr2 zs5%m(;MD(D>O$AV8t^QL*6hR0sSLxZit8tvZ?W@%XI*=H-IL6p6Gnp;nF)h?deVOT zM<6PZs(C;faHeoR2gV)0(cEKo?c|%GCzxZY&GtJ#_aT}UB>T=iqo4gTGZaZHMqr+m zeQ?edsZY<(UVrQ%J9p}yw09e7#2MB|uEcy<2g^spEEqDRK&@|2}MCNSx$hh0;5J6GVrXF58}{7u;%As?n;!ElZHsZ%Ds(ZT z)*Ye~fE}<8XFoxK`Rr5SnqHpA(sbuUQ_ta?WwXUfZ24Rn!u|;D0nXmgeaCemg-}#l z`m@te9ief2b6tux;N)S;AWp(gn>{42hQ0w&Cx!<#OpD1QmZWtAA)I}U+&w!?|Ir25 zJG}H5IJ>uV=x)m+Y3Sdjq<#|`VS{>D#z^mW7$aSYJki6!qtU;O{{QYc&$}?aVOctW z5FfTHGrFH*XY$EA!5H^?B=6@k_@_`bY`u28WSz(PxjJPM8UQ;VKeqkyha(wto^VPRFYQ0Q`*MYBxYIkTs{(AHi_8q zJ+h<^-AfIECJ7&*@|aPDwDema&j5Us@2Fl1vB8Mo^drF&ed-@0*fvgLlA*u zx2-VB$>w`Y9N7!Z_t+utkwh7b;R|=wTsbE?0@G{lNtVC*M@FC|T_Z+3kVUNiN`ta+KrbI*3d0NT`-o6!w0777 zU|14jb#|iH7?=)LoU|OuYXJ?l(AS08%MXr5+ugobI-@!pjet#TXd^yM=!e;<6L9+D z2+jD&MEtx?wFx902)zq)%^yc>cBzDF+j{YQ*SoNgj$7yZVmp>?Cq`T-64guyjXrtp z(TiE~^zMd}Q+l19M+b4e_I6)@WQGFSFuYs9jt8&*{*C z-y#PVE+;fDr9k*xxMW)(ogd$b0e$H~LL(^!LQU751Nvup@#L7=)|FQorF2T{iu30C zz^Z+646m2#GPVpR zz*FkDG=r}5iq%MyGB*60rjGkOJoqfo2d2|Osa=c>y}Sv~AK=3SrVCRjKSoLIobiqc zzN7)rf0@RQbK|LU>@QdDi!yCMBW(3qVIstfjja63Tz)){VtS`jQ$X;cABTJHH)b!t zFCEg?E%v)heka}J=EqBn$v+9kms&}^?cZ~~{P=OQ=^5&z_ZV@1Mo)sa*uD5>lA1J}O+x#=rOyl{YE@l|Zdo(K)6Mj1d_DT6{O4IL*# zRn}g0zryhu9zay|D;Z7m_LHz3PiXUK-95kEeBD0`PTe&({B!GTuYLO4-46(rF+w(8 zbhfE28sb9?_|ukQ`;;^KrQ<`zJAb?dP;=|2clGm<)X9`vLJ;2m(H$8vURa*V0^U92 zFn+mL_Kb4P@4N^|{FVq8c~LYY)Z5jr>JQB?d@|d@izhRHdt+DPr3j2)BTij%51_mx z6wkb9O{%y1$avz-!+`rUOn&?b9XRjL(o``Ge4*7t_X2W7#~JZ|;@nyNCoP-5kHHJi zw^M=r0%Ot-&PIG+cF|mfl@S*l^6w{H#&4u~LNbrl8DwCAbkR+sL6QlTMr$vB3aa8$ zC?1&)t~?OW*hVNMq1e#(7>M&+`~d75*&w=WJfR7SkVssA$}AT{+c7{JJH}02~{b(`R^~K7JesF2P`L0n?b(d644;3 z1dTRcbRX2s$I&eO@BsNMdG4``n1(c>xN9n$wZ*+Ym+|7?_+4BO59mTiykW5*A}s73 zgmpsxx$uB(Eix0{fCx|q@sLEXEcdfnQ zU^JuymX2=}fb8b+UawCIX&)I6Z(o%6^}-H|E_hsD#{tPpvR4)(*e!(!r@-*?7q_VU zYOae>>S`Rxr~u83D@23D1FWu{10R6CVjbJj)d-v+EWL4xN)tZmqkUcXzHM1)W*{2U z2}|z!yg$ueUPN|F-%Ge`aM^R3<$W1H*P;`$$8{JhAG8zC*eP7{UQ}gu-L)`He|=$MZR^ zdMttYrLvM+)&^}-2b=$cdrQ7lR>iLMTAnTf<)1cMTuH*x2BmCB^^4k-o+{Q_>#_L| z&J$%|7CMN}!5j1v%8a4_u6cF8mtZOFDb!NyDF*iG%9i&ISwPU>lPdVsTZuC#ww$IiYN_=U zo0J?d-*qE{LaGRWX`T*=57ToL6RXyL>S#V*;eU0H*>x0zr3o}199;JYgZ#%Sn>(~; zeT{nxAFtUl7y^WY}Z4(RSbX#K|W<9yyr+kZ$lm5ofD`MpaJht71TS`IJZBwmm)H-5iK&B|wow z)LG!T>CRlxAl0DXeHkY2+;$5@ZoT8)I`@nPUp4{GeFDy`Lo~d5f)@c6h>tYv_{sb` z{xr23^oLcY;>%)>9z9AmT2U(Xs3~|m`X$Xd_*kXUhC%;-xXF%g5Erw13=-cusDB4C2(@tSxO*az(2oJttmIn+8?%G!Xh6yV+`J=< z>1QNg_k1R_iXAli7@Yt1wDk1HK<0Dk#|6Cr&H%-6Cb0fc{!L$IgUAPsra<2x-`lwU zSaVGV-{UUmHXzJ7#!~*QRzv=7&_qNGb>}n~;QNJk<^PmZ!6!1T23Yx^KGA0V<@PuG z#5{Jc8(8?PuGSOm?fJzWQIBkRc%lI#h(WW<`YWAp^34L#HffYOpy+wst)Hl_GX|mz zhhx+Rn(IJ&w9WdWjyL>Wmx?Gtln2phbAR7f>xPoL<^aC~c;z51`9$m~t*-g9IRzKr zP#eSJ;ezi!+b(v3qr|P--SFpSAB=rh{w2@-$wXLesZhj7IOp}ZH;Nta1RZlA#~s^n z0UdaG*+<)B$EWnaF$xY)NL)#yvfJQ;caKYp9p%W*D>QKu->|TQ*%0SB>%g7RWE7SE zQWy;HI2N=1a|xRkCUFmU$)PrDoa?Pih6QIst5_^lAC20t4!v?^xiz!It(DjZaNVHk zX^FR&|M}3Xe*061uvk3CLBGGhJE}b1zOC8~TwRILtQjPrew^_rXPoo98(w&qQBi~# zSmy}%(1Y)3Qh%gtUwd7%qn1rU3}Ayer>GJfaqhQoxH&Kt{yi*s);0~U3cPMhoCh40 zMLFF@eNV-7ff_4H!EmTOX8qMZgWlZ@OJJu@(Cp4Vs}eu_YJY2NpTD%i7|=hlJi>HT zfdJ&$J*|g0<6KWje9!KigHs@=2)42-G_-u`t^1#lT%NfuyR)gPBg&?5MlLW4-Jr?Z zP;dQ>e*Ispnh(3f!c2rKi{Lgm@70S3S?iBP8X zTv@ooNh3vohtOyx?6|1#RqNvt%QL^|FYRt-j?^QupIZym;wF%T7K{w}V=X>-;zs`j z$WRnPF{+}p-FL13U8jGf)yLTlU@YluDh}v4Qa~21z^wz7Ki+zzyFAYGD}B$qIV3z8 zGK3>g8AaPvxGDUh+b&b@PEXbdcYM#fsj z?Tv^%V-%z>by zP=tz&@hOUZKHLLWl)dNdi~Gl{^_A=V?e2_XEmpV#eS@2tD?kpKqIfG?{8a0=Z0`SQ zI6eWE01Y%^2o(oIBeG}naMbtn8@B#%Yo=f;-noYTkCpU#mvYSBESyB{l%{;_y zjUAfyClfw+@p9jzQ(zf*5w5T;gZkRYf$6Yn-v=)K;Y3r+x;;}aD(TE;cWJRgFQPyS z0MiFzP=KbWAoB=MoXZ;fzGg3(_mS)S9-RmaAxISVn6ye&&Y!nJ)U{J#)fK)EUq5rf z_q2%fSmRvTnFU4FHTl4;3b>-2N(4iz$nOL-7{J)WQQ5;~jm5sdMZ&E2pC9mbNMIyP zgT>Isw!&eG(ETA+l@l5w9Om?#Ghqx23k>}F{_}r5=lfg6d1KZduF8V_{`OAoicL`! z7496+L0r70hjTkioRaKc$_7+d)&u@!&;1!%xx;BzPcvH#X(jle$ZY;dv-&Maa`8x1z>seRdee`u366S6C{+7pP zZi&UL*7EGi0x&ZSC>edRqoY>45@7)`969jv-8I#qF9K7%t%u_f*Y?UMx>;!~ws*@H z4$b(ohTqow``i9-`=9D~ujTXp&sBY4%idUw)fQB4-`~R#+ELU}UE|I#ozFU_l~$Y?G$F$|wx3J6z+!&Gc61VMhCyR$T>y}AT+Eq=ehhzZ82CA*}$J*TwO zU654I$ZpWG!UFtZottBOk=Yzqp2bv#C3IUw>vM7(bd&;=T|gk_?nGXt&Tu0 zr^mxc2f=A^q#c2P!_?YZRa|W3L~(IdYpcl-2y}3?Y>$tTASq{Sw6?a^<#Oe6!sT++ a*49SZy}Gp0)ne_qVPV3<1lLcHJsAK#s5X88 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index f25a419744727fb48b4b4137b6c6a3a8f2620d51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10652 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 diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..9126ae37cbc3587421d6889eadd1d91fbf1994d4 GIT binary patch literal 7778 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/values/ic_festabook_logo_background.xml b/composeApp/src/androidMain/res/values/ic_festabook_logo_background.xml new file mode 100644 index 00000000..e2a37ae7 --- /dev/null +++ b/composeApp/src/androidMain/res/values/ic_festabook_logo_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt index ad2d822d..ed4c6751 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreLandingContent.kt @@ -51,6 +51,7 @@ fun ExploreLandingContent( onUniversitySelect = onUniversitySelect, onClearRecentSearches = onClearRecentSearches, onUniversityDelete = onUniversityDelete, + isLanding = true, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt index 584b7dd9..63398b9f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt @@ -25,6 +25,7 @@ fun ExploreSearchResultList( onUniversityDelete: (SearchResultUiModel) -> Unit, onClearRecentSearches: () -> Unit, modifier: Modifier = Modifier, + isLanding: Boolean = false, ) { LazyColumn( modifier = @@ -59,13 +60,16 @@ fun ExploreSearchResultList( is SearchUiState.Error -> {} is SearchUiState.Idle -> { - item { - RecentSearchMessageView( - onClearAllClick = onClearRecentSearches, - modifier = Modifier.padding(bottom = 10.dp), - isExist = exploreUiState.recentSearches.isNotEmpty(), - ) + if (!isLanding) { + item { + RecentSearchMessageView( + onClearAllClick = onClearRecentSearches, + modifier = Modifier.padding(bottom = 10.dp), + isExist = exploreUiState.recentSearches.isNotEmpty(), + ) + } } + items( items = exploreUiState.recentSearches, key = { it.festivalId }, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 2afa6de8..90176128 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalWindowInfo @@ -353,7 +352,7 @@ private fun AppInfoButton( Icon( painter = painterResource(Res.drawable.ic_arrow_forward_right), contentDescription = stringResource(Res.string.move), - tint = Color.Unspecified, + tint = FestabookColor.gray300, ) } } From 7eb3a382c4361a7357eb0f6f352e0cd36c696ad3 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:18:28 +0900 Subject: [PATCH 17/54] =?UTF-8?q?[Feat]=20Waiting=20Data=20Layer=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B3=90=20=EA=B5=AC=ED=98=84=20(#1?= =?UTF-8?q?10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 웨이팅 data layer 아키텍처 구현 (#105) Co-Authored-By: Claude Sonnet 4.6 * feat: Swagger 스펙 기반 웨이팅 데이터 계층 수정 * test: 웨이팅 data layer 테스트 추가 * refactor: WaitingLocalDataSource 제거 및 PlaceDetailResponse 정리 * feat: Device 헤더 및 전화번호 API 연동 * refactor: WaitingStatus @Serializable 변환 및 코드 포맷팅 * refactor: 웨이팅 관련 API 엔드포인트 이동 및 Service 인터페이스 정리 * **refactor: 장소 관련 웨이팅 API를 `WaitingService`에서 `PlaceService`로 이동** * `places/{placeId}/waiting-status` (조회) 및 `places/{placeId}/waitings` (등록) API를 도메인 성격에 맞춰 `PlaceService`로 재배치했습니다. * `WaitingService`에는 본인의 웨이팅 정보 조회 및 취소 API만 남도록 정리했습니다. * **refactor: `WaitingRemoteDataSourceImpl` 내 서비스 의존성 수정** * `PlaceService`를 주입받아 이동된 웨이팅 관련 API(`fetchPlaceWaiting`, `registerWaiting`)를 호출하도록 로직을 수정했습니다. * 불필요해진 임포트 및 관련 요청/응답 모델 참조를 정리했습니다. * feat: 웨이팅 존재 여부 API 연동 및 404 대신 exist 체크 방식으로 변경 Co-Authored-By: Claude Sonnet 4.6 * feat: WaitingLocalDataSource 추가 및 WaitingInfoRepository 전화번호 저장/수정 로직 구현 Co-Authored-By: Claude Sonnet 4.6 * refactor: WaitingStatus @Serializable 제가 * **refactor: `WaitingStatus` enum 클래스에서 `@Serializable` 어노테이션 제거** * 사용하지 않는 `kotlinx.serialization.Serializable` 임포트 문과 어노테이션을 삭제했습니다. * refactor: PlaceDetail 모델 및 관련 코드 변수명 수정 * **refactor: `PlaceDetail` 도메인 모델의 속성명 변경** * 대기 가능 여부를 나타내는 `isWaitingAvailable` 변수명을 `isWaitingActive`로 수정했습니다. * **refactor: 데이터 매핑 및 테스트 코드 반영** * `PlaceDetailResponse`에서 도메인 모델로의 매핑 로직을 변경된 변수명에 맞게 업데이트했습니다. * `PlaceDetailTestFixture` 내 테스트용 가짜 데이터(Fake Data)의 필드명을 `isWaitingActive`로 수정했습니다. --------- Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 3 +- .../local/WaitingLocalDataSource.kt | 9 + .../local/WaitingLocalDataSourceImpl.kt | 36 ++++ .../remote/device/DeviceRemoteDataSource.kt | 10 + .../device/DeviceRemoteDataSourceImpl.kt | 17 ++ .../remote/waiting/WaitingRemoteDataSource.kt | 22 ++ .../waiting/WaitingRemoteDataSourceImpl.kt | 34 +++ .../model/request/PhoneRegisterRequest.kt | 10 + .../model/request/WaitingRegisterRequest.kt | 10 + .../response/place/PlaceDetailResponse.kt | 3 + .../response/waiting/MyWaitingResponse.kt | 35 ++++ .../response/waiting/PlaceWaitingResponse.kt | 19 ++ .../response/waiting/WaitingExistResponse.kt | 10 + .../repository/MyWaitingRepositoryImpl.kt | 24 +++ .../repository/WaitingInfoRepositoryImpl.kt | 68 ++++++ .../WaitingRegisterInfoRepositoryImpl.kt | 32 +++ .../festabook/data/service/DeviceService.kt | 15 ++ .../festabook/data/service/PlaceService.kt | 16 ++ .../festabook/data/service/WaitingService.kt | 21 ++ .../data/service/api/FestaBookAuthPlugin.kt | 5 + .../daedan/festabook/di/NetworkBindings.kt | 5 + .../festabook/domain/model/MyWaiting.kt | 11 + .../festabook/domain/model/PlaceDetail.kt | 1 + .../festabook/domain/model/PlaceWaiting.kt | 6 + .../festabook/domain/model/WaitingInfo.kt | 5 + .../festabook/domain/model/WaitingStatus.kt | 9 + .../domain/repository/MyWaitingRepository.kt | 9 + .../repository/WaitingInfoRepository.kt | 11 + .../WaitingRegisterInfoRepository.kt | 13 ++ .../local/WaitingLocalDataSourceTest.kt | 65 ++++++ .../datasource/remote/place/TestFixture.kt | 1 + .../waiting/WaitingRemoteDataSourceTest.kt | 119 +++++++++++ .../remote/waiting/WaitingTestFixture.kt | 79 +++++++ .../WaitingInfoRepositoryImplTest.kt | 194 ++++++++++++++++++ .../fixture/PlaceDetailRepositoryFixture.kt | 3 + .../placeDetail/PlaceDetailTestFixture.kt | 2 + 36 files changed, 931 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSource.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceImpl.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/PhoneRegisterRequest.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/WaitingRegisterRequest.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/PlaceWaitingResponse.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/WaitingExistResponse.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/WaitingService.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceWaiting.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingInfo.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingStatus.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/MyWaitingRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingInfoRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingRegisterInfoRepository.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImplTest.kt diff --git a/.gitignore b/.gitignore index b3c8505e..30becebc 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,5 @@ iosApp/iosApp/GoogleService-Info.plist /iosApp/Configuration/Debug.xcconfig -/iosApp/Configuration/Release.xcconfig \ No newline at end of file +/iosApp/Configuration/Release.xcconfig +.worktrees/ diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt new file mode 100644 index 00000000..236fc8ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt @@ -0,0 +1,9 @@ +package com.daedan.festabook.data.datasource.local + +import kotlinx.coroutines.flow.Flow + +interface WaitingLocalDataSource { + suspend fun savePhoneNumber(phoneNumber: String) + + fun getPhoneNumber(): Flow +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt new file mode 100644 index 00000000..ae9875b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt @@ -0,0 +1,36 @@ +package com.daedan.festabook.data.datasource.local + +import androidx.datastore.core.DataStore +import androidx.datastore.core.IOException +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +@ContributesBinding(AppScope::class) +@Inject +class WaitingLocalDataSourceImpl( + private val dataStore: DataStore, +) : WaitingLocalDataSource { + override suspend fun savePhoneNumber(phoneNumber: String) { + dataStore.edit { preferences -> + preferences[KEY_PHONE_NUMBER] = phoneNumber + } + } + + override fun getPhoneNumber(): Flow = + dataStore.data + .catch { + if (it is IOException) emit(emptyPreferences()) else throw it + }.map { it[KEY_PHONE_NUMBER] } + + companion object { + private val KEY_PHONE_NUMBER = stringPreferencesKey("waiting_phone_number") + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSource.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSource.kt index 3b033530..48c6084b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSource.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSource.kt @@ -8,4 +8,14 @@ interface DeviceRemoteDataSource { deviceIdentifier: String, fcmToken: String, ): ApiResult + + suspend fun registerPhone( + deviceId: Long, + phoneNumber: String, + ): ApiResult + + suspend fun updatePhone( + deviceId: Long, + phoneNumber: String, + ): ApiResult } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSourceImpl.kt index 4f54c217..238d4996 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/device/DeviceRemoteDataSourceImpl.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.data.datasource.remote.device import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.request.DeviceRegisterRequest +import com.daedan.festabook.data.model.request.PhoneRegisterRequest import com.daedan.festabook.data.model.response.DeviceRegisterResponse import com.daedan.festabook.data.service.DeviceService import dev.zacsweers.metro.AppScope @@ -25,4 +26,20 @@ class DeviceRemoteDataSourceImpl( ), ) } + + override suspend fun registerPhone( + deviceId: Long, + phoneNumber: String, + ): ApiResult = + ApiResult.toApiResult { + deviceService.registerPhone(deviceId, PhoneRegisterRequest(phoneNumber)) + } + + override suspend fun updatePhone( + deviceId: Long, + phoneNumber: String, + ): ApiResult = + ApiResult.toApiResult { + deviceService.updatePhone(deviceId, PhoneRegisterRequest(phoneNumber)) + } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSource.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSource.kt new file mode 100644 index 00000000..db81072a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSource.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.data.datasource.remote.waiting + +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.model.request.WaitingRegisterRequest +import com.daedan.festabook.data.model.response.waiting.MyWaitingResponse +import com.daedan.festabook.data.model.response.waiting.PlaceWaitingResponse +import com.daedan.festabook.data.model.response.waiting.WaitingExistResponse + +interface WaitingRemoteDataSource { + suspend fun fetchMyWaiting(): ApiResult + + suspend fun fetchMyWaitingExist(): ApiResult + + suspend fun fetchPlaceWaiting(placeId: Long): ApiResult + + suspend fun cancelWaiting(waitingId: Long): ApiResult + + suspend fun registerWaiting( + placeId: Long, + request: WaitingRegisterRequest, + ): ApiResult +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceImpl.kt new file mode 100644 index 00000000..80ec7f4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceImpl.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.data.datasource.remote.waiting + +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.model.request.WaitingRegisterRequest +import com.daedan.festabook.data.model.response.waiting.MyWaitingResponse +import com.daedan.festabook.data.model.response.waiting.PlaceWaitingResponse +import com.daedan.festabook.data.model.response.waiting.WaitingExistResponse +import com.daedan.festabook.data.service.PlaceService +import com.daedan.festabook.data.service.WaitingService +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject + +@ContributesBinding(AppScope::class) +@Inject +class WaitingRemoteDataSourceImpl( + private val waitingService: WaitingService, + private val placeService: PlaceService, +) : WaitingRemoteDataSource { + override suspend fun fetchMyWaiting(): ApiResult = ApiResult.toApiResult { waitingService.fetchMyWaiting() } + + override suspend fun fetchMyWaitingExist(): ApiResult = + ApiResult.toApiResult { waitingService.fetchMyWaitingExist() } + + override suspend fun fetchPlaceWaiting(placeId: Long): ApiResult = + ApiResult.toApiResult { placeService.fetchPlaceWaiting(placeId) } + + override suspend fun cancelWaiting(waitingId: Long): ApiResult = ApiResult.toApiResult { waitingService.cancelWaiting(waitingId) } + + override suspend fun registerWaiting( + placeId: Long, + request: WaitingRegisterRequest, + ): ApiResult = ApiResult.toApiResult { placeService.registerWaiting(placeId, request) } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/PhoneRegisterRequest.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/PhoneRegisterRequest.kt new file mode 100644 index 00000000..53c8b152 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/PhoneRegisterRequest.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhoneRegisterRequest( + @SerialName("phoneNumber") + val phoneNumber: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/WaitingRegisterRequest.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/WaitingRegisterRequest.kt new file mode 100644 index 00000000..2c93fdab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/request/WaitingRegisterRequest.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WaitingRegisterRequest( + @SerialName("partySize") + val partySize: Int, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt index a00e771f..1f421023 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt @@ -34,6 +34,8 @@ data class PlaceDetailResponse( val title: String?, @SerialName("timeTags") val timeTags: List, + @SerialName("waitingActivateStatus") + val isWaitingActive: Boolean, ) { @Serializable data class PlaceAnnouncement( @@ -67,6 +69,7 @@ fun PlaceDetailResponse.toDomain() = startTime = startTime.toLocalTime(), endTime = endTime.toLocalTime(), images = toPlaceDetailImages(), + isWaitingActive = isWaitingActive, ) private fun PlaceDetailResponse.toPlace() = diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt new file mode 100644 index 00000000..84f13a3d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt @@ -0,0 +1,35 @@ +package com.daedan.festabook.data.model.response.waiting + +import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.model.WaitingStatus +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MyWaitingResponse( + @SerialName("waitingId") + val waitingId: Long, + @SerialName("waitingOrder") + val waitingOrder: Int, + @SerialName("partySize") + val partySize: Int, + @SerialName("waitingStatus") + val waitingStatus: WaitingStatus, + @SerialName("totalWaitingTeams") + val totalWaitingTeams: Int, + @SerialName("estimatedWaitTime") + val estimatedWaitTime: Int, + @SerialName("phoneNumber") + val phoneNumber: String, +) + +fun MyWaitingResponse.toDomain() = + MyWaiting( + waitingId = waitingId, + waitingOrder = waitingOrder, + partySize = partySize, + waitingStatus = waitingStatus, + totalWaitingTeams = totalWaitingTeams, + estimatedWaitTime = estimatedWaitTime, + phoneNumber = phoneNumber, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/PlaceWaitingResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/PlaceWaitingResponse.kt new file mode 100644 index 00000000..b865a2c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/PlaceWaitingResponse.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.data.model.response.waiting + +import com.daedan.festabook.domain.model.PlaceWaiting +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PlaceWaitingResponse( + @SerialName("totalWaitingTeams") + val totalWaitingTeams: Int, + @SerialName("estimatedWaitTime") + val estimatedWaitTime: Int, +) + +fun PlaceWaitingResponse.toDomain() = + PlaceWaiting( + totalWaitingTeams = totalWaitingTeams, + estimatedWaitTime = estimatedWaitTime, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/WaitingExistResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/WaitingExistResponse.kt new file mode 100644 index 00000000..feedc32a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/WaitingExistResponse.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.data.model.response.waiting + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WaitingExistResponse( + @SerialName("exists") + val exists: Boolean, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt new file mode 100644 index 00000000..2f05d9a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt @@ -0,0 +1,24 @@ +package com.daedan.festabook.data.repository + +import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource +import com.daedan.festabook.data.model.response.waiting.toDomain +import com.daedan.festabook.data.util.toResult +import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.repository.MyWaitingRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject + +@ContributesBinding(AppScope::class) +@Inject +class MyWaitingRepositoryImpl( + private val waitingRemoteDataSource: WaitingRemoteDataSource, +) : MyWaitingRepository { + override suspend fun getMyWaiting(): Result { + val existResult = waitingRemoteDataSource.fetchMyWaitingExist().toResult().getOrElse { return Result.failure(it) } + if (!existResult.exists) return Result.success(null) + return waitingRemoteDataSource.fetchMyWaiting().toResult().mapCatching { it.toDomain() } + } + + override suspend fun cancelWaiting(waitingId: Long): Result = waitingRemoteDataSource.cancelWaiting(waitingId).toResult() +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt new file mode 100644 index 00000000..0a9f2a41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt @@ -0,0 +1,68 @@ +package com.daedan.festabook.data.repository + +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource +import com.daedan.festabook.data.datasource.local.WaitingLocalDataSource +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.datasource.remote.device.DeviceRemoteDataSource +import com.daedan.festabook.data.util.toResult +import com.daedan.festabook.domain.model.WaitingInfo +import com.daedan.festabook.domain.repository.WaitingInfoRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.firstOrNull + +@ContributesBinding(AppScope::class) +@Inject +class WaitingInfoRepositoryImpl( + private val deviceRemoteDataSource: DeviceRemoteDataSource, + private val waitingLocalDataSource: WaitingLocalDataSource, + private val deviceLocalDataSource: DeviceLocalDataSource, +) : WaitingInfoRepository { + override suspend fun getWaitingInfo(): Result { + val phoneNumber = + waitingLocalDataSource.getPhoneNumber().firstOrNull() + ?: return Result.success(null) + return Result.success(WaitingInfo(phoneNumber = phoneNumber)) + } + + override suspend fun saveWaitingInfo(waitingInfo: WaitingInfo): Result { + val deviceId = + deviceLocalDataSource.getDeviceId().firstOrNull() + ?: return Result.failure(IllegalStateException("Device ID not found")) + val apiResult = deviceRemoteDataSource.registerPhone(deviceId, waitingInfo.phoneNumber) + return persistPhoneIfAcceptable(apiResult, waitingInfo.phoneNumber) + } + + override suspend fun updateWaitingInfo(waitingInfo: WaitingInfo): Result { + val deviceId = + deviceLocalDataSource.getDeviceId().firstOrNull() + ?: return Result.failure(IllegalStateException("Device ID not found")) + val apiResult = deviceRemoteDataSource.updatePhone(deviceId, waitingInfo.phoneNumber) + return persistPhoneIfAcceptable(apiResult, waitingInfo.phoneNumber) + } + + private suspend fun persistPhoneIfAcceptable( + apiResult: ApiResult, + phoneNumber: String, + ): Result = + when (apiResult) { + is ApiResult.Success -> { + waitingLocalDataSource.savePhoneNumber(phoneNumber) + Result.success(Unit) + } + is ApiResult.ClientError -> + if (apiResult.code == HTTP_CONFLICT) { + // 이미 등록된 번호 — 서버 상태와 로컬 캐시 동기화 + waitingLocalDataSource.savePhoneNumber(phoneNumber) + Result.success(Unit) + } else { + apiResult.toResult() + } + else -> apiResult.toResult() + } + + companion object { + private const val HTTP_CONFLICT = 409 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt new file mode 100644 index 00000000..77bdcb73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt @@ -0,0 +1,32 @@ +package com.daedan.festabook.data.repository + +import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource +import com.daedan.festabook.data.model.request.WaitingRegisterRequest +import com.daedan.festabook.data.model.response.waiting.toDomain +import com.daedan.festabook.data.util.toResult +import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.model.PlaceWaiting +import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject + +@ContributesBinding(AppScope::class) +@Inject +class WaitingRegisterInfoRepositoryImpl( + private val waitingRemoteDataSource: WaitingRemoteDataSource, +) : WaitingRegisterInfoRepository { + override suspend fun getPlaceWaiting(placeId: Long): Result { + val response = waitingRemoteDataSource.fetchPlaceWaiting(placeId).toResult() + return response.mapCatching { it.toDomain() } + } + + override suspend fun registerWaiting( + placeId: Long, + partySize: Int, + ): Result { + val request = WaitingRegisterRequest(partySize = partySize) + val response = waitingRemoteDataSource.registerWaiting(placeId, request).toResult() + return response.mapCatching { it.toDomain() } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/DeviceService.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/DeviceService.kt index 749fa5d6..9a723984 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/DeviceService.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/DeviceService.kt @@ -1,14 +1,29 @@ package com.daedan.festabook.data.service import com.daedan.festabook.data.model.request.DeviceRegisterRequest +import com.daedan.festabook.data.model.request.PhoneRegisterRequest import com.daedan.festabook.data.model.response.DeviceRegisterResponse import de.jensklingenberg.ktorfit.Response import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path interface DeviceService { @POST("devices") suspend fun registerDevice( @Body deviceRegisterRequest: DeviceRegisterRequest, ): Response + + @POST("devices/{deviceId}/phone") + suspend fun registerPhone( + @Path("deviceId") deviceId: Long, + @Body request: PhoneRegisterRequest, + ): Response + + @PUT("devices/{deviceId}/phone") + suspend fun updatePhone( + @Path("deviceId") deviceId: Long, + @Body request: PhoneRegisterRequest, + ): Response } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/PlaceService.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/PlaceService.kt index 449937a7..dae07bf0 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/PlaceService.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/PlaceService.kt @@ -1,11 +1,16 @@ package com.daedan.festabook.data.service +import com.daedan.festabook.data.model.request.WaitingRegisterRequest import com.daedan.festabook.data.model.response.place.PlaceDetailResponse import com.daedan.festabook.data.model.response.place.PlaceGeographyResponse import com.daedan.festabook.data.model.response.place.PlaceResponse import com.daedan.festabook.data.model.response.place.TimeTagResponse +import com.daedan.festabook.data.model.response.waiting.MyWaitingResponse +import com.daedan.festabook.data.model.response.waiting.PlaceWaitingResponse import de.jensklingenberg.ktorfit.Response +import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST import de.jensklingenberg.ktorfit.http.Path interface PlaceService { @@ -22,4 +27,15 @@ interface PlaceService { @GET("places/geographies") suspend fun fetchPlaceGeographies(): Response> + + @GET("places/{placeId}/waiting-status") + suspend fun fetchPlaceWaiting( + @Path("placeId") placeId: Long, + ): Response + + @POST("places/{placeId}/waitings") + suspend fun registerWaiting( + @Path("placeId") placeId: Long, + @Body request: WaitingRegisterRequest, + ): Response } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/WaitingService.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/WaitingService.kt new file mode 100644 index 00000000..1cbe9a0a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/WaitingService.kt @@ -0,0 +1,21 @@ +package com.daedan.festabook.data.service + +import com.daedan.festabook.data.model.response.waiting.MyWaitingResponse +import com.daedan.festabook.data.model.response.waiting.WaitingExistResponse +import de.jensklingenberg.ktorfit.Response +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Path + +interface WaitingService { + @GET("waitings/me") + suspend fun fetchMyWaiting(): Response + + @GET("waitings/me/exists") + suspend fun fetchMyWaitingExist(): Response + + @POST("waitings/{waitingId}/cancel") + suspend fun cancelWaiting( + @Path("waitingId") waitingId: Long, + ): Response +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt index b21018cf..576ba45c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/api/FestaBookAuthPlugin.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.data.service.api +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import dev.zacsweers.metro.Inject import io.ktor.client.plugins.api.createClientPlugin @@ -8,12 +9,16 @@ import kotlinx.coroutines.flow.firstOrNull @Inject class FestaBookAuthPlugin( private val festivalLocalDataSource: FestivalLocalDataSource, + private val deviceLocalDataSource: DeviceLocalDataSource, ) { val plugin = createClientPlugin(name = "FestaBookAuthPlugin") { onRequest { request, _ -> val festivalId = festivalLocalDataSource.getFestivalId().firstOrNull() ?: return@onRequest request.headers["festival"] = festivalId.toString() + + val deviceId = deviceLocalDataSource.getDeviceId().firstOrNull() ?: return@onRequest + request.headers["device"] = deviceId.toString() } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt index fec768d0..94b95ab7 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/NetworkBindings.kt @@ -10,6 +10,7 @@ import com.daedan.festabook.data.service.LostItemService import com.daedan.festabook.data.service.NoticeService import com.daedan.festabook.data.service.PlaceService import com.daedan.festabook.data.service.ScheduleService +import com.daedan.festabook.data.service.WaitingService import com.daedan.festabook.data.service.api.FestaBookAuthPlugin import com.daedan.festabook.data.service.createDeviceService import com.daedan.festabook.data.service.createFAQService @@ -20,6 +21,7 @@ import com.daedan.festabook.data.service.createLostItemService import com.daedan.festabook.data.service.createNoticeService import com.daedan.festabook.data.service.createPlaceService import com.daedan.festabook.data.service.createScheduleService +import com.daedan.festabook.data.service.createWaitingService import de.jensklingenberg.ktorfit.Ktorfit import de.jensklingenberg.ktorfit.converter.ResponseConverterFactory import de.jensklingenberg.ktorfit.ktorfit @@ -95,4 +97,7 @@ object NetworkBindings { @Provides fun provideFestivalLineupService(ktorfit: Ktorfit): FestivalLineupService = ktorfit.createFestivalLineupService() + + @Provides + fun provideWaitingService(ktorfit: Ktorfit): WaitingService = ktorfit.createWaitingService() } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt new file mode 100644 index 00000000..97117a48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt @@ -0,0 +1,11 @@ +package com.daedan.festabook.domain.model + +data class MyWaiting( + val waitingId: Long, + val waitingOrder: Int, + val partySize: Int, + val waitingStatus: WaitingStatus, + val totalWaitingTeams: Int, + val estimatedWaitTime: Int, + val phoneNumber: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceDetail.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceDetail.kt index ed3afffe..90da6e95 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceDetail.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceDetail.kt @@ -10,6 +10,7 @@ data class PlaceDetail( val startTime: LocalTime?, val endTime: LocalTime?, private val images: List, + val isWaitingActive: Boolean, ) { val sortedNotices: List = notices diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceWaiting.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceWaiting.kt new file mode 100644 index 00000000..43893b89 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/PlaceWaiting.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.domain.model + +data class PlaceWaiting( + val totalWaitingTeams: Int, + val estimatedWaitTime: Int, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingInfo.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingInfo.kt new file mode 100644 index 00000000..71e5a6ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingInfo.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.domain.model + +data class WaitingInfo( + val phoneNumber: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingStatus.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingStatus.kt new file mode 100644 index 00000000..368dd4a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/WaitingStatus.kt @@ -0,0 +1,9 @@ +package com.daedan.festabook.domain.model + +enum class WaitingStatus { + WAITING, + CALLED, + ARRIVED, + NO_SHOW, + CANCELED, +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/MyWaitingRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/MyWaitingRepository.kt new file mode 100644 index 00000000..e2ba5f8f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/MyWaitingRepository.kt @@ -0,0 +1,9 @@ +package com.daedan.festabook.domain.repository + +import com.daedan.festabook.domain.model.MyWaiting + +interface MyWaitingRepository { + suspend fun getMyWaiting(): Result + + suspend fun cancelWaiting(waitingId: Long): Result +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingInfoRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingInfoRepository.kt new file mode 100644 index 00000000..be3d18c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingInfoRepository.kt @@ -0,0 +1,11 @@ +package com.daedan.festabook.domain.repository + +import com.daedan.festabook.domain.model.WaitingInfo + +interface WaitingInfoRepository { + suspend fun getWaitingInfo(): Result + + suspend fun saveWaitingInfo(waitingInfo: WaitingInfo): Result + + suspend fun updateWaitingInfo(waitingInfo: WaitingInfo): Result +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingRegisterInfoRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingRegisterInfoRepository.kt new file mode 100644 index 00000000..1963122a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/WaitingRegisterInfoRepository.kt @@ -0,0 +1,13 @@ +package com.daedan.festabook.domain.repository + +import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.model.PlaceWaiting + +interface WaitingRegisterInfoRepository { + suspend fun getPlaceWaiting(placeId: Long): Result + + suspend fun registerWaiting( + placeId: Long, + partySize: Int, + ): Result +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceTest.kt new file mode 100644 index 00000000..17178a9b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceTest.kt @@ -0,0 +1,65 @@ +package com.daedan.festabook.data.datasource.local + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WaitingLocalDataSourceTest { + private lateinit var testDataStore: TestDataStore + private lateinit var waitingLocalDataSource: WaitingLocalDataSource + + @AfterTest + fun tearDown() { + deleteTestDataStore(testDataStore.path) + } + + @Test + fun `전화번호를 읽고 쓸 수 있다`() = + runTest { + // given + val expected = "010-1234-5678" + testDataStore = createTestDataStore(this) + waitingLocalDataSource = WaitingLocalDataSourceImpl(testDataStore.store) + + // when + waitingLocalDataSource.savePhoneNumber(expected) + val result = waitingLocalDataSource.getPhoneNumber().first() + + // then + assertEquals(expected, result) + } + + @Test + fun `전화번호가 저장되지 않았다면 null을 반환한다`() = + runTest { + // given + testDataStore = createTestDataStore(this) + waitingLocalDataSource = WaitingLocalDataSourceImpl(testDataStore.store) + + // when + val result = waitingLocalDataSource.getPhoneNumber().first() + + // then + assertNull(result) + } + + @Test + fun `전화번호를 덮어쓸 수 있다`() = + runTest { + // given + val expected = "010-9999-8888" + testDataStore = createTestDataStore(this) + waitingLocalDataSource = WaitingLocalDataSourceImpl(testDataStore.store) + waitingLocalDataSource.savePhoneNumber("010-1234-5678") + + // when + waitingLocalDataSource.savePhoneNumber(expected) + val result = waitingLocalDataSource.getPhoneNumber().first() + + // then + assertEquals(expected, result) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/place/TestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/place/TestFixture.kt index 241bb835..b7f8c82a 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/place/TestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/place/TestFixture.kt @@ -67,6 +67,7 @@ val FAKE_PLACE_DETAIL: PlaceDetailResponse = timeTagId = 1L, ), ), + isWaitingActive = true, ) val FAKE_PLACES: List = diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceTest.kt new file mode 100644 index 00000000..c73ab36d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingRemoteDataSourceTest.kt @@ -0,0 +1,119 @@ +package com.daedan.festabook.data.datasource.remote.waiting + +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.service.PlaceService +import com.daedan.festabook.data.service.WaitingService +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class WaitingRemoteDataSourceTest { + private lateinit var waitingService: WaitingService + private lateinit var placeService: PlaceService + private lateinit var waitingRemoteDataSource: WaitingRemoteDataSource + + @BeforeTest + fun setUp() { + waitingService = mock() + placeService = mock() + waitingRemoteDataSource = WaitingRemoteDataSourceImpl(waitingService, placeService) + } + + @Test + fun `내 웨이팅 정보를 가져올 수 있다`() = + runTest { + // given + everySuspend { waitingService.fetchMyWaiting() } returns FAKE_MY_WAITING_RESPONSE_WRAPPED + + // when + val expected = ApiResult.toApiResult { FAKE_MY_WAITING_RESPONSE_WRAPPED } + val result = waitingRemoteDataSource.fetchMyWaiting() + + // then + verifySuspend { waitingService.fetchMyWaiting() } + assertEquals(expected, result) + } + + @Test + fun `플레이스 웨이팅 현황을 가져올 수 있다`() = + runTest { + // given + val placeId = 10L + everySuspend { placeService.fetchPlaceWaiting(placeId) } returns FAKE_PLACE_WAITING_RESPONSE_WRAPPED + + // when + val expected = ApiResult.toApiResult { FAKE_PLACE_WAITING_RESPONSE_WRAPPED } + val result = waitingRemoteDataSource.fetchPlaceWaiting(placeId) + + // then + verifySuspend { placeService.fetchPlaceWaiting(placeId) } + assertEquals(expected, result) + } + + @Test + fun `웨이팅을 취소할 수 있다`() = + runTest { + // given + val waitingId = 1L + everySuspend { waitingService.cancelWaiting(waitingId) } returns FAKE_CANCEL_WAITING_RESPONSE + + // when + val expected = ApiResult.toApiResult { FAKE_CANCEL_WAITING_RESPONSE } + val result = waitingRemoteDataSource.cancelWaiting(waitingId) + + // then + verifySuspend { waitingService.cancelWaiting(waitingId) } + assertEquals(expected, result) + } + + @Test + fun `웨이팅을 등록할 수 있다`() = + runTest { + // given + val placeId = 10L + everySuspend { + placeService.registerWaiting(placeId, FAKE_WAITING_REGISTER_REQUEST) + } returns FAKE_MY_WAITING_RESPONSE_WRAPPED + + // when + val expected = ApiResult.toApiResult { FAKE_MY_WAITING_RESPONSE_WRAPPED } + val result = waitingRemoteDataSource.registerWaiting(placeId, FAKE_WAITING_REGISTER_REQUEST) + + // then + verifySuspend { placeService.registerWaiting(placeId, FAKE_WAITING_REGISTER_REQUEST) } + assertEquals(expected, result) + } + + @Test + fun `웨이팅이 존재하면 exists가 true인 응답을 반환한다`() = + runTest { + // given + everySuspend { waitingService.fetchMyWaitingExist() } returns FAKE_WAITING_EXIST_RESPONSE_WRAPPED + + // when + val result = waitingRemoteDataSource.fetchMyWaitingExist() + + // then + verifySuspend { waitingService.fetchMyWaitingExist() } + assertEquals(ApiResult.Success(FAKE_WAITING_EXIST_RESPONSE), result) + } + + @Test + fun `웨이팅이 존재하지 않으면 exists가 false인 응답을 반환한다`() = + runTest { + // given + everySuspend { waitingService.fetchMyWaitingExist() } returns FAKE_WAITING_NOT_EXIST_RESPONSE_WRAPPED + + // when + val result = waitingRemoteDataSource.fetchMyWaitingExist() + + // then + verifySuspend { waitingService.fetchMyWaitingExist() } + assertEquals(ApiResult.Success(FAKE_WAITING_NOT_EXIST_RESPONSE), result) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt new file mode 100644 index 00000000..389302bb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt @@ -0,0 +1,79 @@ +package com.daedan.festabook.data.datasource.remote.waiting + +import com.daedan.festabook.data.model.request.WaitingRegisterRequest +import com.daedan.festabook.data.model.response.waiting.MyWaitingResponse +import com.daedan.festabook.data.model.response.waiting.PlaceWaitingResponse +import com.daedan.festabook.data.model.response.waiting.WaitingExistResponse +import com.daedan.festabook.domain.model.WaitingStatus +import de.jensklingenberg.ktorfit.Response +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode + +val FAKE_HTTP_RESPONSE = + mock { + every { status } returns HttpStatusCode.OK + } + +val FAKE_MY_WAITING_RESPONSE = + MyWaitingResponse( + waitingId = 1L, + waitingOrder = 3, + partySize = 2, + waitingStatus = WaitingStatus.WAITING, + totalWaitingTeams = 10, + estimatedWaitTime = 15, + phoneNumber = "010-1234-5678", + ) + +val FAKE_PLACE_WAITING_RESPONSE = + PlaceWaitingResponse( + totalWaitingTeams = 10, + estimatedWaitTime = 15, + ) + +val FAKE_WAITING_REGISTER_REQUEST = + WaitingRegisterRequest( + partySize = 2, + ) + +@Suppress("UNCHECKED_CAST") +val FAKE_MY_WAITING_RESPONSE_WRAPPED: Response = + Response.success( + rawResponse = FAKE_HTTP_RESPONSE, + body = FAKE_MY_WAITING_RESPONSE, + ) as Response + +val FAKE_WAITING_EXIST_RESPONSE = WaitingExistResponse(exists = true) + +val FAKE_WAITING_NOT_EXIST_RESPONSE = WaitingExistResponse(exists = false) + +@Suppress("UNCHECKED_CAST") +val FAKE_WAITING_EXIST_RESPONSE_WRAPPED: Response = + Response.success( + rawResponse = FAKE_HTTP_RESPONSE, + body = FAKE_WAITING_EXIST_RESPONSE, + ) as Response + +@Suppress("UNCHECKED_CAST") +val FAKE_WAITING_NOT_EXIST_RESPONSE_WRAPPED: Response = + Response.success( + rawResponse = FAKE_HTTP_RESPONSE, + body = FAKE_WAITING_NOT_EXIST_RESPONSE, + ) as Response + +@Suppress("UNCHECKED_CAST") +val FAKE_PLACE_WAITING_RESPONSE_WRAPPED: Response = + Response.success( + rawResponse = FAKE_HTTP_RESPONSE, + body = FAKE_PLACE_WAITING_RESPONSE, + ) as Response + +@Suppress("UNCHECKED_CAST") +val FAKE_CANCEL_WAITING_RESPONSE: Response = + Response.success( + rawResponse = FAKE_HTTP_RESPONSE, + body = Unit, + ) as Response diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImplTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImplTest.kt new file mode 100644 index 00000000..ce936167 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImplTest.kt @@ -0,0 +1,194 @@ +package com.daedan.festabook.data.repository + +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource +import com.daedan.festabook.data.datasource.local.WaitingLocalDataSource +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.datasource.remote.device.DeviceRemoteDataSource +import com.daedan.festabook.domain.model.WaitingInfo +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify.VerifyMode +import dev.mokkery.verifySuspend +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WaitingInfoRepositoryImplTest { + private lateinit var deviceRemoteDataSource: DeviceRemoteDataSource + private lateinit var waitingLocalDataSource: WaitingLocalDataSource + private lateinit var deviceLocalDataSource: DeviceLocalDataSource + private lateinit var repository: WaitingInfoRepositoryImpl + + private val deviceId = 1L + private val phoneNumber = "010-1234-5678" + private val waitingInfo = WaitingInfo(phoneNumber = phoneNumber) + + @BeforeTest + fun setUp() { + deviceRemoteDataSource = mock() + waitingLocalDataSource = mock() + deviceLocalDataSource = mock() + repository = + WaitingInfoRepositoryImpl( + deviceRemoteDataSource = deviceRemoteDataSource, + waitingLocalDataSource = waitingLocalDataSource, + deviceLocalDataSource = deviceLocalDataSource, + ) + } + + // getWaitingInfo + + @Test + fun `로컬에 전화번호가 있으면 WaitingInfo를 반환한다`() = + runTest { + // given + everySuspend { waitingLocalDataSource.getPhoneNumber() } returns flowOf(phoneNumber) + + // when + val result = repository.getWaitingInfo() + + // then + assertEquals(WaitingInfo(phoneNumber = phoneNumber), result.getOrNull()) + } + + @Test + fun `로컬에 전화번호가 없으면 null을 반환한다`() = + runTest { + // given + everySuspend { waitingLocalDataSource.getPhoneNumber() } returns flowOf(null) + + // when + val result = repository.getWaitingInfo() + + // then + assertNull(result.getOrNull()) + } + + // saveWaitingInfo + + @Test + fun `deviceId가 없으면 저장에 실패한다`() = + runTest { + // given + everySuspend { deviceLocalDataSource.getDeviceId() } returns flowOf(null) + + // when + val result = repository.saveWaitingInfo(waitingInfo) + + // then + assertTrue(result.isFailure) + verifySuspend(VerifyMode.not) { waitingLocalDataSource.savePhoneNumber(any()) } + } + + @Test + fun `서버 저장 성공 시 로컬에도 저장하고 success를 반환한다`() = + runTest { + // given + everySuspend { deviceLocalDataSource.getDeviceId() } returns flowOf(deviceId) + everySuspend { deviceRemoteDataSource.registerPhone(deviceId, phoneNumber) } returns ApiResult.Success(Unit) + everySuspend { waitingLocalDataSource.savePhoneNumber(phoneNumber) } returns Unit + + // when + val result = repository.saveWaitingInfo(waitingInfo) + + // then + assertTrue(result.isSuccess) + verifySuspend(VerifyMode.exactly(1)) { waitingLocalDataSource.savePhoneNumber(phoneNumber) } + } + + @Test + fun `서버 409 응답 시 로컬에 저장하고 success를 반환한다`() = + runTest { + // given + everySuspend { deviceLocalDataSource.getDeviceId() } returns flowOf(deviceId) + everySuspend { + deviceRemoteDataSource.registerPhone(deviceId, phoneNumber) + } returns ApiResult.ClientError(409, null, null) + everySuspend { waitingLocalDataSource.savePhoneNumber(phoneNumber) } returns Unit + + // when + val result = repository.saveWaitingInfo(waitingInfo) + + // then + assertTrue(result.isSuccess) + verifySuspend(VerifyMode.exactly(1)) { waitingLocalDataSource.savePhoneNumber(phoneNumber) } + } + + @Test + fun `서버 500 응답 시 로컬에 저장하지 않고 failure를 반환한다`() = + runTest { + // given + everySuspend { deviceLocalDataSource.getDeviceId() } returns flowOf(deviceId) + everySuspend { + deviceRemoteDataSource.registerPhone(deviceId, phoneNumber) + } returns ApiResult.ServerError(500, null, null) + + // when + val result = repository.saveWaitingInfo(waitingInfo) + + // then + assertTrue(result.isFailure) + verifySuspend(VerifyMode.not) { waitingLocalDataSource.savePhoneNumber(any()) } + } + + // updateWaitingInfo + + @Test + fun `업데이트 성공 시 로컬에도 저장하고 success를 반환한다`() = + runTest { + // given + everySuspend { deviceLocalDataSource.getDeviceId() } returns flowOf(deviceId) + everySuspend { deviceRemoteDataSource.updatePhone(deviceId, phoneNumber) } returns ApiResult.Success(Unit) + everySuspend { waitingLocalDataSource.savePhoneNumber(phoneNumber) } returns Unit + + // when + val result = repository.updateWaitingInfo(waitingInfo) + + // then + assertTrue(result.isSuccess) + verifySuspend(VerifyMode.exactly(1)) { waitingLocalDataSource.savePhoneNumber(phoneNumber) } + } + + @Test + fun `업데이트 409 응답 시 로컬에 저장하고 success를 반환한다`() = + runTest { + // given + everySuspend { deviceLocalDataSource.getDeviceId() } returns flowOf(deviceId) + everySuspend { + deviceRemoteDataSource.updatePhone(deviceId, phoneNumber) + } returns ApiResult.ClientError(409, null, null) + everySuspend { waitingLocalDataSource.savePhoneNumber(phoneNumber) } returns Unit + + // when + val result = repository.updateWaitingInfo(waitingInfo) + + // then + assertTrue(result.isSuccess) + verifySuspend(VerifyMode.exactly(1)) { waitingLocalDataSource.savePhoneNumber(phoneNumber) } + } + + @Test + fun `업데이트 서버 500 응답 시 로컬에 저장하지 않고 failure를 반환한다`() = + runTest { + // given + everySuspend { deviceLocalDataSource.getDeviceId() } returns flowOf(deviceId) + everySuspend { + deviceRemoteDataSource.updatePhone(deviceId, phoneNumber) + } returns ApiResult.ServerError(500, null, null) + + // when + val result = repository.updateWaitingInfo(waitingInfo) + + // then + assertTrue(result.isFailure) + verifySuspend(VerifyMode.not) { waitingLocalDataSource.savePhoneNumber(any()) } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/fixture/PlaceDetailRepositoryFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/fixture/PlaceDetailRepositoryFixture.kt index 765b87f7..813de99e 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/fixture/PlaceDetailRepositoryFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/fixture/PlaceDetailRepositoryFixture.kt @@ -30,6 +30,7 @@ fun fakePlaceDetailResponse(placeId: Long) = timeTagId = 1L, ), ), + isWaitingActive = false, ) fun fakePlaceDetailResponseWithNotice(placeId: Long) = @@ -59,6 +60,7 @@ fun fakePlaceDetailResponseWithNotice(placeId: Long) = placeImages = emptyList(), title = null, timeTags = emptyList(), + isWaitingActive = false, ) fun fakePlaceDetailResponseWithImage(placeId: Long) = @@ -86,4 +88,5 @@ fun fakePlaceDetailResponseWithImage(placeId: Long) = ), title = null, timeTags = emptyList(), + isWaitingActive = false, ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt index 73767169..6fd35fcd 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt @@ -25,6 +25,7 @@ val FAKE_PLACE_DETAIL = sequence = 1, ), ), + isWaitingActive = true, ) val FAKE_ETC_PLACE_DETAIL = @@ -51,4 +52,5 @@ val FAKE_ETC_PLACE_DETAIL = startTime = null, endTime = null, images = emptyList(), + isWaitingActive = false, ) From c845121503e30ed19685aad97774cea378869d41 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:27:30 +0900 Subject: [PATCH 18/54] =?UTF-8?q?[Chore]:=20iOS=20Staging=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build: iOS Staging 빌드 구성 추가 및 버전 동기화 로직 개선 * **feat: iOS 'Staging' 빌드 구성 추가** * `project.pbxproj`: `Staging` 빌드 구성을 새롭게 정의하고, `Debug`와 동일한 번들 ID(`debug.eoehdeksruf.festabook`) 및 프로비저닝 프로파일을 사용하도록 설정했습니다. * `project.pbxproj`: 빌드 스크립트를 수정하여 `Staging` 구성에서도 `Debug`용 Firebase 설정(`GoogleService-Info-DEBUG.plist`)을 참조하도록 업데이트했습니다. * `Podfile`: `Staging` 구성을 `:release` 모드로 처리하도록 프로젝트 설정을 추가했습니다. * **feat: Android `Staging` 빌드 버전(Flavor) 추가** * `build.gradle.kts`: `Staging` 빌드 버전을 추가하고 개발용 API URL 및 번들 ID를 주입하도록 설정했습니다. * **chore: iOS 버전 관리 자동화 및 초기값 동기화** * `build.gradle.kts`: `updateIosVersion` 태스크에서 `Info.plist`뿐만 아니라 `Config.xcconfig` 파일의 `MARKETING_VERSION`과 `CURRENT_PROJECT_VERSION`도 자동으로 업데이트하도록 로직을 개선했습니다. * `Config.xcconfig`: 앱 버전을 `2.1.0 (20100)`으로 업데이트하여 다른 설정 파일들과 동기화했습니다. * `Info.plist`: 들여쓰기 포맷을 정리했습니다. * **build: iOS 배포 및 사이닝 설정 수정** * `project.pbxproj`: `Debug` 및 `Staging` 구성의 `IPHONEOS_DEPLOYMENT_TARGET`을 `17.0`으로 조정했습니다. * `CODE_SIGN_IDENTITY`를 `iPhone Distribution`으로, 프로비저닝 프로파일을 `festabook_distribution_debug_provisioning_profile`로 변경하여 배포 설정을 보완했습니다. --- composeApp/build.gradle.kts | 20 +++++ iosApp/Configuration/Config.xcconfig | 4 +- iosApp/Podfile | 2 + iosApp/Podfile.lock | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 115 ++++++++++++++++++++++-- iosApp/iosApp/Info.plist | 4 +- 6 files changed, 137 insertions(+), 10 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cdfe64e2..c10d36d6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -185,6 +185,11 @@ buildkonfig { buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) buildConfigField(STRING, "FESTABOOK_URL", baseUrl) } + create("Staging") { + buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) + buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) + buildConfigField(STRING, "APP_BUNDLE_ID", appBundleIdDev) + } } } @@ -240,6 +245,7 @@ android { resValue("string", "app_name", "Festabook") signingConfig = signingConfigs["release"] } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -278,10 +284,24 @@ ktorfit { val updateIosVersion by tasks.registering { val plistFile = rootProject.layout.projectDirectory.file("iosApp/iosApp/Info.plist") + val xcconfigFile = rootProject.layout.projectDirectory.file("iosApp/Configuration/Config.xcconfig") val versionName = providers.gradleProperty("APP_VERSION_NAME") val versionCode = providers.gradleProperty("APP_VERSION_CODE") doLast { + // Config.xcconfig의 MARKETING_VERSION, CURRENT_PROJECT_VERSION 업데이트 + val xcconfig = xcconfigFile.asFile + var xcconfigText = xcconfig.readText() + xcconfigText = xcconfigText.replace( + Regex("MARKETING_VERSION=.*"), + "MARKETING_VERSION=${versionName.get()}" + ) + xcconfigText = xcconfigText.replace( + Regex("CURRENT_PROJECT_VERSION=.*"), + "CURRENT_PROJECT_VERSION=${versionCode.get()}" + ) + xcconfig.writeText(xcconfigText) + val file = plistFile.asFile var text = file.readText() diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 2ef6b71d..23e51b09 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Festabookkmp PRODUCT_BUNDLE_IDENTIFIER=com.daedan.festabook.Festabookkmp$(TEAM_ID) -CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file +CURRENT_PROJECT_VERSION=20100 +MARKETING_VERSION=2.1.0 \ No newline at end of file diff --git a/iosApp/Podfile b/iosApp/Podfile index df98ad2f..a8a20dfc 100644 --- a/iosApp/Podfile +++ b/iosApp/Podfile @@ -3,6 +3,8 @@ platform :ios, '17.0' +project 'iosApp', 'Staging' => :release + target 'iosApp' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! diff --git a/iosApp/Podfile.lock b/iosApp/Podfile.lock index 3b471ef9..52dad4b4 100644 --- a/iosApp/Podfile.lock +++ b/iosApp/Podfile.lock @@ -175,6 +175,6 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 -PODFILE CHECKSUM: 3408dee9b9ba102f60ed2e9d0f7845f603c7e05a +PODFILE CHECKSUM: 8fd90d4fb1960e259ae00bbfd7b5fdd635fdf9a8 COCOAPODS: 1.16.2 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 8d0387c0..69039bb5 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ /* Begin PBXFileReference section */ 820F350A1443CEFD9CFD725C /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; 855378A5D29CC552A6E4D743 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CEC1F0474A445661797DBFEB /* Pods-iosApp.staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.staging.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.staging.xcconfig"; sourceTree = ""; }; DA1BDCF16C59B3496748A3AF /* Festabookkmp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Festabookkmp.app; sourceTree = BUILT_PRODUCTS_DIR; }; E18E71FF9DA441A898F4ADFD /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -38,6 +39,8 @@ }; 8E6C8D378651BF8144E66D20 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Configuration; sourceTree = ""; }; @@ -68,6 +71,7 @@ children = ( E18E71FF9DA441A898F4ADFD /* Pods-iosApp.debug.xcconfig */, 820F350A1443CEFD9CFD725C /* Pods-iosApp.release.xcconfig */, + CEC1F0474A445661797DBFEB /* Pods-iosApp.staging.xcconfig */, ); path = Pods; sourceTree = ""; @@ -225,7 +229,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nDEBUG_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-DEBUG.plist\"\nRELEASE_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-RELEASE.plist\"\n\n# 2. 앱이 실제로 참조하게 될 목적지 경로\nDESTINATION_PATH=\"${SRCROOT}/iosApp/GoogleService-Info.plist\"\n\n# 3. 빌드 구성(Configuration)에 따라 복사 실행\nif [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n echo \"복사 중: Debug용 Firebase 설정...\"\n cp \"${DEBUG_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nelse\n echo \"복사 중: Release용 Firebase 설정...\"\n cp \"${RELEASE_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nfi\n"; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nDEBUG_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-DEBUG.plist\"\nRELEASE_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-RELEASE.plist\"\n\n# 2. 앱이 실제로 참조하게 될 목적지 경로\nDESTINATION_PATH=\"${SRCROOT}/iosApp/GoogleService-Info.plist\"\n\n# 3. 빌드 구성(Configuration)에 따라 복사 실행\nif [ \"${CONFIGURATION}\" == \"Debug\" ] || [ \"${CONFIGURATION}\" == \"Staging\" ]; then\n echo \"복사 중: Debug용 Firebase 설정...\"\n cp \"${DEBUG_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nelse\n echo \"복사 중: Release용 Firebase 설정...\"\n cp \"${RELEASE_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nfi\n"; }; 884269F5D9640B20E82AC683 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -276,6 +280,105 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 67A4B3202F8C166B00800C64 /* Staging */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 8E6C8D378651BF8144E66D20 /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Staging; + }; + 67A4B3212F8C166B00800C64 /* Staging */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CEC1F0474A445661797DBFEB /* Pods-iosApp.staging.xcconfig */; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = iosApp/iosAppDebug.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L8HZU9MP64; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + KOTLIN_FRAMEWORK_BUILD_TYPE = release; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = debug.eoehdeksruf.festabook; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_distribution_debug_provisioning_profile; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Staging; + }; 94BDB26E51A3BD8821288379 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = E18E71FF9DA441A898F4ADFD /* Pods-iosApp.debug.xcconfig */; @@ -286,7 +389,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = iosApp/iosAppDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -302,14 +405,14 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = debug.eoehdeksruf.festabook; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_development_debug_provisioning_profile; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_distribution_debug_provisioning_profile; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -397,7 +500,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -483,6 +586,7 @@ buildConfigurations = ( 94BDB26E51A3BD8821288379 /* Debug */, A71446CCDF09E963D579622B /* Release */, + 67A4B3212F8C166B00800C64 /* Staging */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -492,6 +596,7 @@ buildConfigurations = ( F2FE3F7BE3BB7C0E45986A6A /* Debug */, A3B84C79799650835DF15813 /* Release */, + 67A4B3202F8C166B00800C64 /* Staging */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index b4f2fba7..994be07b 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -15,8 +15,8 @@ remote-notification CFBundleShortVersionString - 2.1.0 + 2.1.0 CFBundleVersion - 20100 + 20100 From 241c7de018566150353dc3cdab36ff110ce5e410 Mon Sep 17 00:00:00 2001 From: changui <122252160+changuii@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:50:16 +0900 Subject: [PATCH 19/54] =?UTF-8?q?[Chore]=20GitHub=20Actions=20self-hosted?= =?UTF-8?q?=20runner=20=EC=A0=84=ED=99=98=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: GitHub Actions runner를 self-hosted로 전환 GitHub-hosted runner 사용 불가로 인해 모든 워크플로우의 runs-on을 self-hosted로 변경 closes #129 * fix: setup android 추가 * chore: CI 워크플로우 내 테스트 결과 게시 액션 경로 수정 * **chore: `publish-unit-test-result-action` 사용 경로 업데이트** * `.github/workflows/cmp-ci.yml`: `EnricoMi/publish-unit-test-result-action@v2`를 `EnricoMi/publish-unit-test-result-action/linux@v2`로 변경하여 특정 플랫폼 실행 환경에 맞게 수정했습니다. --------- Co-authored-by: oungsi2000 --- .github/workflows/android-release-drafter.yml | 6 +++--- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/cmp-ci.yml | 7 +++++-- .github/workflows/common-slack-notify-opened.yml | 2 +- .github/workflows/common-slack-notify-rerequested.yml | 2 +- .github/workflows/common-slack-notify-submitted.yml | 2 +- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/android-release-drafter.yml b/.github/workflows/android-release-drafter.yml index b0c9b596..1ce5f2a6 100644 --- a/.github/workflows/android-release-drafter.yml +++ b/.github/workflows/android-release-drafter.yml @@ -11,7 +11,7 @@ permissions: jobs: Upload-Release-Bundle: - runs-on: ubuntu-latest + runs-on: self-hosted outputs: version_code: ${{ steps.get_version.outputs.version_code }} @@ -79,7 +79,7 @@ jobs: #번들이 업로드가 완료된 후 버전 태그를 작성합니다 Release-Drafter: - runs-on: ubuntu-latest + runs-on: self-hosted needs: Upload-Release-Bundle steps: @@ -98,7 +98,7 @@ jobs: # hotfix 머지 시, main의 변경사항을 develop에 자동 반영합니다 # 번들이 업로드가 완료된 후 실행됩니다 Reflect-Changes-Into-Develop: - runs-on: ubuntu-latest + runs-on: self-hosted needs: Upload-Release-Bundle steps: diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index d41ad890..c7de15b6 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -18,7 +18,7 @@ jobs: # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - runs-on: ubuntu-latest + runs-on: self-hosted permissions: contents: read pull-requests: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 668fad4d..cf94eb74 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -19,7 +19,7 @@ jobs: (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) ) - runs-on: ubuntu-latest + runs-on: self-hosted permissions: contents: read pull-requests: write diff --git a/.github/workflows/cmp-ci.yml b/.github/workflows/cmp-ci.yml index 9d3ceff5..ef1df1d6 100644 --- a/.github/workflows/cmp-ci.yml +++ b/.github/workflows/cmp-ci.yml @@ -10,7 +10,7 @@ on: jobs: Run-PR-Test: - runs-on: ubuntu-latest + runs-on: self-hosted permissions: contents: read @@ -26,6 +26,9 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Setup Android SDK + uses: android-actions/setup-android@v4 + - name: Gradle cache uses: actions/cache@v4 with: @@ -81,7 +84,7 @@ jobs: - name: Publish Unit Test Results if: always() - uses: EnricoMi/publish-unit-test-result-action@v2 + uses: EnricoMi/publish-unit-test-result-action/linux@v2 with: # 결과 리포트 경로 수정 (composeApp 모듈 기준) files: composeApp/build/test-results/**/*.xml diff --git a/.github/workflows/common-slack-notify-opened.yml b/.github/workflows/common-slack-notify-opened.yml index 0994d2f5..3de838d1 100644 --- a/.github/workflows/common-slack-notify-opened.yml +++ b/.github/workflows/common-slack-notify-opened.yml @@ -13,7 +13,7 @@ concurrency: # 최초 리뷰 요청 알림 jobs: notify: - runs-on: ubuntu-latest + runs-on: self-hosted steps: # JSON 파싱 도구 - name: Install jq diff --git a/.github/workflows/common-slack-notify-rerequested.yml b/.github/workflows/common-slack-notify-rerequested.yml index 4b58c605..bdf52407 100644 --- a/.github/workflows/common-slack-notify-rerequested.yml +++ b/.github/workflows/common-slack-notify-rerequested.yml @@ -15,7 +15,7 @@ concurrency: # 재리뷰 요청 알림 jobs: notify: - runs-on: ubuntu-latest + runs-on: self-hosted steps: # JSON 파싱 도구 - name: Install jq diff --git a/.github/workflows/common-slack-notify-submitted.yml b/.github/workflows/common-slack-notify-submitted.yml index 0f817889..f902331a 100644 --- a/.github/workflows/common-slack-notify-submitted.yml +++ b/.github/workflows/common-slack-notify-submitted.yml @@ -13,7 +13,7 @@ concurrency: # 리뷰 완료 알림 jobs: notify: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq From b7d48263ba57839f9a92cfc34b5a004864188ffc Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:33:36 +0900 Subject: [PATCH 20/54] =?UTF-8?q?[Fix]=20=EC=A7=80=EB=8F=84=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B4=88=EA=B8=B0=20=EC=9C=84=EC=B9=98=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: iOS 네이버 지도 컨텐츠 패딩 설정 오류 수정 및 초기 패딩 값 조정 * **fix: iOS `NaverMap`의 `setContentInset` 호출 방식 및 인자 순서 수정** * `NaverMap.ios.kt`: `platformMap.mapView.contentInset` 속성에 직접 값을 할당하던 방식에서 `setContentInset(_:animate:)` 메서드 호출 방식으로 변경하여 애니메이션 지원을 추가했습니다. * `UIEdgeInsetsMake` 호출 시 잘못 매핑되어 있던 인자 순서(left, top, right, bottom)를 iOS UIKit 표준 순서(top, left, bottom, right)에 맞게 교정했습니다. * **refactor: 지도 초기 바텀 패딩 값 조정** * `MapManager.kt`: UI 레이아웃 최적화를 위해 `INITIAL_PADDING` 값을 `254.dp`에서 `180.dp`로 하향 조정했습니다. --- .../presentation/placeMap/mapManager/MapManager.kt | 2 +- .../presentation/placeMap/platform/NaverMap.ios.kt | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt index ab90a20b..d21ca302 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt @@ -89,7 +89,7 @@ class MapManager( private const val OVERLAY_COLOR_ID = 0x4D1B1B1B - private val INITIAL_PADDING = 254.dp + private val INITIAL_PADDING = 180.dp // 대한민국 전체를 덮는 오버레이 좌표입니다 private val EDGE_COORS = diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/platform/NaverMap.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/platform/NaverMap.ios.kt index 9bff47ab..17820d98 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/platform/NaverMap.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/platform/NaverMap.ios.kt @@ -111,13 +111,15 @@ actual class NaverMap( animate: Boolean, ) { // iOS UIKit은 pt 단위 사용 — Dp는 iOS pt와 동일하므로 .value 직접 사용 가능 - platformMap.mapView.contentInset = + platformMap.mapView.setContentInset( UIEdgeInsetsMake( - left = left.value.toDouble(), - top = top.value.toDouble(), - right = right.value.toDouble(), - bottom = bottom.value.toDouble(), - ) + top.value.toDouble(), + left.value.toDouble(), + bottom.value.toDouble(), + right.value.toDouble(), + ), + animate, + ) } actual fun interface OnCameraChangeListener { From 8ae4fc9ff660e3e7d79e645680a29eff78351d21 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:33:52 +0900 Subject: [PATCH 21/54] =?UTF-8?q?[Fix]=20ios=EC=97=90=EC=84=9C=20=ED=99=88?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=92=A4=EB=A1=9C?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=8B=9C,=20=EC=8A=A4=EB=82=B5=EB=B0=94?= =?UTF-8?q?=EA=B0=80=20=EB=85=B8=EC=B6=9C=EB=90=98=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Android 플랫폼에서만 백 버튼 동작이 수행되도록 수정 * **fix: `MainScreen` 내 백 버튼 처리 로직 플랫폼 분기 추가** * `NavigationBackHandler`에서 `getPlatform()`을 확인하여 `Platform.ANDROID`일 때만 `mainViewModel.onBackPressed()`가 호출되도록 수정했습니다. 이는 iOS 등 타 플랫폼에서 의도치 않게 백 버튼 로직이 트리거되는 현상을 방지합니다. * **feat: 멀티플랫폼 대응을 위한 `Platform` 클래스 및 인터페이스 추가** * `commonMain`: `Platform` enum 클래스(ANDROID, IOS)와 플랫폼 정보를 가져오는 `getPlatform()` expect 함수를 정의했습니다. * `androidMain`: `getPlatform()`의 actual 구현으로 `Platform.ANDROID`를 반환하도록 설정했습니다. * `iosMain`: `getPlatform()`의 actual 구현으로 `Platform.IOS`를 반환하도록 설정했습니다. * refactor: 플랫폼 구분 로직 개선 및 `expect/actual` 제거 * **refactor: `expect/actual` 기반의 플랫폼 문자열 반환 로직 제거** * `FestivalNotificationPlatform.kt` (`expect`), `FestivalNotificationEndpoint.android.kt`, `FestivalNotificationEndpoint.ios.kt` (`actual`) 파일을 삭제했습니다. * 공통 로직인 `getPlatform()`을 사용하여 플랫폼별 문자열("android", "ios")을 결정하도록 변경했습니다. * **refactor: `FestivalNotificationRemoteDataSourceImpl` 내 플랫폼 파라미터 주입 방식 수정** * `festivalNotificationPlatform()` 호출 대신, companion object 내에서 `getPlatform()` 결과에 따라 정의된 `platform` 상수를 사용하도록 수정했습니다. * **test: `FestivalNotificationRemoteDataSourceTest` 내 플랫폼 엔드포인트 로직 반영** * 테스트 코드에서도 `getPlatform()` 기반으로 엔드포인트 문자열을 결정하도록 수정하여 프로덕션 코드의 변경 사항을 반영했습니다. --- .../kotlin/com/daedan/festabook/Platform.kt | 3 +++ .../FestivalNotificationEndpoint.android.kt | 3 --- .../kotlin/com/daedan/festabook/Platform.kt | 8 ++++++++ ...estivalNotificationRemoteDataSourceImpl.kt | 13 +++++++++++-- .../platform/FestivalNotificationPlatform.kt | 3 --- .../presentation/main/component/MainScreen.kt | 6 +++++- ...estivalNotificationRemoteDataSourceTest.kt | 19 ++++++++++++++++--- .../com/daedan/festabook/Platform.ios.kt | 3 +++ .../FestivalNotificationEndpoint.ios.kt | 3 --- 9 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.kt delete mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.kt new file mode 100644 index 00000000..f1ccf6e1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/Platform.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook + +actual fun getPlatform(): Platform = Platform.ANDROID diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt deleted file mode 100644 index 765a574e..00000000 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.android.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.daedan.festabook.data.service.platform - -actual fun festivalNotificationPlatform(): String = "android" diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt new file mode 100644 index 00000000..5bf2d2cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/Platform.kt @@ -0,0 +1,8 @@ +package com.daedan.festabook + +expect fun getPlatform(): Platform + +enum class Platform { + ANDROID, + IOS, +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt index 2d3ba3b1..292d0209 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceImpl.kt @@ -1,11 +1,12 @@ package com.daedan.festabook.data.datasource.remote.festival +import com.daedan.festabook.Platform import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse import com.daedan.festabook.data.service.FestivalNotificationService -import com.daedan.festabook.data.service.platform.festivalNotificationPlatform +import com.daedan.festabook.getPlatform import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @@ -22,7 +23,7 @@ class FestivalNotificationRemoteDataSourceImpl( ApiResult.toApiResult { festivalNotificationService.saveFestivalNotification( festivalId, - festivalNotificationPlatform(), + platform, FestivalNotificationRequest(deviceId = deviceId), ) } @@ -36,4 +37,12 @@ class FestivalNotificationRemoteDataSourceImpl( ApiResult.toApiResult { festivalNotificationService.getFestivalNotification(deviceId) } + + companion object { + private val platform = + when (getPlatform()) { + Platform.ANDROID -> "android" + Platform.IOS -> "ios" + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt deleted file mode 100644 index 7633c63b..00000000 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationPlatform.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.daedan.festabook.data.service.platform - -expect fun festivalNotificationPlatform(): String diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index 019f46a8..1c27c4de 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -15,7 +15,9 @@ import androidx.navigation.compose.NavHost import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.Platform import com.daedan.festabook.di.FestabookAppGraph +import com.daedan.festabook.getPlatform import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.FestabookSnackbar @@ -96,7 +98,9 @@ fun MainScreen( NavigationBackHandler( state = state, ) { - mainViewModel.onBackPressed() + if (getPlatform() == Platform.ANDROID) { + mainViewModel.onBackPressed() + } } RememberDeepLinkHandler { intent -> diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt index e47ddccc..13cb7c75 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationRemoteDataSourceTest.kt @@ -1,11 +1,12 @@ package com.daedan.festabook.data.datasource.remote.festival +import com.daedan.festabook.Platform import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.datasource.remote.schedule.FAKE_HTTP_RESPONSE import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse import com.daedan.festabook.data.service.FestivalNotificationService -import com.daedan.festabook.data.service.platform.festivalNotificationPlatform +import com.daedan.festabook.getPlatform import de.jensklingenberg.ktorfit.Response import dev.mokkery.answering.returns import dev.mokkery.everySuspend @@ -35,6 +36,12 @@ private val FAKE_DELETE_FESTIVAL_NOTIFICATION_HTTP_RESPONSE: Response = body = Unit, ) as Response +private val ENDPOINT = + when (getPlatform()) { + Platform.ANDROID -> "android" + Platform.IOS -> "ios" + } + class FestivalNotificationRemoteDataSourceTest { private lateinit var festivalNotificationService: FestivalNotificationService private lateinit var dataSource: FestivalNotificationRemoteDataSource @@ -54,10 +61,16 @@ class FestivalNotificationRemoteDataSourceTest { val request = FestivalNotificationRequest(deviceId = deviceId) + val endPoint = + when (getPlatform()) { + Platform.ANDROID -> "android" + Platform.IOS -> "ios" + } + everySuspend { festivalNotificationService.saveFestivalNotification( festivalId, - festivalNotificationPlatform(), + ENDPOINT, request, ) } returns FAKE_SAVE_FESTIVAL_NOTIFICATION_HTTP_RESPONSE @@ -70,7 +83,7 @@ class FestivalNotificationRemoteDataSourceTest { verifySuspend { festivalNotificationService.saveFestivalNotification( festivalId, - festivalNotificationPlatform(), + ENDPOINT, request, ) } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt new file mode 100644 index 00000000..e6dcff80 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/Platform.ios.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook + +actual fun getPlatform(): Platform = Platform.IOS diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt deleted file mode 100644 index 7082f987..00000000 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/data/service/platform/FestivalNotificationEndpoint.ios.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.daedan.festabook.data.service.platform - -actual fun festivalNotificationPlatform(): String = "ios" From e33f4798cd2b5e4f06af1474793200a0687ec837 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:34:14 +0900 Subject: [PATCH 22/54] =?UTF-8?q?[Fix]:=20MainScreen=20=EB=82=B4=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit style: MainScreen 내 네비게이션 전환 애니메이션 제거 * **style: NavHost 전환 애니메이션 비활성화** * `MainScreen`의 `NavHost` 설정에 `enterTransition` 및 `exitTransition`을 `EnterTransition.None`과 `ExitTransition.None`으로 각각 설정하여 화면 전환 시 애니메이션이 발생하지 않도록 수정했습니다. --- .../festabook/presentation/main/component/MainScreen.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index 1c27c4de..d0cae458 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -1,5 +1,7 @@ package com.daedan.festabook.presentation.main.component +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -200,6 +202,8 @@ private fun FestabookNavHost( modifier = modifier, startDestination = navigator.startRoute, navController = navigator.navController, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, ) { homeNavGraph( viewModel = homeViewModel, From 3d0a56f75b705e97b05bc4418ff58c02b37229b8 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:40:55 +0900 Subject: [PATCH 23/54] =?UTF-8?q?[Feat]=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=81=EC=84=B8=EC=97=90=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=ED=98=84=ED=99=A9=20=EC=B6=94=EA=B0=80=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: WaitingUiState sealed interface 추가 Co-Authored-By: Claude Sonnet 4.6 * feat: PlaceDetailViewModel 웨이팅 상태 로드 로직 추가 Co-Authored-By: Claude Sonnet 4.6 * feat: 플레이스 상세 화면 실시간 웨이팅 섹션 및 하단 버튼 UI 추가 Co-Authored-By: Claude Sonnet 4.6 * test: PlaceDetailViewModelTest 웨이팅 상태 테스트 추가 Co-Authored-By: Claude Sonnet 4.6 * feat: PlaceDetailUiModel에 isWaitingActive 필드 추가 Co-Authored-By: Claude Sonnet 4.6 * feat: receivedPlaceDetail 진입 시 웨이팅 상태 로드 추가 Co-Authored-By: Claude Sonnet 4.6 * fix: 플레이스 상세 화면 웨이팅 섹션 순서 수정 (설명 텍스트 이전으로 이동) Co-Authored-By: Claude Sonnet 4.6 * fix: 실시간 웨이팅 섹션 UI 피그마 디자인에 맞게 수정 Co-Authored-By: Claude Sonnet 4.6 * fix: 실시간 웨이팅 섹션 하단 4dp 구분선 추가 Co-Authored-By: Claude Sonnet 4.6 * fix: 4dp 구분선 위치 수정 (PlaceWaitingSection 외부 섹션 구분자로 이동) Co-Authored-By: Claude Sonnet 4.6 * fix: 하단 바 버튼 모양 pill 형태로 수정 (RoundedCornerShape 8dp → 50dp) * fix: 웨이팅 로딩 상태 플레이스홀더 회색 스켈레톤 박스로 변경 * feat: 장소 상세 페이지 내 웨이팅 상태 새로고침 기능 구현 * **feat: 장소 상세 웨이팅 정보 새로고침 기능 추가** * `PlaceDetailScreen`: 웨이팅 상태를 수동으로 갱신할 수 있는 `onWaitingRefresh` 콜백을 추가하고, `PlaceWaitingContent`에 전달했습니다. * `PlaceDetailViewModel`: `loadWaitingStatus` 메서드를 `public`으로 변경하고, 호출 시 현재 `PlaceDetail` 상태를 기반으로 `placeId`와 활성화 여부를 참조하도록 로직을 개선했습니다. * `PlaceDetailViewModel`: 새로고침 시작 시 `WaitingUiState.Loading` 상태를 노출하여 피드백을 제공하도록 수정했습니다. * **refactor: 웨이팅 콘텐츠 컴포넌트 구조 개선 및 스타일 수정** * `PlaceWaitingContent.kt`: `PlaceWaitingContent` 내에서 불필요하게 나뉘어 있던 `when` 분기 로직을 `CurrentWaitingTeams` 내부로 위임하여 코드를 간소화했습니다. * `CurrentWaitingTeams`: 로딩 상태(Loading)일 때 배경 형상을 `festabookShapes.radius1`을 사용하도록 수정하여 디자인 시스템 일관성을 맞췄습니다. * `CurrentWaitingTeams`: 텍스트 레이아웃 및 가독성 개선을 위해 코드 포맷팅을 정리했습니다. * **chore: 불필요한 리소스 및 임포트 제거** * 사용하지 않는 문자열 리소스 `place_detail_waiting_inactive`를 삭제했습니다. * `PlaceDetailViewModel`에서 미사용 임포트(`CancellationException`)를 정리했습니다. * refactor: 장소 상세 화면 웨이팅 상태 분리 및 UI 로직 개선 * **refactor: `WaitingUiState`를 `WaitingTeamUiState`와 `WaitingStatusUiState`로 분리** * 기존 `WaitingUiState`를 대기 팀 정보(`WaitingTeamUiState`)와 웨이팅 활성화 상태(`WaitingStatusUiState`)를 다루는 두 개의 상태 객체로 세분화했습니다. * `WaitingStatusUiState`: `Active`(예상 시간 포함), `Closed`, `Loading` 상태를 정의합니다. * `WaitingTeamUiState`: `Success`(대기 팀 수 포함), `Loading`, `Error` 상태를 정의합니다. * `PlaceDetailUiState.Success`: 위 두 가지 상태를 각각 관리하도록 필드를 업데이트했습니다. * **refactor: `PlaceDetailViewModel` 내 웨이팅 로드 및 갱신 로직 수정** * `refreshWaitingStatus`: 대기 팀 정보(`waitingTeam`)를 명시적으로 로딩 상태로 변경한 뒤 데이터를 다시 가져오도록 수정했습니다. * `loadWaitingStatus`: 초기 로드 시 대기 팀 수와 웨이팅 활성화 상태(Active/Closed)를 동시에 처리하여 UI 상태에 반영하도록 개선했습니다. * API 실패 시 전체 화면 에러가 아닌 개별 상태 업데이트가 가능하도록 예외 처리 로직을 보완했습니다. * **refactor: UI 컴포넌트 내 분리된 웨이팅 상태 적용** * `PlaceWaitingContent`: 대기 팀 정보를 표시하기 위해 `WaitingTeamUiState`를 사용하도록 수정하고, 예외 케이스에 대한 대응 코드를 추가했습니다. * `PlaceDetailBottomBar`: 하단 버튼의 활성화 상태 및 예상 시간 표시를 위해 `WaitingStatusUiState`를 사용하도록 변경했습니다. * `PlaceDetailScreen`: 상태 분리에 따른 파라미터 전달 로직을 수정하고, 하단 바에 웨이팅 등록 클릭 리스너를 추가했습니다. * **style: 코드 정리 및 네이밍 변경** * `loadWaitingStatus`를 `refreshWaitingStatus`로 명칭을 변경하여 의도를 명확히 했습니다. * 불필요한 스페이서 조건부 노출 로직을 정리하고 하단 여백을 고정했습니다. * fix: 웨이팅 상세 정보 에러 핸들링 및 스낵바 알림 추가 * **fix: `PlaceWaitingContent` 내 에러 상태 처리 및 스낵바 연동** * `LaunchedEffect`를 사용하여 `WaitingTeamUiState.Error` 상태 발생 시 `onShowErrorSnackbar` 콜백을 통해 에러 메시지를 표시하도록 로직을 추가했습니다. * `PlaceWaitingContent` 컴포저블의 파라미터에 에러 처리를 위한 `onShowErrorSnackbar` 콜백을 추가했습니다. * **refactor: `PlaceDetailScreen` 내 웨이팅 콘텐츠 인자 전달 수정** * `PlaceDetailScreen`에서 `PlaceWaitingContent`를 호출할 때 현재 컨텍스트의 에러 표시 콜백(`currentOnShowErrorSnackbar`)을 전달하도록 수정했습니다. * test: PlaceDetailViewModelTest를 WaitingTeamUiState / WaitingStatusUiState 분리 구조에 맞게 수정 * refactor: 장소 상세 화면 내 웨이팅 마감 상태의 예상 시간 표시 로직 추가 * **feat: 웨이팅 마감(`Closed`) 상태에서도 예상 대기 시간 표시 기능 추가** * `WaitingStatusUiState.Closed`: `data object`에서 `data class`로 변경하고 `estimatedMinutes` 필드를 추가했습니다. * `PlaceDetailBottomBar.kt`: `WaitingStatusUiState.Closed` 상태일 때도 `EstimatedTime` 컴포넌트가 노출되도록 UI 로직을 추가했습니다. * `PlaceDetailViewModel.kt`: 웨이팅 상태가 비활성(Closed)일 때도 서버로부터 받은 `estimatedWaitTime`을 UI 상태에 반영하도록 수정했습니다. * **refactor: `EstimatedTime` 컴포넌트 범용성 개선** * `EstimatedTime`의 파라미터를 특정 UI 상태(`WaitingStatusUiState.Active`) 대신 일반 `Int` 타입(`estimatedMinutes`)을 받도록 변경하여 여러 상태에서 재사용할 수 있도록 개선했습니다. * 문자열 리소스 호출 시 제목(`place_detail_waiting_estimated_time_title`) 부분의 불필요한 인자 전달을 제거했습니다. * feat: 웨이팅 InActive 상태 추가 * [Refactor] PlaceDetailViewModel 의존성 단순화 및 관련 UI 모델 정리 * **refactor: `PlaceDetailViewModel` 생성자 파라미터 및 로딩 로직 수정** * 기존에 `PlaceUiModel`이나 `PlaceDetailUiModel` 전체를 전달받던 방식에서 `placeId` (Long)만 받도록 변경했습니다. * 이에 따라 뷰모델 생성 시 초기 데이터 존재 여부와 상관없이 항상 서버에서 상세 정보를 로드하도록 로직을 단순화했습니다. * **test: `PlaceDetailViewModelTest` 내 테스트 케이스 수정** * 뷰모델의 생성자 변경 사항을 반영하고, 초기 데이터가 있을 때 서버 요청을 건너뛰던 테스트를 "생성 시마다 플레이스를 로드한다"는 검증 로직으로 업데이트했습니다. * `placeDetailRepository` mock 생성 시 `MockMode.autofill`을 적용했습니다. * **refactor: UI 모델 클래스 내 불필요한 설정 제거** * `ImageUiModel`, `PlaceUiModel`, `PlaceDetailUiModel`, `NoticeUiModel` 등 여러 UI 모델 클래스에서 사용하지 않는 `kotlinx.serialization.Serializable` 어노테이션 및 임포트 구문을 삭제했습니다. * **chore: 불필요한 임포트 및 의존성 정리** * `PlaceWaitingContent.kt`: 사용하지 않는 `CoilImage` 및 Shimmer 관련 임포트를 제거했습니다. * `FestabookRoute.kt`: 더 이상 라우팅 파라미터로 넘기지 않는 `PlaceUiModel`, `PlaceDetailUiModel` 참조를 제거했습니다. * feat: 웨이팅 새로고침 회전 애니메이션 추가 * **feat: 웨이팅 새로고침 시 회전 애니메이션 추가** * `PlaceWaitingContent.kt`: `RefreshButton`에 `Animatable`과 `LaunchedEffect`를 사용하여 `isRefreshing` 상태일 때 무한 회전하고, 정지 시 자연스럽게 정위치로 돌아오는 로직을 구현했습니다. * **refactor: 웨이팅 새로고침 중 기존 데이터 표시** * `WaitingTeamUiState.kt`: `Refresh` 상태가 `totalTeams` 데이터를 가질 수 있도록 `data class`로 변경했습니다. * `PlaceDetailViewModel.kt`: 새로고침 시작 시 기존에 로드된 `totalTeams` 값을 유지하여 `Refresh` 상태로 전달하도록 수정했습니다. * `PlaceWaitingContent.kt`: 새로고침(`Refresh`) 상태일 때 스켈레톤 UI 대신 이전 팀 숫자를 화면에 계속 표시하도록 UI 로직을 개선했습니다. --------- Co-authored-by: Claude Sonnet 4.6 --- .../composeResources/values/strings.xml | 8 + .../common/component/SkeletonBox.kt | 42 +++ .../presentation/main/FestabookRoute.kt | 5 +- .../presentation/main/component/MainScreen.kt | 2 +- .../news/notice/model/NoticeUiModel.kt | 2 - .../placeMap/model/PlaceUiModel.kt | 2 - .../placeMap/navigation/PlaceMapNavigation.kt | 3 +- .../placeDetail/PlaceDetailViewModel.kt | 132 ++++++--- .../component/PlaceDetailBottomBar.kt | 146 ++++++++++ .../component/PlaceDetailScreen.kt | 220 ++++++++++----- .../component/PlaceWaitingContent.kt | 257 ++++++++++++++++++ .../placeDetail/model/ImageUiModel.kt | 2 - .../placeDetail/model/PlaceDetailUiModel.kt | 12 +- .../placeDetail/model/PlaceDetailUiState.kt | 2 + .../placeDetail/model/WaitingStatusUiState.kt | 15 + .../placeDetail/model/WaitingTeamUiState.kt | 19 ++ .../placeDetail/PlaceDetailTestFixture.kt | 7 + .../placeDetail/PlaceDetailViewModelTest.kt | 42 ++- 18 files changed, 788 insertions(+), 130 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SkeletonBox.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailBottomBar.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingStatusUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingTeamUiState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4a467f56..aa87c1fb 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -42,6 +42,14 @@ 공지사항 미지정 미지정 + 실시간 웨이팅 + 예상 대기시간 + 현재 대기 + %1$d팀 + 약 %1$d분 + 웨이팅을 지원하지 않는 부스입니다. + + 웨이팅 등록하기 + 웨이팅 마감 Hello blank fragment diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SkeletonBox.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SkeletonBox.kt new file mode 100644 index 00000000..e7e27c6d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/SkeletonBox.kt @@ -0,0 +1,42 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import com.daedan.festabook.presentation.theme.FestabookColor + +@Composable +fun SkeletonBox(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition() + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + ), + ) + + val brush = + Brush.linearGradient( + colors = + listOf( + FestabookColor.gray500.copy(alpha = 0.6f), + FestabookColor.gray500.copy(alpha = 0.2f), + FestabookColor.gray500.copy(alpha = 0.6f), + ), + start = Offset(translateAnim - 200f, 0f), + end = Offset(translateAnim, 0f), + ) + + Box(modifier = modifier.background(brush)) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index a5f0db8d..6b0c2e75 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -1,7 +1,5 @@ package com.daedan.festabook.presentation.main -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel import kotlinx.serialization.Serializable @Serializable @@ -11,8 +9,7 @@ sealed interface FestabookRoute { @Serializable data class PlaceDetail( - val placeUiModel: PlaceUiModel? = null, - val placeDetailUiModel: PlaceDetailUiModel? = null, + val placeId: Long, ) : FestabookRoute @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index d0cae458..9ae4a5e9 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -165,7 +165,7 @@ fun MainScreen( onStartPlaceDetail = { mainNavigator.navigate( FestabookRoute.PlaceDetail( - placeDetailUiModel = it.placeDetail.value, + placeId = it.placeDetail.value.place.id, ), ) }, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt index c96255cc..d3386014 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt @@ -2,9 +2,7 @@ package com.daedan.festabook.presentation.news.notice.model import com.daedan.festabook.domain.model.Notice import kotlinx.datetime.LocalDateTime -import kotlinx.serialization.Serializable -@Serializable data class NoticeUiModel( val id: Long, val title: String, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt index fd26a1dc..26197e12 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt @@ -1,9 +1,7 @@ package com.daedan.festabook.presentation.placeMap.model import com.daedan.festabook.domain.model.Place -import kotlinx.serialization.Serializable -@Serializable data class PlaceUiModel( val id: Long, val imageUrl: String?, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index ad03d894..0d1ec5b6 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -50,8 +50,7 @@ fun NavGraphBuilder.placeMapNavGraph( assistedMetroViewModel( extras = MutableCreationExtras().apply { - set(PlaceDetailViewModel.PlaceKey, route.placeUiModel) - set(PlaceDetailViewModel.PlaceDetailKey, route.placeDetailUiModel) + set(PlaceDetailViewModel.PlaceIdKey, route.placeId) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt index c1f8709e..c990fb28 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt @@ -3,12 +3,14 @@ package com.daedan.festabook.presentation.placeMap.placeDetail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras +import com.daedan.festabook.domain.model.PlaceWaiting import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.placeDetail.model.ImageUiModel -import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.WaitingStatusUiState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.WaitingTeamUiState import com.daedan.festabook.presentation.placeMap.placeDetail.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted @@ -19,13 +21,14 @@ import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @AssistedInject class PlaceDetailViewModel( private val placeDetailRepository: PlaceDetailRepository, - @Assisted private val place: PlaceUiModel?, - @Assisted private val receivedPlaceDetail: PlaceDetailUiModel?, + private val waitingRegisterInfoRepository: WaitingRegisterInfoRepository, + @Assisted private val placeId: Long, ) : ViewModel() { @AssistedFactory @ViewModelAssistedFactoryKey(PlaceDetailViewModel::class) @@ -33,14 +36,10 @@ class PlaceDetailViewModel( interface Factory : ViewModelAssistedFactory { override fun create(extras: CreationExtras): PlaceDetailViewModel = create( - place = extras[PlaceKey], - receivedPlaceDetail = extras[PlaceDetailKey], + placeId = extras[PlaceIdKey] ?: error("placeId must be required"), ) - fun create( - place: PlaceUiModel?, - receivedPlaceDetail: PlaceDetailUiModel?, - ): PlaceDetailViewModel + fun create(placeId: Long): PlaceDetailViewModel } private val _placeDetail = @@ -50,12 +49,7 @@ class PlaceDetailViewModel( val placeDetail: StateFlow = _placeDetail init { - receivedPlaceDetail?.let { - val placeDetailUiModel = - if (it.images.isEmpty()) it.copy(images = listOf(ImageUiModel())) else it - _placeDetail.value = PlaceDetailUiState.Success(placeDetailUiModel) - } - place?.let { loadPlaceDetail(it.id) } + loadPlaceDetail(placeId) } fun loadPlaceDetail(placeId: Long) { @@ -71,32 +65,108 @@ class PlaceDetailViewModel( } _placeDetail.value = PlaceDetailUiState.Success(placeDetailUiModel) - }.onFailure { - _placeDetail.value = PlaceDetailUiState.Error(it) + loadWaitingStatus() + }.onFailure { throwable -> + _placeDetail.value = PlaceDetailUiState.Error(throwable) } } } + suspend fun refreshWaitingStatus() { + updateInnerState { current -> + val previousTeams = (current.waitingTeam as? WaitingTeamUiState.Success)?.totalTeams ?: 0 + current.copy(waitingTeam = WaitingTeamUiState.Refresh(totalTeams = previousTeams)) + } + val placeDetailState = _placeDetail.value + if (placeDetailState !is PlaceDetailUiState.Success) return + val placeId = placeDetailState.placeDetail.place.id + val waitingResult = waitingRegisterInfoRepository.getPlaceWaiting(placeId) + + waitingResult + .onSuccess { placeWaiting -> + val waitingTeamUiState = + WaitingTeamUiState.Success(totalTeams = placeWaiting.totalWaitingTeams) + + updateInnerState { current -> + current.copy( + waitingTeam = waitingTeamUiState, + ) + } + }.onFailure { throwable -> + updateInnerState { current -> + current.copy( + waitingTeam = WaitingTeamUiState.Error(throwable), + ) + } + } + } + fun toggleNoticeExpanded(notice: NoticeUiModel) { - val currentState = _placeDetail.value - if (currentState !is PlaceDetailUiState.Success) return - _placeDetail.value = - currentState.copy( + _placeDetail.update { current -> + if (current !is PlaceDetailUiState.Success) return@update current + current.copy( placeDetail = - currentState.placeDetail.copy( + current.placeDetail.copy( notices = - currentState.placeDetail.notices.map { - if (notice.id == it.id) { - it.copy(isExpanded = !it.isExpanded) - } else { - it - } + current.placeDetail.notices.map { + if (notice.id == it.id) it.copy(isExpanded = !it.isExpanded) else it }, ), ) + } } - object PlaceKey : CreationExtras.Key + // TODO UseCase 혹은 Domain Layer로 이동, 하지만 PlaceUiModel -> Place로 변환 불가, 아키텍쳐 변화 필요 + private fun PlaceDetailUiState.isWaitingNotSupported(placeWaiting: PlaceWaiting): Boolean = + if (this is PlaceDetailUiState.Success) { + !placeDetail.isWaitingActive && placeWaiting.totalWaitingTeams == 0 + } else { + true + } + + private suspend fun loadWaitingStatus() { + val placeDetailState = _placeDetail.value + if (placeDetailState !is PlaceDetailUiState.Success) return + val placeId = placeDetailState.placeDetail.place.id + val waitingResult = waitingRegisterInfoRepository.getPlaceWaiting(placeId) + val isWaitingActive = placeDetailState.placeDetail.isWaitingActive + + waitingResult + .onSuccess { placeWaiting -> + val isWaitingNotSupported = placeDetailState.isWaitingNotSupported(placeWaiting) + val waitingTeamUiState = + WaitingTeamUiState.Success(totalTeams = placeWaiting.totalWaitingTeams) + + val waitingStatusUiState = + if (isWaitingActive) { + WaitingStatusUiState.Active( + estimatedMinutes = placeWaiting.estimatedWaitTime, + ) + } else { + WaitingStatusUiState.Closed( + estimatedMinutes = placeWaiting.estimatedWaitTime, + ) + } + updateInnerState { current -> + current.copy( + waitingTeam = if (isWaitingNotSupported) WaitingTeamUiState.InActive else waitingTeamUiState, + waitingStatus = if (isWaitingNotSupported) WaitingStatusUiState.InActive else waitingStatusUiState, + ) + } + }.onFailure { throwable -> + _placeDetail.value = PlaceDetailUiState.Error(throwable) + } + } + + private fun updateInnerState(onUpdate: (PlaceDetailUiState.Success) -> PlaceDetailUiState.Success) { + _placeDetail.update { current -> + if (current is PlaceDetailUiState.Success) { + onUpdate(current) + } else { + current + } + } + } - object PlaceDetailKey : CreationExtras.Key + object PlaceIdKey : CreationExtras.Key } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailBottomBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailBottomBar.kt new file mode 100644 index 00000000..d6a6a7e2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailBottomBar.kt @@ -0,0 +1,146 @@ +package com.daedan.festabook.presentation.placeMap.placeDetail.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.placeMap.placeDetail.model.WaitingStatusUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.place_detail_waiting_closed_btn +import festabookkmp.composeapp.generated.resources.place_detail_waiting_estimated_time +import festabookkmp.composeapp.generated.resources.place_detail_waiting_estimated_time_title +import festabookkmp.composeapp.generated.resources.place_detail_waiting_register +import org.jetbrains.compose.resources.stringResource + +@Composable +fun PlaceDetailBottomBar( + waiting: WaitingStatusUiState, + onRegisterWaitingClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .shadow( + elevation = 10.dp, + clip = false, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(FestabookColor.white) + .padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.paddingBody2, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + PlaceDetailBottomBarContent( + waiting = waiting, + onRegisterWaitingClick = onRegisterWaitingClick, + ) + } + } +} + +@Composable +private fun RowScope.PlaceDetailBottomBarContent( + waiting: WaitingStatusUiState, + onRegisterWaitingClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (waiting) { + is WaitingStatusUiState.Active -> { + EstimatedTime(waiting.estimatedMinutes) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody3)) + + Box( + modifier = + modifier + .weight(1f) + .background( + color = FestabookColor.accentBlue, + shape = festabookShapes.radius3, + ).clickable(onClick = onRegisterWaitingClick) + .padding(vertical = festabookSpacing.paddingBody4), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.place_detail_waiting_register), + style = FestabookTypography.displaySmall, + color = FestabookColor.white, + ) + } + } + + is WaitingStatusUiState.Closed -> { + EstimatedTime(waiting.estimatedMinutes) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody3)) + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + color = FestabookColor.gray500, + shape = festabookShapes.radius3, + ).padding(vertical = festabookSpacing.paddingBody4), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.place_detail_waiting_closed_btn), + style = FestabookTypography.displaySmall, + color = FestabookColor.white, + ) + } + } + + else -> { + Unit + } + } +} + +@Composable +private fun EstimatedTime( + estimatedMinutes: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + Text( + text = + stringResource(Res.string.place_detail_waiting_estimated_time_title), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + Text( + text = + stringResource( + Res.string.place_detail_waiting_estimated_time, + estimatedMinutes, + ), + style = FestabookTypography.titleLarge, + color = FestabookColor.black, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt index 013d106b..bc88e36d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt @@ -11,6 +11,7 @@ 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 @@ -32,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -78,6 +80,7 @@ import festabookkmp.composeapp.generated.resources.place_detail_default_time import festabookkmp.composeapp.generated.resources.place_list_default_description import festabookkmp.composeapp.generated.resources.place_list_default_location import festabookkmp.composeapp.generated.resources.place_list_default_title +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -90,11 +93,15 @@ fun PlaceDetailRoute( modifier: Modifier = Modifier, ) { val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() PlaceDetailScreen( modifier = modifier, uiState = placeDetailUiState, onBackToPreviousClick = onBackToPreviousClick, onShowErrorSnackbar = onShowErrorSnackbar, + onWaitingRefresh = { + scope.launch { viewModel.refreshWaitingStatus() } + }, ) } @@ -102,6 +109,7 @@ fun PlaceDetailRoute( fun PlaceDetailScreen( uiState: PlaceDetailUiState, onBackToPreviousClick: () -> Unit, + onWaitingRefresh: () -> Unit, modifier: Modifier = Modifier, onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 ) { @@ -143,24 +151,44 @@ fun PlaceDetailScreen( images = uiState.placeDetail.images, ) - Column( - modifier = - modifier - .fillMaxSize() - .background(color = FestabookColor.white) - .verticalScroll(scrollState), - ) { - PlaceDetailImageContent( - images = uiState.placeDetail.images, - onBackToPreviousClick = onBackToPreviousClick, - onPageUpdate = { pagerState.scrollToPage(it) }, + Box(modifier = modifier.fillMaxSize()) { + Column( modifier = Modifier - .clickable { isDialogOpen = true } - .fillMaxWidth(), - ) + .fillMaxSize() + .background(color = FestabookColor.white) + .verticalScroll(scrollState), + ) { + PlaceDetailImageContent( + images = uiState.placeDetail.images, + onBackToPreviousClick = onBackToPreviousClick, + onPageUpdate = { pagerState.scrollToPage(it) }, + modifier = + Modifier + .clickable { isDialogOpen = true } + .fillMaxWidth(), + ) + + PlaceDetailContent(placeDetail = uiState.placeDetail) + + PlaceWaitingContent( + waiting = uiState.waitingTeam, + onRefresh = onWaitingRefresh, + onShowErrorSnackbar = currentOnShowErrorSnackbar, + ) + + PlaceDetailDescription(placeDetail = uiState.placeDetail) - PlaceDetailContent(placeDetail = uiState.placeDetail) + Spacer(modifier = Modifier.height(80.dp)) + } + + PlaceDetailBottomBar( + waiting = uiState.waitingStatus, + modifier = Modifier.align(Alignment.BottomCenter), + onRegisterWaitingClick = { + // TODO 웨이팅 등록 화면으로 연결 + }, + ) } } @@ -283,8 +311,6 @@ private fun PlaceDetailContent( placeDetail: PlaceDetailUiModel, modifier: Modifier = Modifier, ) { - var isDescriptionExpand by remember { mutableStateOf(true) } - Column( modifier = modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), ) { @@ -300,35 +326,46 @@ private fun PlaceDetailContent( ) PlaceDetailInfo(placeDetail = placeDetail) + } +} - URLText( - modifier = - Modifier - .animateContentSize( - animationSpec = - spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMedium, - ), - ).padding( - top = festabookSpacing.paddingBody3, - ), - onClick = { - isDescriptionExpand = !isDescriptionExpand +@Composable +private fun PlaceDetailDescription( + placeDetail: PlaceDetailUiModel, + modifier: Modifier = Modifier, +) { + var isDescriptionExpand by remember { mutableStateOf(true) } + + URLText( + modifier = + modifier + .animateContentSize( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMedium, + ), + ).padding( + horizontal = festabookSpacing.paddingScreenGutter, + ).padding( + bottom = festabookSpacing.paddingBody3, + ), + onClick = { + isDescriptionExpand = !isDescriptionExpand + }, + text = + placeDetail.place.description + ?: stringResource(Res.string.place_list_default_description), + style = FestabookTypography.bodyMedium, + maxLines = + if (isDescriptionExpand) { + Int.MAX_VALUE + } else { + 1 }, - text = - placeDetail.place.description - ?: stringResource(Res.string.place_list_default_description), - style = FestabookTypography.bodySmall, - maxLines = - if (isDescriptionExpand) { - Int.MAX_VALUE - } else { - 1 - }, - overflow = TextOverflow.Ellipsis, - ) - } + overflow = TextOverflow.Ellipsis, + color = FestabookColor.gray500, + ) } @Composable @@ -382,7 +419,7 @@ private fun PlaceDetailInfoItem( Text( modifier = Modifier.padding(start = festabookSpacing.paddingBody1), text = text, - style = FestabookTypography.bodySmall, + style = FestabookTypography.bodyMedium, color = FestabookColor.gray500, ) } @@ -443,11 +480,12 @@ private fun formattedDate( @Preview(showBackground = true) @Composable -private fun PlaceDetailScreenPreview() { +private fun PlaceDetailScreenActivePreview() { FestabookTheme { PlaceDetailScreen( onBackToPreviousClick = {}, onShowErrorSnackbar = {}, + onWaitingRefresh = {}, uiState = PlaceDetailUiState.Success( placeDetail = @@ -456,32 +494,86 @@ private fun PlaceDetailScreenPreview() { PlaceUiModel( id = 1, imageUrl = null, - title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", - description = - "테스트테스트테스트테스트테스트테스.트테스트.테스트테스트테스트테스트//테스트테스트테스트테스트테스" + - "트테스트테스트테스트http://i1.sndcdn.com/art 트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테" + - "스트테스트테스트테스트https://i.ytimg.com/vi/Wr8egRRLU28/maxresdefault.com테스트테스트테스트테스트" + - "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", - location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + title = "컹과 주점 '코딩하며 한잔'", + description = "테스트 설명입니다.", + location = "테스트 위치", category = PlaceCategoryUiModel.FOOD_TRUCK, isBookmarked = true, timeTagId = listOf(1), ), notices = emptyList(), - host = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + host = "테스트", startTime = "09:00", endTime = "18:00", - images = - listOf( - ImageUiModel( - id = 1, - url = "https://i1.sndcdn.com/artworks-AIxlEDn4gNDBnNJj-qHUnyA-t500x500.jpg", - ), - ImageUiModel( - id = 2, - url = "https://i.ytimg.com/vi/Wr8egRRLU28/maxresdefault.jpg", - ), + images = listOf(ImageUiModel(id = 1, url = "")), + ), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PlaceDetailScreenClosedPreview() { + FestabookTheme { + PlaceDetailScreen( + onBackToPreviousClick = {}, + onShowErrorSnackbar = {}, + onWaitingRefresh = {}, + uiState = + PlaceDetailUiState.Success( + placeDetail = + PlaceDetailUiModel( + place = + PlaceUiModel( + id = 1, + imageUrl = null, + title = "컹과 주점 '코딩하며 한잔'", + description = "테스트 설명입니다.", + location = "테스트 위치", + category = PlaceCategoryUiModel.FOOD_TRUCK, + isBookmarked = true, + timeTagId = listOf(1), ), + notices = emptyList(), + host = "테스트", + startTime = "09:00", + endTime = "18:00", + images = listOf(ImageUiModel(id = 1, url = "")), + ), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PlaceDetailScreenInactivePreview() { + FestabookTheme { + PlaceDetailScreen( + onBackToPreviousClick = {}, + onShowErrorSnackbar = {}, + onWaitingRefresh = {}, + uiState = + PlaceDetailUiState.Success( + placeDetail = + PlaceDetailUiModel( + place = + PlaceUiModel( + id = 1, + imageUrl = null, + title = "컹과 주점 '코딩하며 한잔'", + description = "테스트 설명입니다.", + location = "테스트 위치", + category = PlaceCategoryUiModel.FOOD_TRUCK, + isBookmarked = true, + timeTagId = listOf(1), + ), + notices = emptyList(), + host = "테스트", + startTime = "09:00", + endTime = "18:00", + images = listOf(ImageUiModel(id = 1, url = "")), ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt new file mode 100644 index 00000000..885e78b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt @@ -0,0 +1,257 @@ +package com.daedan.festabook.presentation.placeMap.placeDetail.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.SkeletonBox +import com.daedan.festabook.presentation.placeMap.placeDetail.model.WaitingTeamUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.place_detail_real_time_waiting +import festabookkmp.composeapp.generated.resources.place_detail_waiting_current_teams +import festabookkmp.composeapp.generated.resources.place_detail_waiting_inactive +import festabookkmp.composeapp.generated.resources.place_detail_waiting_teams_count +import org.jetbrains.compose.resources.stringResource + +@Composable +fun PlaceWaitingContent( + waiting: WaitingTeamUiState, + onRefresh: () -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + LaunchedEffect(waiting) { + when (waiting) { + is WaitingTeamUiState.Error -> { + currentOnShowErrorSnackbar(waiting.throwable) + } + + else -> { + Unit + } + } + } + Column( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = festabookSpacing.paddingBody5), + ) { + HorizontalDivider(thickness = 4.dp, color = FestabookColor.gray200) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.paddingBody5, + ), + ) { + Text( + text = stringResource(Res.string.place_detail_real_time_waiting), + style = FestabookTypography.titleSmall, + ) + + when (waiting) { + is WaitingTeamUiState.Loading -> { + SkeletonBox( + modifier = + Modifier + .padding(top = festabookSpacing.paddingBody2) + .fillMaxWidth() + .height(52.dp) + .clip(festabookShapes.radiusFull), + ) + } + + is WaitingTeamUiState.InActive -> { + WaitingNotSupportedContent( + modifier = Modifier.padding(top = festabookSpacing.paddingBody2), + ) + } + + else -> { + CurrentWaitingTeams( + waiting = waiting, + modifier = Modifier.padding(top = festabookSpacing.paddingBody2), + onRefresh = onRefresh, + ) + } + } + } + HorizontalDivider(thickness = 4.dp, color = FestabookColor.gray200) + } +} + +@Composable +private fun WaitingNotSupportedContent(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .fillMaxWidth() + .background(FestabookColor.gray500, festabookShapes.radiusFull) + .padding( + horizontal = 24.dp, + vertical = 18.dp, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.place_detail_waiting_inactive), + style = FestabookTypography.bodyLarge, + color = FestabookColor.white, + ) + } +} + +@Composable +private fun CurrentWaitingTeams( + waiting: WaitingTeamUiState, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .background(FestabookColor.black, festabookShapes.radiusFull) + .padding( + horizontal = 24.dp, + vertical = festabookSpacing.paddingBody4, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.place_detail_waiting_current_teams), + style = FestabookTypography.bodyLarge, + color = FestabookColor.white, + ) + + Spacer(modifier = Modifier.weight(1f)) + + when (waiting) { + is WaitingTeamUiState.Success -> { + Text( + text = + stringResource( + Res.string.place_detail_waiting_teams_count, + waiting.totalTeams, + ), + style = FestabookTypography.displayMedium, + color = FestabookColor.white, + ) + } + + is WaitingTeamUiState.Refresh -> { + Text( + text = + stringResource( + Res.string.place_detail_waiting_teams_count, + waiting.totalTeams, + ), + style = FestabookTypography.displayMedium, + color = FestabookColor.white, + ) + } + + else -> { + Text( + text = "-", + style = FestabookTypography.displayMedium, + color = FestabookColor.white, + ) + } + } + + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody4)) + + RefreshButton( + onClick = onRefresh, + isRefreshing = waiting is WaitingTeamUiState.Refresh, + ) + } +} + +@Composable +private fun RefreshButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + isRefreshing: Boolean = false, +) { + val rotation = remember { Animatable(0f) } + val durationMillis = 500 + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + while (true) { + rotation.animateTo( + targetValue = rotation.value + 360f, + animationSpec = tween(durationMillis = durationMillis, easing = LinearEasing), + ) + } + } else { + val remainder = rotation.value % 360f + if (remainder > 0f) { + val target = rotation.value + (360f - remainder) + val remainingFraction = (360f - remainder) / 360f + rotation.animateTo( + targetValue = target, + animationSpec = + tween( + durationMillis = (remainingFraction * durationMillis).toInt().coerceAtLeast(1), + easing = LinearEasing, + ), + ) + } + } + } + + Box( + modifier = + modifier + .background( + color = FestabookColor.white, + shape = festabookShapes.radiusFull, + ).clickable( + onClick = onClick, + ).padding(festabookSpacing.paddingBody1), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + tint = FestabookColor.black, + modifier = Modifier.size(20.dp).rotate(rotation.value), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/ImageUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/ImageUiModel.kt index 47c71b65..e369b286 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/ImageUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/ImageUiModel.kt @@ -1,9 +1,7 @@ package com.daedan.festabook.presentation.placeMap.placeDetail.model import com.daedan.festabook.domain.model.PlaceDetailImage -import kotlinx.serialization.Serializable -@Serializable data class ImageUiModel( val url: String? = null, val id: Long = -1, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt index e1723089..8cac62bc 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt @@ -7,9 +7,7 @@ import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import kotlinx.datetime.LocalTime import kotlinx.datetime.format.char -import kotlinx.serialization.Serializable -@Serializable data class PlaceDetailUiModel( val place: PlaceUiModel, val notices: List, @@ -17,13 +15,8 @@ data class PlaceDetailUiModel( val startTime: String?, val endTime: String?, val images: List, -) { - val featuredImage: String? - get() = images.firstOrNull()?.url - - val operatingHours: String - get() = "$startTime ~ $endTime" -} + val isWaitingActive: Boolean = false, +) fun PlaceDetail.toUiModel() = PlaceDetailUiModel( @@ -33,6 +26,7 @@ fun PlaceDetail.toUiModel() = startTime = startTime.toFormattedString(), endTime = endTime.toFormattedString(), images = sortedImages.map { it.toUiModel() }, + isWaitingActive = isWaitingActive, ) private fun LocalTime?.toFormattedString(): String? = this?.let { timeFormat.format(it) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiState.kt index 18578ea5..2a0bbfcb 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiState.kt @@ -5,6 +5,8 @@ sealed interface PlaceDetailUiState { data class Success( val placeDetail: PlaceDetailUiModel, + val waitingTeam: WaitingTeamUiState = WaitingTeamUiState.Loading, + val waitingStatus: WaitingStatusUiState = WaitingStatusUiState.Loading, ) : PlaceDetailUiState data class Error( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingStatusUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingStatusUiState.kt new file mode 100644 index 00000000..baba5847 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingStatusUiState.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.placeDetail.model + +sealed interface WaitingStatusUiState { + data object Loading : WaitingStatusUiState + + data object InActive : WaitingStatusUiState + + data class Active( + val estimatedMinutes: Int, + ) : WaitingStatusUiState + + data class Closed( + val estimatedMinutes: Int, + ) : WaitingStatusUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingTeamUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingTeamUiState.kt new file mode 100644 index 00000000..21a7c72d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/WaitingTeamUiState.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.placeDetail.model + +sealed interface WaitingTeamUiState { + data object Loading : WaitingTeamUiState + + data class Refresh( + val totalTeams: Int, + ) : WaitingTeamUiState + + data object InActive : WaitingTeamUiState + + data class Success( + val totalTeams: Int, + ) : WaitingTeamUiState + + data class Error( + val throwable: Throwable, + ) : WaitingTeamUiState +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt index 6fd35fcd..25a7b706 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt @@ -4,6 +4,7 @@ import com.daedan.festabook.domain.model.Place import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.domain.model.PlaceDetail import com.daedan.festabook.domain.model.PlaceDetailImage +import com.daedan.festabook.domain.model.PlaceWaiting import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.news.FAKE_NOTICES import com.daedan.festabook.placeMap.FAKE_PLACES @@ -28,6 +29,12 @@ val FAKE_PLACE_DETAIL = isWaitingActive = true, ) +val FAKE_PLACE_WAITING = + PlaceWaiting( + totalWaitingTeams = 5, + estimatedWaitTime = 30, + ) + val FAKE_ETC_PLACE_DETAIL = PlaceDetail( id = 1, diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt index 89154621..c06bd882 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt @@ -1,13 +1,16 @@ package com.daedan.festabook.placeMap.placeDetail import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository import com.daedan.festabook.news.FAKE_NOTICES import com.daedan.festabook.placeMap.FAKE_PLACES import com.daedan.festabook.presentation.news.notice.model.toUiModel -import com.daedan.festabook.presentation.placeMap.model.toUiModel import com.daedan.festabook.presentation.placeMap.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.WaitingStatusUiState +import com.daedan.festabook.presentation.placeMap.placeDetail.model.WaitingTeamUiState import com.daedan.festabook.presentation.placeMap.placeDetail.model.toUiModel +import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.matcher.any @@ -31,18 +34,26 @@ import kotlin.test.fail class PlaceDetailViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var placeDetailRepository: PlaceDetailRepository + private lateinit var waitingRegisterInfoRepository: WaitingRegisterInfoRepository private lateinit var placeDetailViewModel: PlaceDetailViewModel @BeforeTest fun setup() { Dispatchers.setMain(testDispatcher) placeDetailRepository = mock() + waitingRegisterInfoRepository = mock() everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns Result.success( FAKE_PLACE_DETAIL, ) + everySuspend { waitingRegisterInfoRepository.getPlaceWaiting(any()) } returns + Result.success(FAKE_PLACE_WAITING) placeDetailViewModel = - PlaceDetailViewModel(placeDetailRepository, FAKE_PLACES.first().toUiModel(), null) + PlaceDetailViewModel( + placeDetailRepository, + waitingRegisterInfoRepository, + FAKE_PLACES.first().id, + ) } @AfterTest @@ -64,10 +75,15 @@ class PlaceDetailViewModelTest { advanceUntilIdle() // then - val expected = FAKE_PLACE_DETAIL.toUiModel() + val expected = + PlaceDetailUiState.Success( + placeDetail = FAKE_PLACE_DETAIL.toUiModel(), + waitingTeam = WaitingTeamUiState.Success(totalTeams = FAKE_PLACE_WAITING.totalWaitingTeams), + waitingStatus = WaitingStatusUiState.Active(estimatedMinutes = FAKE_PLACE_WAITING.estimatedWaitTime), + ) val actual = placeDetailViewModel.placeDetail.value verifySuspend { placeDetailRepository.getPlaceDetail(FAKE_PLACES.first().id) } - assertEquals(PlaceDetailUiState.Success(expected), actual) + assertEquals(expected, actual) } @Test @@ -94,23 +110,23 @@ class PlaceDetailViewModelTest { } @Test - fun `처음 뷰모델 생성 시에 플레이스 상세 정보가 있다면 서버에 요청하지 않는다`() = + fun `뷰모델 생성 시마다 플레이스를 로드한다`() = runTest { // given val expected = FAKE_PLACE_DETAIL.toUiModel() - val placeDetailRepository = mock() + val placeDetailRepository = mock(MockMode.autofill) // when placeDetailViewModel = - PlaceDetailViewModel(placeDetailRepository, null, expected) + PlaceDetailViewModel( + placeDetailRepository, + waitingRegisterInfoRepository, + expected.place.id, + ) + advanceUntilIdle() // then - verifySuspend(VerifyMode.exactly(0)) { placeDetailRepository.getPlaceDetail(any()) } - val actual = placeDetailViewModel.placeDetail.value - assertEquals( - PlaceDetailUiState.Success(expected), - actual, - ) + verifySuspend(VerifyMode.exactly(1)) { placeDetailRepository.getPlaceDetail(any()) } } @Test From 0c7407381538263ac416ea7ce5d49eeb918bc1fc Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:48:42 +0900 Subject: [PATCH 24/54] =?UTF-8?q?[Feat]=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=93=B1=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat] 설정 전화번호(WaitingInfo) 등록/수정 기능 구현 (#120) - WaitingInfoViewModel: 전화번호 로드/저장/수정 상태 관리 (Metro DI) - AddWaitingInfoScreen: 전화번호 등록·수정 공용 화면 (미등록 시 빈 입력, 등록 시 pre-fill) - SettingScreen: 등록 정보 섹션 추가 (전화번호 표시 및 클릭 네비게이션) - FestabookRoute: AddWaitingInfo 라우트 추가 - SettingNavigation: AddWaitingInfo 컴포저블 등록 및 WaitingInfoViewModel 연동 Co-Authored-By: Claude Sonnet 4.6 * style: 설정 화면(SettingScreen) UI 스타일 및 레이아웃 개선 * **style: `SettingScreen` 내 섹션 구분선 및 간격 조정** * 섹션 사이의 `HorizontalDivider` 색상을 `gray100`에서 `gray200`으로 변경하고, 두께를 `paddingBody2`에서 `paddingBody1`으로 두껍게 조정했습니다. * 구분선의 수직 패딩(Padding)을 늘려 섹션 간 시각적 여백을 충분히 확보했습니다. * **style: 설정 항목 섹션 타이틀 및 텍스트 스타일 통일** * 웨이팅 정보, 등록 정보, 앱 정보 등 각 섹션 타이틀의 텍스트 스타일을 `bodyMedium`에서 `bodyLarge`로 변경하고, 색상을 `gray500`으로 명시했습니다. * 앱 버전 표시 및 웨이팅 전화번호 텍스트의 스타일을 `bodyLarge` 및 `gray500`으로 업데이트하여 일관성을 높였습니다. * **refactor: 웨이팅 정보 항목 레이아웃 단순화** * `RegistrationInfoContent` 내 전화번호 표시 부분에서 불필요한 `Row`와 화살표 아이콘(`ic_arrow_forward_right`)을 제거했습니다. * 클릭 가능한 영역의 패딩을 `14.dp`로 고정하고 레이아웃 구성을 간소화했습니다. * **style: 등록 정보 및 앱 정보 레이아웃 세부 수정** * 대학교 이름 표시 영역의 하단 패딩을 제거하고 상단 패딩만 유지하도록 수정했습니다. * 앱 버전 정보 항목(`AppVersionInfo`)의 수직 패딩을 `14.dp`로 조정했습니다. * feat: 웨이팅 정보 등록 화면 UI 개선 및 로직 고도화 * **feat: 웨이팅 정보(전화번호) 등록 화면 UI 전면 개편** * `AddWaitingInfoScreen`: 헤드라인, 상세 설명, 약관 동의 체크박스 등을 추가하여 사용자 경험을 개선했습니다. * `CenterAlignedTopAppBar`를 적용하고 디자인 시스템(`festabookShapes`, `festabookSpacing`)에 맞춰 여백 및 컴포넌트 스타일을 조정했습니다. * `HorizontalDivider` 및 전화번호 아이콘 등을 추가하여 시각적 완성도를 높였습니다. * **feat: 웨이팅 정보 등록 시 약관 동의 로직 추가** * `WaitingInfoViewModel`: 필수 약관 동의 여부(`isTermsAgreed`) 상태를 추가했습니다. * `isSaveEnabled`: 전화번호 유효성(숫자 9자리 이상), 약관 동의 여부, 저장 중 상태를 결합하여 저장 버튼 활성화 로직을 강화했습니다. * **refactor: 웨이팅 정보 관련 패키지 구조 변경 및 코드 정리** * 웨이팅 정보 관련 파일들을 `presentation.setting.waitinginfo`에서 `presentation.waitinginfo` 패키지로 이동하여 구조를 재배치했습니다. * `WaitingInfoUiState`를 별도의 파일로 분리하고, 관련 임포트 경로를 모두 업데이트했습니다. * `WaitingInfoViewModel` 내 `loadWaitingInfo` 메서드를 `private`으로 변경하고 초기화 시점에 자동으로 정보를 불러오도록 개선했습니다. * **chore: 웨이팅 정보 관련 다국어 리소스 추가 및 수정** * `strings.xml`: "전화번호 등록", "약관 동의" 등 신규 화면 구성에 필요한 문자열 리소스를 추가하고 기존 문구를 명확하게 수정했습니다. * refactor: Scaffold innerPadding 적용을 통한 네비게이션 화면 레이아웃 개선 * **refactor: 메인 탭 네비게이션 그래프에 `innerPadding` 전달 및 적용** * `MainScreen.kt`: `Scaffold`에서 제공하는 `innerPadding`을 `FestabookNavHost`로 전달하도록 수정했습니다. * `HomeNavigation.kt`, `ScheduleNavigation.kt`, `NewsNavigation.kt`, `SettingNavigation.kt`, `PlaceMapNavigation.kt`: 각 네비게이션 그래프 함수(`navGraph`)에 `innerPadding: PaddingValues` 파라미터를 추가했습니다. * 각 탭의 메인 스크린(`HomeScreen`, `ScheduleScreen`, `NewsScreen`, `SettingRoute`, `PlaceDetailRoute`) 호출 시 `Modifier.padding(innerPadding)`을 적용하여 상하단 바 영역과 겹치지 않도록 개선했습니다. * **style: 웨이팅 정보 추가 화면 레이아웃 설정 최적화** * `AddWaitingInfoScreen.kt`: `Scaffold` 및 `TopAppBar`에서 명시적으로 설정되어 있던 `windowInsets = WindowInsets(0)` 설정을 제거하여 시스템 기본 인셋이 자연스럽게 적용되도록 수정했습니다. * fix: 웨이팅 정보(전화번호) 등록 성공 시 스낵바 알림 추가 및 약관 동의 상태 로직 수정 * **feat: 전화번호 등록 성공 시 스낵바 메시지 표시** * `strings.xml`: 등록 완료 메시지 리소스(`setting_waiting_info_save_success`)를 추가했습니다. * `AddWaitingInfoScreen.kt`: `saveSuccessEvent` 발생 시 `onShowSnackBar`를 호출하여 성공 메시지를 띄우도록 로직을 추가했습니다. * `SettingNavigation.kt`: `AddWaitingInfoRoute` 호출 시 `onShowSnackBar` 콜백을 전달하도록 수정했습니다. * **refactor: 웨이팅 정보 조회 시 약관 동의 상태 동기화** * `WaitingInfoViewModel.kt`: 저장된 웨이팅 정보가 존재할 경우, 화면 진입 시 `isTermsAgreed` 상태를 `true`로 설정하여 사용자 UI 상태를 올바르게 업데이트하도록 수정했습니다. * refactor: WaitingInfo 로드 시점 변경 및 ViewModel 초기화 로직 수정 * **refactor: `WaitingInfoViewModel` 내 자동 데이터 로드 로직 제거 및 접근 제어자 수정** * `init` 블록에서 `loadWaitingInfo()`를 호출하던 로직을 삭제하여 ViewModel 생성 시 자동으로 데이터를 로드하지 않도록 변경했습니다. * UI 단계에서 명시적으로 호출할 수 있도록 `loadWaitingInfo()` 메서드의 접근 제어자를 `private`에서 `public`으로 수정했습니다. * **feat: 화면 진입 시 WaitingInfo 로드 로직 추가** * `SettingScreen.kt`: `LaunchedEffect(Unit)`을 추가하여 설정 화면에 진입할 때 웨이팅 정보를 새로고침하도록 구현했습니다. * `AddWaitingInfoScreen.kt`: `LaunchedEffect(Unit)`을 통해 화면 진입 시 웨이팅 정보를 로드하는 로직을 추가했습니다. * fix: 스낵바 표시 시 키보드 가림 현상 수정 * **style: `MainScreen` 내 `SnackbarHost`에 `imePadding` 적용** * `Scaffold`의 `snackbarHost`에 `Modifier.imePadding()`을 추가하여, 소프트 키보드가 활성화되었을 때 스낵바가 키보드 위로 자동으로 밀려 올라가도록 수정했습니다. * refactor: `WaitingInfoViewModel` 내 휴대폰 번호 입력 로직 및 이벤트 스트림 최적화 * **refactor: `updatePhoneNumber` 메서드 내 입력 제한 로직 수정** * 입력 문자열의 길이가 11자를 초과할 경우 업데이트를 중단하는 가드 클로즈(Guard Clause)를 추가했습니다. * 기존 `take(11)` 대신 전체 입력값에서 숫자만 필터링하도록 변경하여 입력 제어의 정확성을 높였습니다. * **refactor: `SharedFlow` 설정 간소화** * `_saveSuccessEvent` 및 `_errorEvent`에서 불필요한 `replay = 0` 및 `extraBufferCapacity = 1` 설정을 제거하여 기본값으로 단순화했습니다. * refactor: `WaitingInfoViewModel` 내 SharedFlow 버퍼 설정 추가 * **refactor: `saveSuccessEvent` 및 `errorEvent`에 버퍼 용량 설정** * `MutableSharedFlow` 생성 시 `extraBufferCapacity = 1`을 추가하여, 구독자가 없는 상태에서 이벤트가 발생하더라도 최소 1개의 최신 이벤트를 보관할 수 있도록 개선했습니다. * feat: 서비스 이용약관 및 개인정보 처리방침 분리 및 링크 연동 (#120) * **feat: 설정 화면 내 정책 관련 메뉴 세분화 및 URL 업데이트** * `strings.xml`: 기존 `setting_service_policy` 명칭을 "서비스 이용약관"으로 변경하고, `setting_personal_information_policy`("개인정보 처리방침") 항목을 새롭게 추가했습니다. * `SettingScreen.kt`: 정책 URL을 `SERVICE_POLICY_URL`과 `PERSONAL_INFORMATION_POLICY_URL`로 분리하여 업데이트했습니다. * `SettingScreen.kt`: `AppInfoContent` 및 관련 컴포저블에 개인정보 처리방침 버튼과 클릭 핸들러를 추가하여 각 정책 페이지로 연결되도록 구현했습니다. * **feat: 웨이팅 정보 등록 화면 내 약관 동의 링크 추가** * `AddWaitingInfoScreen.kt`: 웨이팅 정보 등록 시 동의가 필요한 약관 URL(`TERMS_URL`) 상수를 추가했습니다. * `AddWaitingInfoScreen.kt`: 약관 동의 텍스트에 밑줄(`Underline`) 스타일을 적용하고, 클릭 시 `LocalUriHandler`를 통해 해당 약관 페이지가 열리도록 수정했습니다. * **refactor: 설정 화면 내 문의하기 URL 업데이트** * `SettingScreen.kt`: 개발자 문의하기 링크(`CONTACT_US_URL`)를 최신 Google Forms 주소로 갱신했습니다. * refactor: 웨이팅 정보 패키지 구조 변경, 화면 전환 앵니메이션, 각 navigation으로 이동 * **refactor: 웨이팅 정보(WaitingInfo) 관련 파일을 `setting` 패키지 하위로 이동** * 웨이팅 정보 관리 기능이 설정 도메인에 종속됨에 따라 관련 파일들의 패키지 경로를 수정했습니다. * `presentation.waitinginfo` -> `presentation.setting.waitinginfo` * `WaitingInfoViewModel`, `WaitingInfoUiState`, `AddWaitingInfoScreen` 등의 위치를 변경하고 관련 임포트 문을 업데이트했습니다. * **style: 메인 탭 네비게이션 전환 애니메이션 제거** * `MainScreen`의 전역 설정으로 되어 있던 전환 애니메이션을 제거하고, 각 개별 탭(`Home`, `News`, `Schedule`, `PlaceMap`, `Setting`)의 `composable` 설정에 `EnterTransition.None` 및 `ExitTransition.None`을 명시적으로 적용했습니다. * 탭 전환 시 시각적 지연 없이 즉각적으로 화면이 전환되도록 개선했습니다. * **chore: 코드 정리 및 경로 최적화** * `MainScreen.kt`, `SettingNavigation.kt` 등에서 변경된 패키지 경로에 맞춰 의존성 참조를 수정했습니다. * refactor: `WaitingInfoViewModel` 내 휴대폰 번호 자릿수 제한 상수화 * **refactor: 휴대폰 번호 유효성 검사 로직에 상수 도입** * 매직 넘버로 관리되던 휴대폰 번호의 최소 자릿수(`9`)와 최대 자릿수(`11`)를 `MIN_DIGIT_COUNT` 및 `MAX_DIGIT_COUNT` 상수로 정의하여 가독성을 높였습니다. * `updatePhoneNumber`: 입력 제한 길이를 `MAX_DIGIT_COUNT` 상수를 사용하도록 수정했습니다. * `isSubmitEnabled`: 등록 가능 여부 판단 시 `MIN_DIGIT_COUNT` 상수를 사용하도록 수정했습니다. --------- Co-authored-by: Claude Sonnet 4.6 --- .../composeResources/values/strings.xml | 18 +- .../home/navigation/HomeNavigation.kt | 12 +- .../presentation/main/FestabookRoute.kt | 3 + .../presentation/main/component/MainScreen.kt | 26 +- .../main/navigation/MainNavigation.kt | 1 + .../news/navigation/NewsNavigation.kt | 12 +- .../placeMap/navigation/PlaceMapNavigation.kt | 20 +- .../schedule/navigation/ScheduleNavigation.kt | 12 +- .../setting/component/SettingScreen.kt | 136 +++++- .../setting/navigation/SettingNavigation.kt | 28 +- .../waitinginfo/WaitingInfoViewModel.kt | 120 ++++++ .../component/AddWaitingInfoScreen.kt | 386 ++++++++++++++++++ .../waitinginfo/model/WaitingInfoUiState.kt | 15 + 13 files changed, 753 insertions(+), 36 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/model/WaitingInfoUiState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index aa87c1fb..16be9930 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -104,11 +104,27 @@ 앱 알림 세부 설정 앱 정보 앱 버전 - 약관 및 정책 + 서비스 이용약관 + 개인정보 처리방침 개발자 문의하기 개발자 문의하기 festabook2025@gmail.com 알림이 설정되었습니다 + 등록 정보 + 전화번호 + 미등록 + 전화번호 + 뒤로가기 + 전화번호 등록 + 1번의 전화번호 등록으로\n바로 웨이팅을 시작할 수 있어요 + 전화번호 + 입력하신 전화번호를 바탕으로 웨이팅이 진행됩니다. + 010-0000-0000 + 전화번호 아이콘 + 약관 동의 + (필수) 개인 정보 수집 및 이용 동의 + 전화번호 등록하기 + 전화번호가 등록되었습니다 공지 아이콘 diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt index f600d9ab..6b8bf3d9 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -1,6 +1,11 @@ package com.daedan.festabook.presentation.home.navigation +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable @@ -13,6 +18,7 @@ import com.daedan.festabook.presentation.main.component.FirstVisitDialog import com.daedan.festabook.presentation.setting.SettingViewModel fun NavGraphBuilder.homeNavGraph( + innerPadding: PaddingValues, viewModel: HomeViewModel, mainViewModel: MainViewModel, settingViewModel: SettingViewModel, @@ -22,7 +28,10 @@ fun NavGraphBuilder.homeNavGraph( onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, ) { - composable { + composable( + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { val isFirstVisit by mainViewModel.isFirstVisit.collectAsStateWithLifecycle() if (isFirstVisit) { FirstVisitDialog( @@ -31,6 +40,7 @@ fun NavGraphBuilder.homeNavGraph( ) } HomeScreen( + modifier = Modifier.padding(innerPadding), viewModel = viewModel, onShowErrorSnackbar = onShowErrorSnackbar, onNavigateToExplore = onNavigateToExplore, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index 6b0c2e75..c30733d3 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -17,6 +17,9 @@ sealed interface FestabookRoute { @Serializable data object Main : FestabookRoute + + @Serializable + data object AddWaitingInfo : FestabookRoute } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index 9ae4a5e9..e3f69a32 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.main.component -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -49,6 +49,7 @@ import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.setting.component.platform.rememberNotificationPermissionManager import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings import com.daedan.festabook.presentation.setting.navigation.settingNavGraph +import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.back_press_exit_message import org.jetbrains.compose.resources.stringResource @@ -67,6 +68,7 @@ fun MainScreen( placeMapViewModel: PlaceMapViewModel, newsViewModel: NewsViewModel, settingViewModel: SettingViewModel, + waitingInfoViewModel: WaitingInfoViewModel, modifier: Modifier = Modifier, ) { val mainNavigator = rememberFestabookNavigator(MainTabRoute.Home) @@ -110,7 +112,10 @@ fun MainScreen( } Scaffold( snackbarHost = { - SnackbarHost(hostState = snackbarHostState) { data -> + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.imePadding(), + ) { data -> FestabookSnackbar(data) } }, @@ -171,13 +176,14 @@ fun MainScreen( }, ) FestabookNavHost( - modifier = Modifier.padding(innerPadding), + innerPadding = innerPadding, festabookNavigator = festabookNavigator, navigator = mainNavigator, mainViewModel = mainViewModel, homeViewModel = homeViewModel, scheduleViewModel = scheduleViewModel, settingViewModel = settingViewModel, + waitingInfoViewModel = waitingInfoViewModel, newsViewModel = newsViewModel, notificationPermissionManager = notificationPermissionManager, snackbarManager = snackbarManager, @@ -187,6 +193,7 @@ fun MainScreen( @Composable private fun FestabookNavHost( + innerPadding: PaddingValues, navigator: FestabookNavigator, festabookNavigator: FestabookNavigator, mainViewModel: MainViewModel, @@ -194,6 +201,7 @@ private fun FestabookNavHost( scheduleViewModel: ScheduleViewModel, newsViewModel: NewsViewModel, settingViewModel: SettingViewModel, + waitingInfoViewModel: WaitingInfoViewModel, notificationPermissionManager: NotificationPermissionManager, snackbarManager: SnackbarManager, modifier: Modifier = Modifier, @@ -202,10 +210,9 @@ private fun FestabookNavHost( modifier = modifier, startDestination = navigator.startRoute, navController = navigator.navController, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, ) { homeNavGraph( + innerPadding = innerPadding, viewModel = homeViewModel, mainViewModel = mainViewModel, onNavigateToExplore = { festabookNavigator.navigate(FestabookRoute.Explore) }, @@ -219,23 +226,30 @@ private fun FestabookNavHost( notificationPermissionManager = notificationPermissionManager, ) scheduleNavGraph( + innerPadding = innerPadding, viewModel = scheduleViewModel, onShowErrorSnackbar = snackbarManager::showError, ) placeMapNavGraph( + innerPadding = innerPadding, onBackToPreviousClick = { navigator.popBackStack() }, onShowErrorSnackbar = snackbarManager::showError, ) newsNavGraph( + innerPadding = innerPadding, viewModel = newsViewModel, onShowErrorSnackbar = snackbarManager::showError, ) settingNavGraph( + innerPadding = innerPadding, homeViewModel = homeViewModel, settingViewModel = settingViewModel, + waitingInfoViewModel = waitingInfoViewModel, notificationPermissionManager = notificationPermissionManager, onShowSnackBar = snackbarManager::show, onShowErrorSnackBar = snackbarManager::showError, + onNavigateToAddWaitingInfo = { navigator.navigate(FestabookRoute.AddWaitingInfo) }, + onBackClick = { navigator.popBackStack() }, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt index 4dbb4feb..f620f701 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt @@ -27,6 +27,7 @@ fun NavGraphBuilder.mainNavGraph( scheduleViewModel = metroViewModel(mainBackEntry), placeMapViewModel = metroViewModel(mainBackEntry), settingViewModel = metroViewModel(mainBackEntry), + waitingInfoViewModel = metroViewModel(mainBackEntry), mainViewModel = metroViewModel(mainBackEntry), newsViewModel = metroViewModel(mainBackEntry), ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt index ee34b28f..a71c26bb 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt @@ -1,5 +1,10 @@ package com.daedan.festabook.presentation.news.navigation +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.daedan.festabook.presentation.main.MainTabRoute @@ -7,11 +12,16 @@ import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsScreen fun NavGraphBuilder.newsNavGraph( + innerPadding: PaddingValues, viewModel: NewsViewModel, onShowErrorSnackbar: (Throwable) -> Unit, ) { - composable { + composable( + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { NewsScreen( + modifier = Modifier.padding(innerPadding), newsViewModel = viewModel, onShowErrorSnackbar = onShowErrorSnackbar, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index 0d1ec5b6..052ae43e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -1,9 +1,13 @@ package com.daedan.festabook.presentation.placeMap.navigation +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer @@ -26,10 +30,14 @@ import kotlinx.serialization.json.Json import kotlin.reflect.typeOf fun NavGraphBuilder.placeMapNavGraph( + innerPadding: PaddingValues, onBackToPreviousClick: () -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, ) { - composable { + composable( + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { } composable( @@ -56,10 +64,12 @@ fun NavGraphBuilder.placeMapNavGraph( PlaceDetailRoute( modifier = - Modifier.graphicsLayer( - compositingStrategy = CompositingStrategy.Offscreen, - clip = true, - ), + Modifier + .padding(innerPadding) + .graphicsLayer( + compositingStrategy = CompositingStrategy.Offscreen, + clip = true, + ), viewModel = viewModel, onBackToPreviousClick = onBackToPreviousClick, onShowErrorSnackbar = onShowErrorSnackbar, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt index 6caad10f..b2bf7ebb 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt @@ -1,5 +1,10 @@ package com.daedan.festabook.presentation.schedule.navigation +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.daedan.festabook.presentation.main.MainTabRoute @@ -7,11 +12,16 @@ import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.component.ScheduleScreen fun NavGraphBuilder.scheduleNavGraph( + innerPadding: PaddingValues, viewModel: ScheduleViewModel, onShowErrorSnackbar: (Throwable) -> Unit, ) { - composable { + composable( + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { ScheduleScreen( + modifier = Modifier.padding(innerPadding), scheduleViewModel = viewModel, onShowErrorSnackbar = onShowErrorSnackbar, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 90176128..c571ca87 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.BuildKonfig @@ -40,6 +41,8 @@ import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel +import com.daedan.festabook.presentation.setting.waitinginfo.model.WaitingInfoUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography @@ -53,15 +56,22 @@ import festabookkmp.composeapp.generated.resources.setting_contact_us import festabookkmp.composeapp.generated.resources.setting_current_university_notice import festabookkmp.composeapp.generated.resources.setting_notice_enabled import festabookkmp.composeapp.generated.resources.setting_notice_title +import festabookkmp.composeapp.generated.resources.setting_personal_information_policy import festabookkmp.composeapp.generated.resources.setting_service_policy import festabookkmp.composeapp.generated.resources.setting_title +import festabookkmp.composeapp.generated.resources.setting_waiting_info_not_registered +import festabookkmp.composeapp.generated.resources.setting_waiting_info_phone_number +import festabookkmp.composeapp.generated.resources.setting_waiting_info_section_title import kotlinx.datetime.LocalDate import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview -private const val POLICY_URL: String = - "https://www.notion.so/244a540dc0b780638e56e31c4bdb3c9f" +private const val SERVICE_POLICY_URL: String = + "https://www.notion.so/festabook-2026-04-01-335a540dc0b78055a450d7c82a1bdd40?source=copy_link" + +private const val PERSONAL_INFORMATION_POLICY_URL: String = + "https://www.notion.so/festabook-2026-04-01-335a540dc0b780fdb79cc83372595721?source=copy_link" private const val CONTACT_US_URL = "https://forms.gle/XjqJFfQrTPgkZzGZ9" @@ -73,11 +83,14 @@ fun SettingRoute( onShowErrorSnackBar: (Throwable) -> Unit, settingViewModel: SettingViewModel, homeViewModel: HomeViewModel, + waitingInfoViewModel: WaitingInfoViewModel, + onPhoneNumberClick: () -> Unit, modifier: Modifier = Modifier, ) { val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() val isSubscribedLoading by settingViewModel.isLoading.collectAsStateWithLifecycle() + val waitingInfoUiState by waitingInfoViewModel.waitingInfoUiState.collectAsStateWithLifecycle() val uriHandler = LocalUriHandler.current val enableMessage = stringResource(Res.string.setting_notice_enabled) @@ -106,6 +119,11 @@ fun SettingRoute( ObserveAsEvents(flow = settingViewModel.error) { onShowErrorSnackBar(it) } + + LaunchedEffect(Unit) { + waitingInfoViewModel.loadWaitingInfo() + } + if (showPermissionDialog) { NotificationPermissionDialog( onConfirm = { @@ -114,21 +132,23 @@ fun SettingRoute( }, ) } + + val phoneNumber = (waitingInfoUiState as? WaitingInfoUiState.Registered)?.phoneNumber + SettingScreen( modifier = modifier, festivalUiState = festival, isUniversitySubscribed = isUniversitySubscribed, appVersion = "v ${BuildKonfig.APP_VERSION_NAME}", isSubscribeEnabled = !isSubscribedLoading, + phoneNumber = phoneNumber, onSubscribeClick = { settingViewModel.notificationAllowClick() }, - onPolicyClick = { uriHandler.openUri(POLICY_URL) }, + onServicePolicyClick = { uriHandler.openUri(SERVICE_POLICY_URL) }, + onPersonalInformationPolicyClick = { uriHandler.openUri(PERSONAL_INFORMATION_POLICY_URL) }, onContactUsClick = { uriHandler.openUri(CONTACT_US_URL) }, + onPhoneNumberClick = onPhoneNumberClick, onError = { onShowErrorSnackBar(it.throwable) -// Timber.w( -// it.throwable, -// "${"SettingRoute"}: ${it.throwable.message}", -// ) }, ) } @@ -139,10 +159,13 @@ fun SettingScreen( isUniversitySubscribed: Boolean, appVersion: String, isSubscribeEnabled: Boolean, + phoneNumber: String?, modifier: Modifier = Modifier, onSubscribeClick: (Boolean) -> Unit = {}, - onPolicyClick: () -> Unit = {}, + onServicePolicyClick: () -> Unit = {}, + onPersonalInformationPolicyClick: () -> Unit = {}, onContactUsClick: () -> Unit = {}, + onPhoneNumberClick: () -> Unit = {}, onError: (FestivalUiState.Error) -> Unit = {}, ) { val windowInfo = LocalWindowInfo.current @@ -196,20 +219,83 @@ fun SettingScreen( modifier = Modifier .requiredWidth(screenWidthDp) - .padding(vertical = 20.dp), - color = FestabookColor.gray100, - thickness = festabookSpacing.paddingBody2, + .padding(vertical = 28.dp), + color = FestabookColor.gray200, + thickness = festabookSpacing.paddingBody1, + ) + + RegistrationInfoContent( + phoneNumber = phoneNumber, + onPhoneNumberClick = onPhoneNumberClick, + screenWidthDp = screenWidthDp, + ) + + HorizontalDivider( + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding( + top = 16.dp, + bottom = 28.dp, + ), + color = FestabookColor.gray200, + thickness = festabookSpacing.paddingBody1, ) AppInfoContent( appVersion = appVersion, - onPolicyClick = onPolicyClick, + onServicePolicyClick = onServicePolicyClick, + onPersonalInformationPolicyClick = onPersonalInformationPolicyClick, onContactUsClick = onContactUsClick, ) } } } +@Composable +private fun RegistrationInfoContent( + phoneNumber: String?, + onPhoneNumberClick: () -> Unit, + screenWidthDp: Dp, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(Res.string.setting_waiting_info_section_title), + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + ) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .requiredWidth(screenWidthDp) + .clickable { onPhoneNumberClick() } + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .padding( + vertical = 14.dp, + ), + ) { + Text( + text = stringResource(Res.string.setting_waiting_info_phone_number), + style = FestabookTypography.titleMedium, + ) + + Text( + text = + phoneNumber + ?: stringResource(Res.string.setting_waiting_info_not_registered), + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun SubscriptionContent( universityName: String, @@ -221,7 +307,8 @@ private fun SubscriptionContent( Column(modifier = modifier) { Text( text = stringResource(Res.string.setting_notice_title), - style = FestabookTypography.bodyMedium, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, modifier = Modifier.padding(top = 20.dp), ) @@ -246,7 +333,7 @@ private fun SubscriptionContent( Text( text = universityName, style = FestabookTypography.bodyMedium, - modifier = Modifier.padding(vertical = festabookSpacing.paddingBody1), + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), maxLines = 1, overflow = TextOverflow.Ellipsis, color = FestabookColor.gray500, @@ -265,15 +352,16 @@ private fun SubscriptionContent( @Composable private fun AppInfoContent( appVersion: String, - onPolicyClick: () -> Unit, + onServicePolicyClick: () -> Unit, + onPersonalInformationPolicyClick: () -> Unit, onContactUsClick: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Text( text = stringResource(Res.string.setting_app_info_title), - modifier = Modifier.padding(vertical = festabookSpacing.paddingBody3), - style = FestabookTypography.bodyMedium, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, ) AppVersionInfo( @@ -282,8 +370,14 @@ private fun AppInfoContent( AppInfoButton( text = stringResource(Res.string.setting_service_policy), - onClick = onPolicyClick, + onClick = onServicePolicyClick, ) + + AppInfoButton( + text = stringResource(Res.string.setting_personal_information_policy), + onClick = onPersonalInformationPolicyClick, + ) + AppInfoButton( text = stringResource(Res.string.setting_contact_us), onClick = onContactUsClick, @@ -302,7 +396,7 @@ private fun AppVersionInfo( modifier = modifier .fillMaxWidth() - .padding(vertical = festabookSpacing.paddingBody3), + .padding(vertical = 14.dp), ) { Text( text = stringResource(Res.string.setting_app_version), @@ -311,7 +405,8 @@ private fun AppVersionInfo( Text( text = appVersion, - style = FestabookTypography.bodyMedium, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, ) } } @@ -381,6 +476,7 @@ private fun SettingScreenPreview() { onSubscribeClick = { isSubscribed = !isSubscribed }, appVersion = "v1.0.0", isSubscribeEnabled = true, + phoneNumber = null, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt index a7ab51fc..9ae23d3d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -1,27 +1,53 @@ package com.daedan.festabook.presentation.setting.navigation +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.MainTabRoute import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.setting.component.SettingRoute +import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel +import com.daedan.festabook.presentation.setting.waitinginfo.component.AddWaitingInfoRoute fun NavGraphBuilder.settingNavGraph( + innerPadding: PaddingValues, homeViewModel: HomeViewModel, settingViewModel: SettingViewModel, + waitingInfoViewModel: WaitingInfoViewModel, notificationPermissionManager: NotificationPermissionManager, onShowSnackBar: (String) -> Unit, onShowErrorSnackBar: (Throwable) -> Unit, + onNavigateToAddWaitingInfo: () -> Unit, + onBackClick: () -> Unit, ) { - composable { + composable( + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { SettingRoute( + modifier = Modifier.padding(innerPadding), homeViewModel = homeViewModel, settingViewModel = settingViewModel, + waitingInfoViewModel = waitingInfoViewModel, notificationPermissionManager = notificationPermissionManager, onShowSnackBar = onShowSnackBar, onShowErrorSnackBar = onShowErrorSnackBar, + onPhoneNumberClick = onNavigateToAddWaitingInfo, + ) + } + composable { + AddWaitingInfoRoute( + viewModel = waitingInfoViewModel, + onBackClick = onBackClick, + onShowSnackBar = onShowSnackBar, + onShowErrorSnackBar = onShowErrorSnackBar, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt new file mode 100644 index 00000000..05db1d1f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt @@ -0,0 +1,120 @@ +package com.daedan.festabook.presentation.setting.waitinginfo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.model.WaitingInfo +import com.daedan.festabook.domain.repository.WaitingInfoRepository +import com.daedan.festabook.presentation.setting.waitinginfo.model.WaitingInfoUiState +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@ContributesIntoMap(AppScope::class) +@ViewModelKey(WaitingInfoViewModel::class) +@Inject +class WaitingInfoViewModel( + private val waitingInfoRepository: WaitingInfoRepository, +) : ViewModel() { + private val _waitingInfoUiState = + MutableStateFlow(WaitingInfoUiState.Loading) + val waitingInfoUiState: StateFlow = _waitingInfoUiState.asStateFlow() + + private val _phoneNumber = MutableStateFlow("") + val phoneNumber: StateFlow = _phoneNumber.asStateFlow() + + private val _isTermsAgreed = MutableStateFlow(false) + val isTermsAgreed: StateFlow = _isTermsAgreed.asStateFlow() + + private val isSaving = MutableStateFlow(false) + + val isSaveEnabled: StateFlow = + combine( + _phoneNumber, + _isTermsAgreed, + isSaving, + ) { phone, terms, saving -> + phone.count { it.isDigit() } >= MIN_DIGIT_COUNT && terms && !saving + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false, + ) + + private val _saveSuccessEvent = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val saveSuccessEvent: SharedFlow = _saveSuccessEvent.asSharedFlow() + + private val _errorEvent = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val errorEvent: SharedFlow = _errorEvent.asSharedFlow() + + fun updatePhoneNumber(input: String) { + if (input.count() > MAX_DIGIT_COUNT) return + _phoneNumber.value = input.filter { it.isDigit() } + } + + fun setTermsAgreed(agreed: Boolean) { + _isTermsAgreed.value = agreed + } + + fun saveWaitingInfo(phoneNumber: String) { + if (isSaving.value) return + val currentState = _waitingInfoUiState.value + if (currentState is WaitingInfoUiState.Loading) return + + viewModelScope.launch { + isSaving.value = true + val result = + if (currentState is WaitingInfoUiState.Registered) { + waitingInfoRepository.updateWaitingInfo(WaitingInfo(phoneNumber)) + } else { + waitingInfoRepository.saveWaitingInfo(WaitingInfo(phoneNumber)) + } + result + .onSuccess { + // 낙관적 업데이트: reload 없이 직접 상태 반영 + _waitingInfoUiState.value = WaitingInfoUiState.Registered(phoneNumber) + _saveSuccessEvent.emit(Unit) + }.onFailure { throwable -> + _errorEvent.emit(throwable) + }.also { + isSaving.value = false + } + } + } + + fun loadWaitingInfo() { + viewModelScope.launch { + _waitingInfoUiState.value = WaitingInfoUiState.Loading + waitingInfoRepository + .getWaitingInfo() + .onSuccess { waitingInfo -> + if (waitingInfo != null) { + _phoneNumber.value = waitingInfo.phoneNumber + _isTermsAgreed.value = true + _waitingInfoUiState.value = + WaitingInfoUiState.Registered(waitingInfo.phoneNumber) + } else { + _waitingInfoUiState.value = WaitingInfoUiState.NotRegistered + } + }.onFailure { throwable -> + _waitingInfoUiState.value = WaitingInfoUiState.Error(throwable) + _errorEvent.emit(throwable) + } + } + } + + companion object { + private const val MAX_DIGIT_COUNT = 11 + private const val MIN_DIGIT_COUNT = 9 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt new file mode 100644 index 00000000..5733fe7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt @@ -0,0 +1,386 @@ +package com.daedan.festabook.presentation.setting.waitinginfo.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.ErrorStateScreen +import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel +import com.daedan.festabook.presentation.setting.waitinginfo.model.WaitingInfoUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.ic_arrow_back +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_button +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_description +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_headline +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_hint +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_label +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_phone_icon +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_terms_item +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_terms_title +import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_title +import festabookkmp.composeapp.generated.resources.setting_waiting_info_back +import festabookkmp.composeapp.generated.resources.setting_waiting_info_save_success +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +private const val TERMS_URL = + "https://www.notion.so/2026-04-01-335a540dc0b780fa9897e0a3bdf45bae" + +@Composable +fun AddWaitingInfoRoute( + viewModel: WaitingInfoViewModel, + onBackClick: () -> Unit, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val waitingInfoUiState by viewModel.waitingInfoUiState.collectAsStateWithLifecycle() + val phoneNumber by viewModel.phoneNumber.collectAsStateWithLifecycle() + val isTermsAgreed by viewModel.isTermsAgreed.collectAsStateWithLifecycle() + val isSaveEnabled by viewModel.isSaveEnabled.collectAsStateWithLifecycle() + val saveSuccessMessage = stringResource(Res.string.setting_waiting_info_save_success) + + ObserveAsEvents(flow = viewModel.saveSuccessEvent) { + onShowSnackBar(saveSuccessMessage) + onBackClick() + } + ObserveAsEvents(flow = viewModel.errorEvent) { + onShowErrorSnackBar(it) + } + + LaunchedEffect(Unit) { + viewModel.loadWaitingInfo() + } + + when (waitingInfoUiState) { + is WaitingInfoUiState.Error -> { + ErrorStateScreen( + modifier = modifier, + ) + } + + else -> { + AddWaitingInfoScreen( + modifier = modifier, + phoneNumber = phoneNumber, + isTermsAgreed = isTermsAgreed, + isSaveEnabled = isSaveEnabled, + onPhoneNumberChange = viewModel::updatePhoneNumber, + onTermsAgreedChange = viewModel::setTermsAgreed, + onSaveClick = viewModel::saveWaitingInfo, + onBackClick = onBackClick, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddWaitingInfoScreen( + phoneNumber: String, + isTermsAgreed: Boolean, + isSaveEnabled: Boolean, + onPhoneNumberChange: (String) -> Unit, + onTermsAgreedChange: (Boolean) -> Unit, + onSaveClick: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(Res.string.setting_waiting_info_add_title), + style = FestabookTypography.titleMedium, + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = stringResource(Res.string.setting_waiting_info_back), + tint = FestabookColor.black, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = FestabookColor.white, + ), + ) + }, + modifier = modifier, + containerColor = FestabookColor.white, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .background(FestabookColor.white) + .padding(innerPadding) + .imePadding(), + ) { + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody5)) + Text( + text = stringResource(Res.string.setting_waiting_info_add_headline), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + Spacer(modifier = Modifier.height(40.dp)) + + HorizontalDivider( + color = FestabookColor.gray200, + thickness = festabookSpacing.paddingBody1, + ) + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = festabookSpacing.paddingScreenGutter), + ) { + AddPhoneNumberContent( + phoneNumber = phoneNumber, + onPhoneNumberChange = onPhoneNumberChange, + modifier = Modifier.padding(vertical = 28.dp), + ) + + HorizontalDivider( + color = FestabookColor.gray200, + thickness = festabookSpacing.paddingBody1, + modifier = Modifier.requiredWidth(screenWidthDp), + ) + + Spacer(modifier = Modifier.height(28.dp)) + + WaitingInfoAddTerms( + isTermsAgreed = isTermsAgreed, + onTermsAgreedChange = onTermsAgreedChange, + ) + + Spacer(modifier = Modifier.weight(1f)) + + ConfirmButton( + phoneNumber = phoneNumber, + isSaveEnabled = isSaveEnabled, + onSaveClick = onSaveClick, + ) + } + } + } +} + +@Composable +private fun AddPhoneNumberContent( + phoneNumber: String, + onPhoneNumberChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(Res.string.setting_waiting_info_add_label), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody1)) + Text( + text = stringResource(Res.string.setting_waiting_info_add_description), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray400, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody3)) + + OutlinedTextField( + value = phoneNumber, + onValueChange = onPhoneNumberChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(Res.string.setting_waiting_info_add_hint), + style = FestabookTypography.titleMedium, + color = FestabookColor.gray400, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Phone, + contentDescription = stringResource(Res.string.setting_waiting_info_add_phone_icon), + tint = FestabookColor.gray400, + ) + }, + textStyle = FestabookTypography.titleMedium, + singleLine = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Done, + ), + shape = festabookShapes.radius2, + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = FestabookColor.gray100, + unfocusedContainerColor = FestabookColor.gray100, + focusedBorderColor = FestabookColor.black, + unfocusedBorderColor = FestabookColor.gray200, + focusedTextColor = FestabookColor.black, + unfocusedTextColor = FestabookColor.black, + cursorColor = FestabookColor.black, + ), + ) + } +} + +@Composable +private fun WaitingInfoAddTerms( + isTermsAgreed: Boolean, + onTermsAgreedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(Res.string.setting_waiting_info_add_terms_title), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody1)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + modifier = Modifier.clip(festabookShapes.radius1), + checked = isTermsAgreed, + onCheckedChange = onTermsAgreedChange, + colors = + CheckboxDefaults.colors( + checkedColor = FestabookColor.black, + uncheckedColor = FestabookColor.gray400, + checkmarkColor = FestabookColor.white, + ), + ) + val uriHandler = LocalUriHandler.current + Text( + text = stringResource(Res.string.setting_waiting_info_add_terms_item), + style = FestabookTypography.bodyMedium.copy(textDecoration = TextDecoration.Underline), + color = FestabookColor.gray600, + modifier = + Modifier.clickable { + uriHandler.openUri(TERMS_URL) + }, + ) + } + } +} + +@Composable +private fun ConfirmButton( + phoneNumber: String, + isSaveEnabled: Boolean, + onSaveClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = { onSaveClick(phoneNumber) }, + enabled = isSaveEnabled, + modifier = + modifier + .fillMaxWidth() + .padding(bottom = festabookSpacing.paddingBody5), + shape = festabookShapes.radius3, + colors = + ButtonDefaults.buttonColors( + containerColor = FestabookColor.black, + contentColor = FestabookColor.white, + disabledContainerColor = FestabookColor.gray200, + disabledContentColor = FestabookColor.gray400, + ), + ) { + Text( + text = stringResource(Res.string.setting_waiting_info_add_button), + style = FestabookTypography.displaySmall, + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody2), + ) + } +} + +@Preview +@Composable +private fun AddWaitingInfoScreenEmptyPreview() { + AddWaitingInfoScreen( + phoneNumber = "", + isTermsAgreed = false, + isSaveEnabled = false, + onPhoneNumberChange = {}, + onTermsAgreedChange = {}, + onSaveClick = {}, + onBackClick = {}, + ) +} + +@Preview +@Composable +private fun AddWaitingInfoScreenFilledPreview() { + AddWaitingInfoScreen( + phoneNumber = "010-1234-5678", + isTermsAgreed = true, + isSaveEnabled = true, + onPhoneNumberChange = {}, + onTermsAgreedChange = {}, + onSaveClick = {}, + onBackClick = {}, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/model/WaitingInfoUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/model/WaitingInfoUiState.kt new file mode 100644 index 00000000..c46feac3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/model/WaitingInfoUiState.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.setting.waitinginfo.model + +sealed interface WaitingInfoUiState { + data object Loading : WaitingInfoUiState + + data class Registered( + val phoneNumber: String, + ) : WaitingInfoUiState + + data object NotRegistered : WaitingInfoUiState + + data class Error( + val throwable: Throwable, + ) : WaitingInfoUiState +} From 6068ca48d8dca1337b85265199bdc06735255d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sat, 18 Apr 2026 22:01:59 +0900 Subject: [PATCH 25/54] =?UTF-8?q?[Fix]=20=EA=B3=B5=EC=A7=80=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20FCM=20=EC=95=8C=EB=A6=BC=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: MainViewModel 탐색 이벤트 처리 방식 변경 및 딥링크 중복 방지 * **refactor: `MainViewModel` 내 일회성 이벤트 처리 로직 개선** * `navigateNewsEvent`를 기존 `MutableSharedFlow` 방식에서 `Channel` 기반의 `Flow`로 변경하여 뉴스 이동 이벤트가 한 번만 소비되도록 수정했습니다. * `navigateToNews` 메서드 내 이벤트 발행 로직을 `tryEmit`에서 `trySend`로 변경했습니다. * **fix: 딥링크 처리 후 Intent Extra 제거 로직 추가** * `RememberDeepLinkHandler.android.kt`에서 딥링크 이벤트를 처리한 직후, 사용된 Intent Extra(`KEY_CAN_NAVIGATE_TO_NEWS`, `KEY_NOTICE_ID_TO_EXPAND`)를 제거하는 로직을 추가했습니다. * 이는 액티비티가 재생성되거나 새로운 인텐트를 받을 때 동일한 딥링크가 중복으로 처리되는 현상을 방지합니다. * style: 코드 포맷팅 * [Fix] iOS FCM 알림 클릭 시 데이터 유실 문제 해결 및 딥링크 연동 개선 * **feat: iOS FCM 알림 처리를 위한 `PendingFcmNotification` 추가** * `NSNotification` 기반의 휘발성 데이터 전달 방식을 `Channel` 기반의 `PendingFcmNotification` 오브젝트로 교체했습니다. * 이를 통해 앱이 실행되지 않은 상태(Cold Start)에서 알림을 클릭하더라도 Compose UI 준비 후 데이터를 안전하게 수신할 수 있도록 개선했습니다. * **refactor: `RememberDeepLinkHandler` 구조 및 네비게이션 로직 수정** * `RememberDeepLinkHandler`에 `onFestivalChanged` 콜백을 추가하여 축제 ID 변경 상황을 처리할 수 있도록 인터페이스를 확장했습니다. * `FestabookRoute.Main`에 `pendingAnnouncementId` 파라미터를 추가하여 네비게이션 시 특정 공지사항으로 즉시 이동할 수 있는 기반을 마련했습니다. * `MainScreen`에서 `pendingAnnouncementId` 변경 감지 시 `NewsViewModel`을 통해 해당 공지를 확장하고 뉴스 탭으로 이동하는 로직을 구현했습니다. * **refactor: iOS 알림 데리게이트 및 핸들러 로직 개선** * `DefaultUserNotificationDelegate`에서 알림 수신 시 현재 축제 ID와 비교하여 변경 여부를 판단하고, `PendingFcmNotification`에 데이터를 저장하도록 수정했습니다. * `RememberDeepLinkHandler.ios.kt`에서 `NSNotificationCenter` 대신 `PendingFcmNotification`의 Flow를 구독하여 딥링크 및 축제 변경 이벤트를 처리하도록 변경했습니다. * **chore: 기타 변경 사항** * `FestabookScreen.kt` 내 `Main` 경로 이동 시 불필요한 인자 제거 및 기본 생성자 호출 방식으로 정리했습니다. * `DeepLinkKeys`에서 더 이상 사용하지 않는 `KEY_FCM_NOTIFICATION` 상수를 제거했습니다. * refactor: DefaultUserNotificationDelegate 내 페스티벌 ID 저장 로직 개선 * **refactor: 알림 수신 시 페스티벌 ID 처리 로직 수정** * `festivalLocalDataSource.getFestivalId()` 호출 시 `first()` 대신 `firstOrNull()`을 사용하여 보다 안전하게 현재 저장된 ID를 조회하도록 변경했습니다. * 기존에는 유효한 ID인 경우 무조건 저장하던 방식에서, 현재 저장된 ID와 비교하여 값이 변경된 경우(`festivalIdChanged`)에만 저장하도록 로직을 개선했습니다. * 수신된 ID를 명확히 구분하기 위해 변수명을 `festivalId`에서 `newFestivalId`로 변경했습니다. * refactor: 딥링크 및 FCM 알림 핸들링 로직 통합 및 간소화 * **refactor: `RememberDeepLinkHandler` 인터페이스 통합** * 기존 `onFestivalChanged`와 `onDeepLink`로 분리되어 있던 콜백을 `onNotificationClicked` 하나로 통합하여 공통 인터페이스를 단순화했습니다. * 딥링크 처리에 필요한 `announcementId`와 `festivalIdChanged` 여부를 한 번에 전달하도록 변경했습니다. * **refactor: Android 플랫폼 핸들러 구현 수정** * `Intent`에서 딥링크 정보를 추출하는 로직을 `handleDeepLink` 확장 함수로 분리했습니다. * `LaunchedEffect`와 `onNewIntentListener`에서 중복되던 처리 로직을 통합했습니다. * **refactor: iOS 플랫폼 핸들러 구현 수정** * `PendingFcmNotification` 데이터를 수신할 때 불필요한 `Intent` 객체 생성 과정을 제거하고 통합된 콜백을 사용하도록 수정했습니다. * **refactor: `MainScreen` 내 네비게이션 로직 정리** * `NewsViewModel`과 `MainViewModel`을 조작하여 소식 화면으로 이동하는 로직을 `navigateToNewsScreen` 함수로 추출하여 재사용성을 높였습니다. * `RememberDeepLinkHandler`의 변경된 인터페이스에 맞춰 딥링크 수신 시의 네비게이션 분기 로직을 간소화했습니다. * refactor: 사용하지 않는 코드 제거 * refactor: 파라미터명 변경 --- .../RememberDeepLinkHandler.android.kt | 20 +++++--- .../festabook/presentation/FestabookScreen.kt | 4 +- .../presentation/main/FestabookRoute.kt | 4 +- .../presentation/main/MainViewModel.kt | 16 +++--- .../presentation/main/component/MainScreen.kt | 45 ++++++++++------ .../main/navigation/MainNavigation.kt | 5 +- .../platform/RememberDeepLinkHandler.kt | 21 +++----- .../DefaultUserNotificationDelegate.kt | 25 +++++---- .../platform/PendingFcmNotification.kt | 35 +++++++++++++ .../platform/RememberDeepLinkHandler.ios.kt | 51 +++---------------- 10 files changed, 121 insertions(+), 105 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt index 043aa0dc..a4494850 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt @@ -10,24 +10,32 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.core.util.Consumer -actual typealias Intent = Intent - @Composable -actual fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { +actual fun RememberDeepLinkHandler(onNotificationClick: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) { val context = LocalContext.current val activity = context as? ComponentActivity ?: return - val currentOnDeepLink by rememberUpdatedState(onDeepLink) + val currentOnNotificationClicked by rememberUpdatedState(onNotificationClick) LaunchedEffect(Unit) { - currentOnDeepLink(activity.intent) + activity.intent.handleDeepLink(currentOnNotificationClicked) } DisposableEffect(activity) { val listener = Consumer { intent -> - currentOnDeepLink(intent) + intent.handleDeepLink(currentOnNotificationClicked) } activity.addOnNewIntentListener(listener) onDispose { activity.removeOnNewIntentListener(listener) } } } + +private fun Intent.handleDeepLink(onNotificationClicked: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) { + val announcementId = + getLongExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, DeepLinkKeys.INITIALIZED_ID) + if (announcementId != DeepLinkKeys.INITIALIZED_ID) { + onNotificationClicked(announcementId, false) + removeExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND) + removeExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt index 3f66903f..9662c448 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt @@ -50,12 +50,12 @@ fun FestabookScreen( }, ) }, - onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main) }, + onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main()) }, onFinishApp = onAppFinish, ) exploreNavGraph( onBackClick = { festabookNavigator.popBackStack() }, - onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main) }, + onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main()) }, ) mainNavGraph( appGraph = appGraph, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index c30733d3..8134a658 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -16,7 +16,9 @@ sealed interface FestabookRoute { data object Explore : FestabookRoute @Serializable - data object Main : FestabookRoute + data class Main( + val pendingAnnouncementId: Long? = null, + ) : FestabookRoute @Serializable data object AddWaitingInfo : FestabookRoute diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt index d6f2d42b..331e78ac 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/MainViewModel.kt @@ -7,6 +7,7 @@ import com.daedan.festabook.domain.repository.FestivalRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlin.time.Clock @@ -24,17 +26,11 @@ class MainViewModel( festivalRepository: FestivalRepository, ) : ViewModel() { private val _backPressEvent: MutableSharedFlow = - MutableSharedFlow( - extraBufferCapacity = 1, - ) + MutableSharedFlow(extraBufferCapacity = 1) val backPressEvent: SharedFlow = _backPressEvent.asSharedFlow() - private val _navigateNewsEvent: MutableSharedFlow = - MutableSharedFlow( - replay = 1, - extraBufferCapacity = 1, - ) - val navigateNewsEvent = _navigateNewsEvent.asSharedFlow() + private val _navigateNewsEvent: Channel = Channel(capacity = 1) + val navigateNewsEvent = _navigateNewsEvent.receiveAsFlow() private val _isFirstVisit = MutableStateFlow(false) val isFirstVisit: StateFlow = _isFirstVisit.asStateFlow() @@ -53,7 +49,7 @@ class MainViewModel( } fun navigateToNews() { - _navigateNewsEvent.tryEmit(Unit) + _navigateNewsEvent.trySend(Unit) } fun declineAlert() { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index e3f69a32..dcdf6cab 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -14,6 +15,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.navigation.compose.NavHost +import androidx.navigation.navOptions import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState @@ -40,8 +42,6 @@ import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph import com.daedan.festabook.presentation.placeMap.platform.LocationSource -import com.daedan.festabook.presentation.platform.DeepLinkKeys -import com.daedan.festabook.presentation.platform.Intent import com.daedan.festabook.presentation.platform.RememberDeepLinkHandler import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.navigation.scheduleNavGraph @@ -62,6 +62,7 @@ fun MainScreen( locationSource: LocationSource, onAppFinish: () -> Unit, festabookNavigator: FestabookNavigator, + pendingAnnouncementId: Long?, mainViewModel: MainViewModel, homeViewModel: HomeViewModel, scheduleViewModel: ScheduleViewModel, @@ -107,8 +108,23 @@ fun MainScreen( } } - RememberDeepLinkHandler { intent -> - handleNavigation(intent, newsViewModel, mainViewModel) + LaunchedEffect(pendingAnnouncementId) { + pendingAnnouncementId?.let { announcementId -> + navigateToNewsScreen(newsViewModel, mainViewModel, announcementId) + } + } + + RememberDeepLinkHandler { announcementId, festivalIdChanged -> + if (festivalIdChanged) { + festabookNavigator.navigate( + FestabookRoute.Main(pendingAnnouncementId = announcementId), + navOptions { + popUpTo { inclusive = true } + }, + ) + } else { + navigateToNewsScreen(newsViewModel, mainViewModel, announcementId) + } } Scaffold( snackbarHost = { @@ -191,6 +207,15 @@ fun MainScreen( } } +private fun navigateToNewsScreen( + newsViewModel: NewsViewModel, + mainViewModel: MainViewModel, + id: Long, +) { + newsViewModel.expandNotice(id) + mainViewModel.navigateToNews() +} + @Composable private fun FestabookNavHost( innerPadding: PaddingValues, @@ -253,15 +278,3 @@ private fun FestabookNavHost( ) } } - -private fun handleNavigation( - intent: Intent, - newsViewModel: NewsViewModel, - mainViewModel: MainViewModel, -) { - val noticeIdToExpand = - intent.getLongExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, DeepLinkKeys.INITIALIZED_ID) - if (noticeIdToExpand != DeepLinkKeys.INITIALIZED_ID) newsViewModel.expandNotice(noticeIdToExpand) - val canNavigateToNews = intent.getBooleanExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, false) - if (canNavigateToNews) mainViewModel.navigateToNews() -} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt index f620f701..df5665cf 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.presentation.main.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import androidx.navigation.toRoute import com.daedan.festabook.di.FestabookAppGraph import com.daedan.festabook.presentation.main.FestabookNavigator import com.daedan.festabook.presentation.main.FestabookRoute @@ -15,7 +16,8 @@ fun NavGraphBuilder.mainNavGraph( locationSource: LocationSource, festabookNavigator: FestabookNavigator, ) { - composable { + composable { backStackEntry -> + val mainRoute = backStackEntry.toRoute() val mainBackEntry = festabookNavigator.navController.getBackStackEntry() MainScreen( @@ -23,6 +25,7 @@ fun NavGraphBuilder.mainNavGraph( locationSource = locationSource, onAppFinish = onAppFinish, festabookNavigator = festabookNavigator, + pendingAnnouncementId = mainRoute.pendingAnnouncementId, homeViewModel = metroViewModel(mainBackEntry), scheduleViewModel = metroViewModel(mainBackEntry), placeMapViewModel = metroViewModel(mainBackEntry), diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt index 750196ea..85d3889c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt @@ -8,20 +8,13 @@ object DeepLinkKeys { const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" const val INITIALIZED_ID = -1L - const val KEY_FCM_NOTIFICATION = "fcmNewsNotification" -} - -expect class Intent { - fun getLongExtra( - key: String, - defaultValue: Long, - ): Long - - fun getBooleanExtra( - key: String, - defaultValue: Boolean, - ): Boolean } +/** + * FCM 알림 클릭 시 호출되는 딥링크 핸들러. + * + * @param onNotificationClick announcementId와 festivalId 변경 여부를 전달. + * Android에서는 festivalIdChanged가 항상 false (Activity 재시작으로 처리). + */ @Composable -expect fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) +expect fun RememberDeepLinkHandler(onNotificationClick: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt index efbc5297..f8da52d1 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt @@ -2,12 +2,13 @@ package com.daedan.festabook.delegate import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.presentation.platform.DeepLinkKeys +import com.daedan.festabook.presentation.platform.PendingFcmNotification import dev.zacsweers.metro.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import platform.Foundation.NSNotificationCenter import platform.UserNotifications.UNNotification import platform.UserNotifications.UNNotificationPresentationOptionAlert import platform.UserNotifications.UNNotificationPresentationOptionSound @@ -17,7 +18,6 @@ import platform.UserNotifications.UNUserNotificationCenter import platform.UserNotifications.UNUserNotificationCenterDelegateProtocol import platform.darwin.NSObject -// 임시 @Inject class DefaultUserNotificationDelegate( private val festivalLocalDataSource: FestivalLocalDataSource, @@ -40,27 +40,30 @@ class DefaultUserNotificationDelegate( ) { val userInfo = didReceiveNotificationResponse.notification.request.content.userInfo - val festivalId = + val newFestivalId = (userInfo[DeepLinkKeys.KEY_FESTIVAL_ID] as? String)?.toLongOrNull() ?: DeepLinkKeys.INITIALIZED_ID val announcementId = - (userInfo[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String) ?: run { + (userInfo[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String)?.toLongOrNull() ?: run { withCompletionHandler() return } ioCoroutineScope.launch { - if (festivalId != DeepLinkKeys.INITIALIZED_ID) { - festivalLocalDataSource.saveFestivalId(festivalId) + val currentFestivalId = festivalLocalDataSource.getFestivalId().firstOrNull() + val festivalIdChanged = + newFestivalId != DeepLinkKeys.INITIALIZED_ID && newFestivalId != currentFestivalId + + if (festivalIdChanged) { + festivalLocalDataSource.saveFestivalId(newFestivalId) } + PendingFcmNotification.store( + announcementId = announcementId, + festivalIdChanged = festivalIdChanged, + ) withContext(Dispatchers.Main) { - NSNotificationCenter.defaultCenter.postNotificationName( - aName = DeepLinkKeys.KEY_FCM_NOTIFICATION, - `object` = null, - userInfo = mapOf(DeepLinkKeys.KEY_ANNOUNCEMENT_ID to announcementId), - ) withCompletionHandler() } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt new file mode 100644 index 00000000..87400e30 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt @@ -0,0 +1,35 @@ +package com.daedan.festabook.presentation.platform + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * iOS에서 FCM 알림 클릭 시 딥링크 데이터를 전달하는 Channel 기반 버퍼. + * + * NSNotification은 fire-and-forget이라 Compose UI가 준비되기 전(cold start)에 + * 발사되면 유실됨. Channel은 collector가 소비할 때까지 데이터를 보관하므로: + * - Cold start: 데이터가 버퍼에 대기 → Compose 준비 후 collect 시 즉시 수신 + * - 앱 실행 중: 데이터 전송 → 이미 collect 중인 handler가 즉시 수신 + */ +object PendingFcmNotification { + private val _pending = Channel(capacity = Channel.BUFFERED) + val pending: Flow = _pending.receiveAsFlow() + + fun store( + announcementId: Long, + festivalIdChanged: Boolean, + ) { + _pending.trySend( + Data( + announcementId = announcementId, + festivalIdChanged = festivalIdChanged, + ), + ) + } + + data class Data( + val announcementId: Long, + val festivalIdChanged: Boolean, + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt index 44bfbfe5..baa090a7 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt @@ -1,55 +1,18 @@ package com.daedan.festabook.presentation.platform import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState -import platform.Foundation.NSNotificationCenter -import platform.Foundation.NSOperationQueue - -actual class Intent( - val extras: Map = emptyMap(), -) { - actual fun getLongExtra( - key: String, - defaultValue: Long, - ): Long = extras[key] as? Long ?: defaultValue - - actual fun getBooleanExtra( - key: String, - defaultValue: Boolean, - ): Boolean = extras[key] as? Boolean ?: defaultValue -} +import kotlinx.coroutines.flow.collectLatest @Composable -actual fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { - val currentOnDeepLink by rememberUpdatedState(onDeepLink) - - DisposableEffect(Unit) { - val observer = - NSNotificationCenter.defaultCenter.addObserverForName( - name = DeepLinkKeys.KEY_FCM_NOTIFICATION, // 커스텀 알림 이름 (AppDelegate에서 쏴줘야 함) - `object` = null, - queue = NSOperationQueue.mainQueue, - ) { notification -> - val notificationToExpand = - notification - ?.userInfo - ?.get(DeepLinkKeys.KEY_ANNOUNCEMENT_ID) - ?.toString() - ?.toLongOrNull() ?: return@addObserverForName - currentOnDeepLink( - Intent( - mapOf( - DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND to notificationToExpand, - DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS to true, - ), - ), - ) - } +actual fun RememberDeepLinkHandler(onNotificationClick: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) { + val currentOnNotificationClicked by rememberUpdatedState(onNotificationClick) - onDispose { - NSNotificationCenter.defaultCenter.removeObserver(observer) + LaunchedEffect(Unit) { + PendingFcmNotification.pending.collectLatest { data -> + currentOnNotificationClicked(data.announcementId, data.festivalIdChanged) } } } From 3dc19dedc7caf56533151765d0111864ac48fc14 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:40:55 +0900 Subject: [PATCH 26/54] =?UTF-8?q?[Feat]=20=EC=9B=A8=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat] 설정 전화번호(WaitingInfo) 등록/수정 기능 구현 (#120) - WaitingInfoViewModel: 전화번호 로드/저장/수정 상태 관리 (Metro DI) - AddWaitingInfoScreen: 전화번호 등록·수정 공용 화면 (미등록 시 빈 입력, 등록 시 pre-fill) - SettingScreen: 등록 정보 섹션 추가 (전화번호 표시 및 클릭 네비게이션) - FestabookRoute: AddWaitingInfo 라우트 추가 - SettingNavigation: AddWaitingInfo 컴포저블 등록 및 WaitingInfoViewModel 연동 Co-Authored-By: Claude Sonnet 4.6 * style: 설정 화면(SettingScreen) UI 스타일 및 레이아웃 개선 * **style: `SettingScreen` 내 섹션 구분선 및 간격 조정** * 섹션 사이의 `HorizontalDivider` 색상을 `gray100`에서 `gray200`으로 변경하고, 두께를 `paddingBody2`에서 `paddingBody1`으로 두껍게 조정했습니다. * 구분선의 수직 패딩(Padding)을 늘려 섹션 간 시각적 여백을 충분히 확보했습니다. * **style: 설정 항목 섹션 타이틀 및 텍스트 스타일 통일** * 웨이팅 정보, 등록 정보, 앱 정보 등 각 섹션 타이틀의 텍스트 스타일을 `bodyMedium`에서 `bodyLarge`로 변경하고, 색상을 `gray500`으로 명시했습니다. * 앱 버전 표시 및 웨이팅 전화번호 텍스트의 스타일을 `bodyLarge` 및 `gray500`으로 업데이트하여 일관성을 높였습니다. * **refactor: 웨이팅 정보 항목 레이아웃 단순화** * `RegistrationInfoContent` 내 전화번호 표시 부분에서 불필요한 `Row`와 화살표 아이콘(`ic_arrow_forward_right`)을 제거했습니다. * 클릭 가능한 영역의 패딩을 `14.dp`로 고정하고 레이아웃 구성을 간소화했습니다. * **style: 등록 정보 및 앱 정보 레이아웃 세부 수정** * 대학교 이름 표시 영역의 하단 패딩을 제거하고 상단 패딩만 유지하도록 수정했습니다. * 앱 버전 정보 항목(`AppVersionInfo`)의 수직 패딩을 `14.dp`로 조정했습니다. * feat: 웨이팅 정보 등록 화면 UI 개선 및 로직 고도화 * **feat: 웨이팅 정보(전화번호) 등록 화면 UI 전면 개편** * `AddWaitingInfoScreen`: 헤드라인, 상세 설명, 약관 동의 체크박스 등을 추가하여 사용자 경험을 개선했습니다. * `CenterAlignedTopAppBar`를 적용하고 디자인 시스템(`festabookShapes`, `festabookSpacing`)에 맞춰 여백 및 컴포넌트 스타일을 조정했습니다. * `HorizontalDivider` 및 전화번호 아이콘 등을 추가하여 시각적 완성도를 높였습니다. * **feat: 웨이팅 정보 등록 시 약관 동의 로직 추가** * `WaitingInfoViewModel`: 필수 약관 동의 여부(`isTermsAgreed`) 상태를 추가했습니다. * `isSaveEnabled`: 전화번호 유효성(숫자 9자리 이상), 약관 동의 여부, 저장 중 상태를 결합하여 저장 버튼 활성화 로직을 강화했습니다. * **refactor: 웨이팅 정보 관련 패키지 구조 변경 및 코드 정리** * 웨이팅 정보 관련 파일들을 `presentation.setting.waitinginfo`에서 `presentation.waitinginfo` 패키지로 이동하여 구조를 재배치했습니다. * `WaitingInfoUiState`를 별도의 파일로 분리하고, 관련 임포트 경로를 모두 업데이트했습니다. * `WaitingInfoViewModel` 내 `loadWaitingInfo` 메서드를 `private`으로 변경하고 초기화 시점에 자동으로 정보를 불러오도록 개선했습니다. * **chore: 웨이팅 정보 관련 다국어 리소스 추가 및 수정** * `strings.xml`: "전화번호 등록", "약관 동의" 등 신규 화면 구성에 필요한 문자열 리소스를 추가하고 기존 문구를 명확하게 수정했습니다. * refactor: Scaffold innerPadding 적용을 통한 네비게이션 화면 레이아웃 개선 * **refactor: 메인 탭 네비게이션 그래프에 `innerPadding` 전달 및 적용** * `MainScreen.kt`: `Scaffold`에서 제공하는 `innerPadding`을 `FestabookNavHost`로 전달하도록 수정했습니다. * `HomeNavigation.kt`, `ScheduleNavigation.kt`, `NewsNavigation.kt`, `SettingNavigation.kt`, `PlaceMapNavigation.kt`: 각 네비게이션 그래프 함수(`navGraph`)에 `innerPadding: PaddingValues` 파라미터를 추가했습니다. * 각 탭의 메인 스크린(`HomeScreen`, `ScheduleScreen`, `NewsScreen`, `SettingRoute`, `PlaceDetailRoute`) 호출 시 `Modifier.padding(innerPadding)`을 적용하여 상하단 바 영역과 겹치지 않도록 개선했습니다. * **style: 웨이팅 정보 추가 화면 레이아웃 설정 최적화** * `AddWaitingInfoScreen.kt`: `Scaffold` 및 `TopAppBar`에서 명시적으로 설정되어 있던 `windowInsets = WindowInsets(0)` 설정을 제거하여 시스템 기본 인셋이 자연스럽게 적용되도록 수정했습니다. * fix: 웨이팅 정보(전화번호) 등록 성공 시 스낵바 알림 추가 및 약관 동의 상태 로직 수정 * **feat: 전화번호 등록 성공 시 스낵바 메시지 표시** * `strings.xml`: 등록 완료 메시지 리소스(`setting_waiting_info_save_success`)를 추가했습니다. * `AddWaitingInfoScreen.kt`: `saveSuccessEvent` 발생 시 `onShowSnackBar`를 호출하여 성공 메시지를 띄우도록 로직을 추가했습니다. * `SettingNavigation.kt`: `AddWaitingInfoRoute` 호출 시 `onShowSnackBar` 콜백을 전달하도록 수정했습니다. * **refactor: 웨이팅 정보 조회 시 약관 동의 상태 동기화** * `WaitingInfoViewModel.kt`: 저장된 웨이팅 정보가 존재할 경우, 화면 진입 시 `isTermsAgreed` 상태를 `true`로 설정하여 사용자 UI 상태를 올바르게 업데이트하도록 수정했습니다. * refactor: WaitingInfo 로드 시점 변경 및 ViewModel 초기화 로직 수정 * **refactor: `WaitingInfoViewModel` 내 자동 데이터 로드 로직 제거 및 접근 제어자 수정** * `init` 블록에서 `loadWaitingInfo()`를 호출하던 로직을 삭제하여 ViewModel 생성 시 자동으로 데이터를 로드하지 않도록 변경했습니다. * UI 단계에서 명시적으로 호출할 수 있도록 `loadWaitingInfo()` 메서드의 접근 제어자를 `private`에서 `public`으로 수정했습니다. * **feat: 화면 진입 시 WaitingInfo 로드 로직 추가** * `SettingScreen.kt`: `LaunchedEffect(Unit)`을 추가하여 설정 화면에 진입할 때 웨이팅 정보를 새로고침하도록 구현했습니다. * `AddWaitingInfoScreen.kt`: `LaunchedEffect(Unit)`을 통해 화면 진입 시 웨이팅 정보를 로드하는 로직을 추가했습니다. * fix: 스낵바 표시 시 키보드 가림 현상 수정 * **style: `MainScreen` 내 `SnackbarHost`에 `imePadding` 적용** * `Scaffold`의 `snackbarHost`에 `Modifier.imePadding()`을 추가하여, 소프트 키보드가 활성화되었을 때 스낵바가 키보드 위로 자동으로 밀려 올라가도록 수정했습니다. * refactor: `WaitingInfoViewModel` 내 휴대폰 번호 입력 로직 및 이벤트 스트림 최적화 * **refactor: `updatePhoneNumber` 메서드 내 입력 제한 로직 수정** * 입력 문자열의 길이가 11자를 초과할 경우 업데이트를 중단하는 가드 클로즈(Guard Clause)를 추가했습니다. * 기존 `take(11)` 대신 전체 입력값에서 숫자만 필터링하도록 변경하여 입력 제어의 정확성을 높였습니다. * **refactor: `SharedFlow` 설정 간소화** * `_saveSuccessEvent` 및 `_errorEvent`에서 불필요한 `replay = 0` 및 `extraBufferCapacity = 1` 설정을 제거하여 기본값으로 단순화했습니다. * refactor: `WaitingInfoViewModel` 내 SharedFlow 버퍼 설정 추가 * **refactor: `saveSuccessEvent` 및 `errorEvent`에 버퍼 용량 설정** * `MutableSharedFlow` 생성 시 `extraBufferCapacity = 1`을 추가하여, 구독자가 없는 상태에서 이벤트가 발생하더라도 최소 1개의 최신 이벤트를 보관할 수 있도록 개선했습니다. * feat: 서비스 이용약관 및 개인정보 처리방침 분리 및 링크 연동 (#120) * **feat: 설정 화면 내 정책 관련 메뉴 세분화 및 URL 업데이트** * `strings.xml`: 기존 `setting_service_policy` 명칭을 "서비스 이용약관"으로 변경하고, `setting_personal_information_policy`("개인정보 처리방침") 항목을 새롭게 추가했습니다. * `SettingScreen.kt`: 정책 URL을 `SERVICE_POLICY_URL`과 `PERSONAL_INFORMATION_POLICY_URL`로 분리하여 업데이트했습니다. * `SettingScreen.kt`: `AppInfoContent` 및 관련 컴포저블에 개인정보 처리방침 버튼과 클릭 핸들러를 추가하여 각 정책 페이지로 연결되도록 구현했습니다. * **feat: 웨이팅 정보 등록 화면 내 약관 동의 링크 추가** * `AddWaitingInfoScreen.kt`: 웨이팅 정보 등록 시 동의가 필요한 약관 URL(`TERMS_URL`) 상수를 추가했습니다. * `AddWaitingInfoScreen.kt`: 약관 동의 텍스트에 밑줄(`Underline`) 스타일을 적용하고, 클릭 시 `LocalUriHandler`를 통해 해당 약관 페이지가 열리도록 수정했습니다. * **refactor: 설정 화면 내 문의하기 URL 업데이트** * `SettingScreen.kt`: 개발자 문의하기 링크(`CONTACT_US_URL`)를 최신 Google Forms 주소로 갱신했습니다. * refactor: 웨이팅 정보 패키지 구조 변경, 화면 전환 앵니메이션, 각 navigation으로 이동 * **refactor: 웨이팅 정보(WaitingInfo) 관련 파일을 `setting` 패키지 하위로 이동** * 웨이팅 정보 관리 기능이 설정 도메인에 종속됨에 따라 관련 파일들의 패키지 경로를 수정했습니다. * `presentation.waitinginfo` -> `presentation.setting.waitinginfo` * `WaitingInfoViewModel`, `WaitingInfoUiState`, `AddWaitingInfoScreen` 등의 위치를 변경하고 관련 임포트 문을 업데이트했습니다. * **style: 메인 탭 네비게이션 전환 애니메이션 제거** * `MainScreen`의 전역 설정으로 되어 있던 전환 애니메이션을 제거하고, 각 개별 탭(`Home`, `News`, `Schedule`, `PlaceMap`, `Setting`)의 `composable` 설정에 `EnterTransition.None` 및 `ExitTransition.None`을 명시적으로 적용했습니다. * 탭 전환 시 시각적 지연 없이 즉각적으로 화면이 전환되도록 개선했습니다. * **chore: 코드 정리 및 경로 최적화** * `MainScreen.kt`, `SettingNavigation.kt` 등에서 변경된 패키지 경로에 맞춰 의존성 참조를 수정했습니다. * refactor: `WaitingInfoViewModel` 내 휴대폰 번호 자릿수 제한 상수화 * **refactor: 휴대폰 번호 유효성 검사 로직에 상수 도입** * 매직 넘버로 관리되던 휴대폰 번호의 최소 자릿수(`9`)와 최대 자릿수(`11`)를 `MIN_DIGIT_COUNT` 및 `MAX_DIGIT_COUNT` 상수로 정의하여 가독성을 높였습니다. * `updatePhoneNumber`: 입력 제한 길이를 `MAX_DIGIT_COUNT` 상수를 사용하도록 수정했습니다. * `isSubmitEnabled`: 등록 가능 여부 판단 시 `MIN_DIGIT_COUNT` 상수를 사용하도록 수정했습니다. * feat: 웨이팅 등록 UiState, ViewModel, UiModel 구현 Co-Authored-By: Claude Sonnet 4.6 * feat: 웨이팅 등록 화면 UI 및 문자열 리소스 추가 Co-Authored-By: Claude Sonnet 4.6 * feat: 웨이팅 등록 화면 네비게이션 연결 Co-Authored-By: Claude Sonnet 4.6 * test: WaitingRegisterViewModelTest 추가 Co-Authored-By: Claude Sonnet 4.6 * refactor: WaitingPlaceSummaryCard에 PlaceDetailPreviewContent 재사용 및 상단바 글씨 크기 titleMedium으로 변경 Co-Authored-By: Claude Sonnet 4.6 * refactor: 웨이팅 등록 화면 약관 동기화 및 UI/UX 개선 (#115) * **feat: 개인정보 동의 항목 간소화 및 약관 링크 연결** * `strings.xml`: "(선택) 마케팅 정보 수신 동의" 항목을 제거하고, "(필수) 서비스 이용 약관 동의" 문구를 "(필수) 개인정보 제3자 제공 동의서"로 변경했습니다. * `WaitingRegisterScreen`: 약관 텍스트 클릭 시 노션(Notion) 기반의 외부 약관 페이지로 연결되도록 `LocalUriHandler`를 적용하고 밑줄(`Underline`) 스타일을 추가했습니다. * `WaitingRegisterUiState` & `WaitingRegisterViewModel`: 더 이상 사용하지 않는 `isMarketingAgreed` 속성 및 관련 토글 로직을 삭제했습니다. * **refactor: `WaitingRegisterViewModel` 상태 업데이트 로직 개선** * `withDerivedState` 확장 함수를 도입하여 `partySize` 변경, 제출 중 상태(`isSubmitting`), 약관 동의 여부에 따른 파생 상태(`canSubmit`, `canIncreasePartySize` 등) 계산 로직을 캡슐화했습니다. * `_uiState.update` 호출 시마다 `withDerivedState()`를 통해 UI 상태의 일관성을 보장하도록 수정했습니다. * **style: 웨이팅 등록 화면 UI 컴포넌트 및 테마 수정** * `WaitingRegisterScreen`: 기존 `SectionDivider` 대신 `HorizontalDivider`를 사용하여 시각적 구분을 명확히 했습니다. * 체크박스 및 등록 버튼의 강조 색상을 `accentBlue`에서 `black`으로 변경하여 디자인 톤앤매너를 조정했습니다. * 레이아웃 여백(Padding) 및 컴포넌트 간 간격을 전반적으로 조정했습니다. * **test: 약관 변경 사항 반영 및 테스트 코드 정비** * `WaitingRegisterViewModelTest`: 마케팅 동의 관련 테스트 케이스를 삭제하고, 변경된 필수 약관 동의 로직에 맞춰 기존 테스트 코드를 업데이트했습니다. * **chore: 코드 정리 및 주석 추가** * `WaitingRegisterViewModel` 내 전화번호 저장 상태에 대한 작업 메모(NOTE/TODO)를 추가했습니다. * 불필요한 임포트 및 사용되지 않는 스타일 정의를 정리했습니다. * refactor: 웨이팅 등록 화면 약관 동기화 및 UI/UX 개선 (#115) * **feat: 개인정보 동의 항목 간소화 및 약관 링크 연결** * `strings.xml`: "(선택) 마케팅 정보 수신 동의" 항목을 제거하고, "(필수) 서비스 이용 약관 동의" 문구를 "(필수) 개인정보 제3자 제공 동의서"로 변경했습니다. * `WaitingRegisterScreen`: 약관 텍스트 클릭 시 노션(Notion) 기반의 외부 약관 페이지로 연결되도록 `LocalUriHandler`를 적용하고 밑줄(`Underline`) 스타일을 추가했습니다. * `WaitingRegisterUiState` & `WaitingRegisterViewModel`: 더 이상 사용하지 않는 `isMarketingAgreed` 속성 및 관련 토글 로직을 삭제했습니다. * **refactor: `WaitingRegisterViewModel` 상태 업데이트 로직 개선** * `withDerivedState` 확장 함수를 도입하여 `partySize` 변경, 제출 중 상태(`isSubmitting`), 약관 동의 여부에 따른 파생 상태(`canSubmit`, `canIncreasePartySize` 등) 계산 로직을 캡슐화했습니다. * `_uiState.update` 호출 시마다 `withDerivedState()`를 통해 UI 상태의 일관성을 보장하도록 수정했습니다. * **style: 웨이팅 등록 화면 UI 컴포넌트 및 테마 수정** * `WaitingRegisterScreen`: 기존 `SectionDivider` 대신 `HorizontalDivider`를 사용하여 시각적 구분을 명확히 했습니다. * 체크박스 및 등록 버튼의 강조 색상을 `accentBlue`에서 `black`으로 변경하여 디자인 톤앤매너를 조정했습니다. * 레이아웃 여백(Padding) 및 컴포넌트 간 간격을 전반적으로 조정했습니다. * **test: 약관 변경 사항 반영 및 테스트 코드 정비** * `WaitingRegisterViewModelTest`: 마케팅 동의 관련 테스트 케이스를 삭제하고, 변경된 필수 약관 동의 로직에 맞춰 기존 테스트 코드를 업데이트했습니다. * **chore: 코드 정리 및 주석 추가** * `WaitingRegisterViewModel` 내 전화번호 저장 상태에 대한 작업 메모(NOTE/TODO)를 추가했습니다. * 불필요한 임포트 및 사용되지 않는 스타일 정의를 정리했습니다. * feat: 웨이팅 등록 전 전화번호 확인 로직 추가 및 네비게이션 구조 개선 * **feat: 전화번호 미등록 시 전화번호 등록 화면으로 이동하는 로직 추가** * `WaitingRegisterViewModel`: 초기화 시 `waitingInfo`가 없을 경우 `navigateToPhoneRegistrationEvent` 이벤트를 발행하도록 수정했습니다. * `WaitingRegisterScreen`: ViewModel에서 발행된 이벤트를 관찰하여 `onNavigateToPhoneRegistration` 콜백을 호출하도록 구현했습니다. * `WaitingRegisterViewModelTest`: 전화번호 미등록 상태에서 초기화 시 이벤트가 정상적으로 발행되는지 검증하는 테스트 케이스를 추가했습니다. * **refactor: 웨이팅 등록 관련 라우트 명칭 및 네비게이션 설정 변경** * `FestabookRoute`: `AddWaitingInfo(placeId: Long)`를 `WaitingRegister(placeId: Long)`로 이름을 변경하고, 기존 `AddWaitingInfo`는 파라미터가 없는 형태로 전환하여 전화번호 등록 화면으로 사용하도록 조정했습니다. * `PlaceMapNavigation`: `AddWaitingInfo`로 정의되어 있던 웨이팅 등록 화면의 라우트를 `WaitingRegister`로 수정하고, 새로운 `onNavigateToPhoneRegistration` 콜백을 추가했습니다. * `MainScreen`: 바뀐 라우트 명칭에 맞춰 네비게이션 호출 로직을 업데이트했습니다. * **chore: 불필요한 TODO 제거 및 코드 정리** * `WaitingRegisterViewModel`: 전화번호 미등록 시 처리에 대한 `TODO` 주석을 구현된 로직으로 대체하며 삭제했습니다. * feat: 웨이팅 등록 확인 바텀시트 추가 Co-Authored-By: Claude Sonnet 4.6 * style: 웨이팅 등록 확인 바텀시트 UI 개선 및 이미지 자산 교체 * **style: `WaitingRegisterConfirmBottomSheet` UI 및 스타일 업데이트** * **이미지 교체**: 기존의 `ic_alarm`, `ic_clock` 조합 아이콘을 삭제하고 신규 이미지 자산인 `img_waiting_push_notification_alert`로 교체했습니다. * **타이포그래피 및 색상 변경**: 제목 스타일을 `titleMedium`에서 `displaySmall`로, 설명 스타일을 `bodyMedium`에서 `bodyLarge`로 변경했습니다. 또한 체크박스 텍스트 색상을 `gray600`에서 `black`으로 수정했습니다. * **레이아웃 조정**: `ModalBottomSheet`의 `dragHandle`을 제거하고, 버튼 간격 및 패딩에 `festabookSpacing` 시스템을 적용했습니다. * **버튼 스타일 수정**: `ConfirmBottomSheetButton`의 고정 높이(52.dp)를 제거하고 수직 패딩을 추가하여 유연한 높이를 갖도록 개선했으며, 텍스트 스타일을 `displayMedium`에서 `displaySmall`로 변경했습니다. * **chore: 웨이팅 등록 경로 내 레이아웃 수정** * `PlaceMapNavigation.kt`: `WaitingRegisterRoute` 호출 시 `innerPadding`을 반영하도록 `modifier`를 추가했습니다. * **resource: 신규 이미지 자산 추가** * `img_waiting_push_notification_alert.png` 파일을 추가했습니다. * feat: MyWaitingWithPlace 도메인 모델 및 MyWaitingRepository 반환타입 변경 Co-Authored-By: Claude Sonnet 4.6 * feat: placeId 로컬 캐시 추가 및 웨이팅 등록/조회/취소 레포지토리 구현 Co-Authored-By: Claude Sonnet 4.6 * refactor: MyWaitingWithPlace를 MyWaiting으로 통합 Co-Authored-By: Claude Sonnet 4.6 * feat: 웨이팅 정보 저장 중 로딩 상태 표시 및 UI 개선 * **feat: `WaitingInfoViewModel` 내 저장 상태 관찰 로직 추가** * 기존 프라이빗 필드였던 `isSaving`을 `StateFlow` 형태인 `_isSaving`과 `isSaving`으로 분리하여 UI에서 관찰할 수 있도록 노출했습니다. * `saveWaitingInfo` 메서드 실행 시 `_isSaving` 상태를 업데이트하여 중복 호출 방지 및 로딩 상태를 제어하도록 수정했습니다. * **feat: `AddWaitingInfoScreen` 내 로딩 애니메이션 추가** * `isSaving` 상태를 구독하여, 정보 저장 중일 때 확인 버튼 내 텍스트 대신 `CircularProgressIndicator`가 표시되도록 구현했습니다. * `ConfirmButton` 및 관련 컴포넌트들에 `isSaving` 파라미터를 추가하고 프리뷰 데이터를 업데이트했습니다. * **style: `WaitingRegisterScreen` 내 로딩 인디케이터 스타일 수정** * `CircularProgressIndicator`의 크기를 `24.dp`에서 `28.dp`로, 두께(`strokeWidth`)를 `2.dp`에서 `4.dp`로 변경하여 시각적 인지성을 높였습니다. * **chore: `WaitingRegisterScreen` 내 TODO 주석 추가** * 등록 성공 시 "나의 웨이팅 화면"으로 이동해야 함을 알리는 작업 주석을 추가했습니다. * refactor: 웨이팅 등록 UI 상태 모델 분리 및 구조 개선 * **feat: `WaitingRegisterUiModel` 도입 및 UI 상태 구조 변경** * `WaitingRegisterUiState.Success` 클래스에 직접 정의되어 있던 필드들을 별도의 `WaitingRegisterUiModel` 데이터 클래스로 분리했습니다. * 인원수 증감 가능 여부(`canDecreasePartySize`, `canIncreasePartySize`) 및 등록 가능 여부(`canSubmit`) 등 유도 상태(Derived State) 계산 로직을 새 모델로 이동했습니다. * 인원수 제한 상수(`MIN_PARTY_SIZE`, `MAX_PARTY_SIZE`)를 `WaitingRegisterUiState`에서 `WaitingRegisterUiModel`로 재배치했습니다. * **refactor: `WaitingRegisterViewModel` 내 상태 업데이트 로직 최적화** * `updateRegister` inline 메서드를 추가하여 `WaitingRegisterUiState.Success` 상태일 때의 `WaitingRegisterUiModel` 업데이트 로직을 단순화했습니다. * `withDerivedState` 확장 함수를 `WaitingRegisterUiModel` 타입으로 변경하여 UI 로직 결합도를 낮췄습니다. * **style: `WaitingRegisterScreen` UI 컴포넌트 데이터 참조 수정** * 상태 구조 변경에 따라 `uiState.waitingRegister`를 통해 필요한 데이터(`isSubmitting`, `partySize`, `isServiceAgreed` 등)를 참조하도록 업데이트했습니다. * **test: `WaitingRegisterViewModelTest` 반영** * 모델 분리 및 경로 변경에 맞춰 테스트 코드 내 Assert 문과 상수 참조 경로를 수정했습니다. * feat: 웨이팅 중복 등록 방지 로직 및 안내 다이얼로그 구현 * **feat: 웨이팅 중복 등록 방지 안내 다이얼로그(`WaitingDuplicateDialog`) 추가** * 기기당 하나의 장소만 웨이팅 가능하다는 안내 문구와 함께 "나의 웨이팅" 확인 및 "다음에 하기" 버튼을 포함하는 커스텀 다이얼로그를 구현했습니다. * `strings.xml`: 다이얼로그에 사용되는 다국어 리소스 문자열을 추가했습니다. * **feat: `PlaceDetailViewModel` 내 웨이팅 중복 체크 로직 구현** * `onRegisterWaitingClick` 메서드 추가: 웨이팅 등록 시 `MyWaitingRepository`를 통해 기존 웨이팅 존재 여부를 확인합니다. * 기존 웨이팅이 없는 경우 등록 화면으로 이동(`navigateToWaitingRegisterEvent`)하고, 이미 존재하는 경우 안내 다이얼로그를 노출(`showDuplicateWaitingDialogEvent`)하도록 이벤트를 분리했습니다. * `MyWaitingRepository` 의존성을 추가하고 초기화 로직을 보완했습니다. * **refactor: `PlaceDetailScreen` UI-ViewModel 이벤트 연동** * `ObserveAsEvents`를 사용하여 뷰모델의 네비게이션 및 다이얼로그 노출 이벤트를 처리하도록 수정했습니다. * 하단 바의 웨이팅 등록 버튼 클릭 시 뷰모델의 `onRegisterWaitingClick`을 호출하도록 변경했습니다. * **test: `PlaceDetailViewModelTest` 내 의존성 추가에 따른 테스트 코드 수정** * `PlaceDetailViewModel` 생성자 변경에 맞춰 `MyWaitingRepository` 모킹(Mocking) 및 초기화 로직을 테스트 코드에 반영했습니다. * feat: 웨이팅 중복 등록 방지 로직 고도화 및 권한 요청 Flow 추가 (#120) * **feat: 웨이팅 중복 등록 처리 UX 개선** * `WaitingDuplicateDialog`를 삭제하고, 더 상세한 정보를 제공하는 `WaitingDuplicateBottomSheet` 및 `WaitingCancelConfirmDialog`를 새롭게 추가했습니다. * 한 번에 하나의 플레이스만 웨이팅 가능하다는 안내 문구와 함께 기존 웨이팅 취소 후 재등록할 수 있는 기능을 구현했습니다. * `strings.xml`: 중복 웨이팅 안내 및 취소 확인 관련 다국어 리소스를 추가 및 수정했습니다. * **feat: 웨이팅 등록 시 알림 권한 체크 로직 추가** * `WaitingRegisterRoute`: 웨이팅 등록 제출 시 `NotificationPermissionManager`를 통해 알림 권한 상태를 확인하고, 상태에 따라 권한 요청 또는 안내 다이얼로그(`NotificationPermissionDialog`)를 표시하도록 로직을 보완했습니다. * `PlaceMapNavigation`: 네비게이션 그래프에 `NotificationPermissionManager` 의존성을 주입했습니다. * **refactor: `PlaceDetailViewModel` 및 `PlaceDetailScreen` 이벤트 처리 개선** * `showDuplicateWaitingDialogEvent`를 `showDuplicateWaitingBottomSheetEvent`로 변경하여 기존 웨이팅 ID를 전달하도록 수정했습니다. * 기존 웨이팅 취소 및 신규 등록을 위한 `cancelAndRegister` 메서드와 실패 처리용 `cancelWaitingFailureEvent`를 추가했습니다. * **test: `PlaceDetailViewModel` 테스트 코드 추가** * 웨이팅 등록 버튼 클릭 시 등록 여부에 따른 이벤트 발행 로직(`navigateToWaitingRegisterEvent`, `showDuplicateWaitingBottomSheetEvent`)에 대한 단위 테스트를 추가했습니다. * 웨이팅 취소 후 재등록(`cancelAndRegister`) 시나리오에 대한 성공 및 실패 테스트 케이스를 작성했습니다. * **chore: 코드 정리 및 주석 추가** * `WaitingLocalDataSource.kt`: 서버 응답 스펙 변경에 따라 향후 삭제가 필요한 `placeId` 관련 메서드들에 TODO 주석을 추가했습니다. * refactor: 웨이팅 등록 알림 권한 로직 개선 및 장소 상세 대기 시간 연동 (#120) * **refactor: 웨이팅 등록 시 알림 권한 체크 로직을 `SettingViewModel` 기반으로 통합** * `MainScreen` 및 `placeMapNavGraph`: `WaitingRegisterRoute`에 `SettingViewModel`을 주입하도록 구조를 변경했습니다. * `WaitingRegisterRoute`: 알림 권한 상태(`isAllowed`)를 구독하여 권한이 없는 경우에만 안내 다이얼로그를 표시하도록 로직을 개선했습니다. * `WaitingRegisterRoute`: 권한 허용 이벤트(`permissionCheckEvent`) 발생 시 `NotificationPermissionManager`를 통해 실제 권한을 확인하고 상태를 동기화하는 로직을 추가했습니다. * **refactor: 장소 상세(`PlaceDetailViewModel`) 내 대기 상태 업데이트 로직 보완** * `PlaceDetailViewModel`: 웨이팅 정보 조회 성공 시, 기존의 대기 팀 수뿐만 아니라 예상 대기 시간(`estimatedWaitTime`)도 `waitingStatus` UI 상태에 반영하도록 수정했습니다. * **chore: 웨이팅 등록 화면 UI 상태 처리 개선** * `WaitingRegisterRoute`: 알림 활성화 시 표시할 스낵바 메시지(`setting_notice_enabled`) 연동을 추가했습니다. * 불필요해진 `rememberCoroutineScope` 임포트 및 관련 로직을 정리했습니다. * refactor: 리소스 오타 수정 * **refactor: `strings.xml` 내 오타 수정** * `waiting_duplicate_discription` 리소스 키를 `waiting_duplicate_description`으로 변경하여 오타를 수정했습니다. * refactor: 웨이팅 등록 ViewModel 캡슐화 및 리소스 오타 수정 * **refactor: `WaitingRegisterViewModel` 내 장소 정보 로드 로직 캡슐화** * `loadPlaceSummary()` 메서드의 접근 제어자를 `private`으로 변경하여 외부 노출을 제한했습니다. 해당 로직은 클래스 초기화 시점에 실행되도록 관리됩니다. * **test: `WaitingRegisterViewModelTest` 내 가시성 변경에 따른 테스트 코드 수정** * `loadPlaceSummary()`가 `private`으로 변경됨에 따라, 테스트 코드에서 메서드를 직접 호출하는 대신 ViewModel을 새로 생성하여 초기화 로직이 실행되도록 수정했습니다. * **style: 리소스 식별자 오타 수정** * `WaitingDuplicateBottomSheet`: 잘못 작성된 리소스 명 `waiting_duplicate_discription`을 `waiting_duplicate_description`으로 수정했습니다. * refactor: `LocalTime` 포맷팅 방식 변경 * **refactor: `LocalTime.toFormattedString` 구현 개선** * 기존의 개별 구성 요소(`hour`, `minute`) 지정 방식 대신 `byUnicodePattern("HH:mm")`을 사용하도록 변경했습니다. * 이에 따라 불필요해진 `placeTimeFormat` 상수를 제거하고 로직을 함수 내부로 통합했습니다. * refactor: `AddWaitingInfo` 내 `placeId` 타입을 nullable로 변경 및 관련 로직 간소화 * **refactor: `FestabookRoute.AddWaitingInfo` 내 `placeId` 처리 방식 개선** * 기존에 유효하지 않은 ID를 나타내기 위해 사용하던 센티넬 값(`NO_PLACE_ID = -1L`)과 컴패니언 객체를 제거했습니다. * `placeId` 타입을 `Long?`으로 변경하고 기본값을 `null`로 설정하여 도메인 모델을 명확하게 개선했습니다. * **refactor: `SettingNavigation` 내 조건부 네비게이션 로직 수정** * `onSaveSuccess` 콜백 내에서 `placeId` 유효성을 확인하는 로직을 센티넬 값 비교에서 `null` 체크 방식으로 단순화했습니다. * refactor: PlaceDetailPreviewContent 접근 제어자 변경 * **refactor: `PlaceDetailPreviewContent` 가시성 범위 확장** * `PlaceDetailPreviewContent` 컴포저블 함수의 접근 제어자를 `internal`에서 `public`으로 변경하여 외부에서의 접근성을 개선했습니다. * refactor: `PlaceMapNavigation` 내 네비게이션 전환 애니메이션 상수화 * **refactor: 화면 전환 오프셋 값을 상수로 분리** * `PlaceDetail` 경로의 `enterTransition` 및 `exitTransition`에 사용되던 매직 넘버(10)를 `INITIAL_OFFSET_CONSTANT` 상수로 정의하여 가독성을 개선했습니다. * refactor: `WaitingRegisterConfirmBottomSheet` UI 간격 설정 방식 개선 * **refactor: 하드코딩된 레이아웃 수치를 공통 디자인 시스템(`festabookSpacing`)으로 대체** * 체크 아이콘의 상하 패딩을 `40.dp`에서 `festabookSpacing.paddingTitleHorizontal`로 변경했습니다. * 제목과 설명 문구 사이의 간격을 `8.dp`에서 `festabookSpacing.paddingBody2`로 변경했습니다. * refactor: 테스트 코드 내 중복 테스트 픽스처 통합 및 가독성 개선 * **refactor: `FAKE_MY_WAITING`을 공통 테스트 픽스처로 분리** * `PlaceDetailViewModelTest`에 중복 정의되어 있던 `FAKE_MY_WAITING` 객체를 `PlaceDetailTestFixture.kt`로 이동하여 테스트 데이터 관리의 일관성을 높였습니다. * **test: `WaitingRegisterViewModelTest` 내 테스트 데이터 및 환경 개선** * 기존의 로컬 `fakeMyWaiting` 대신 공통 픽스처인 `FAKE_MY_WAITING`을 사용하도록 수정했습니다. * 전화번호 미등록 시나리오 테스트 시, 이벤트 발행 시점과의 동기화를 위해 `UnconfinedTestDispatcher`를 적용했습니다. * **style: 테스트 코드 포맷팅 정리** * `Result.success` 및 `Result.failure` 반환 문 등 가독성을 위해 개행 및 들여쓰기 스타일을 일관되게 조정했습니다. * `WaitingRegisterViewModelTest`에서 불필요해진 임포트 구문을 정리했습니다. * fix: getWaitingInfo가 실패했을 때도, 전화번호 등록 화면으로 이동하는 버그 개선 * **refactor: `WaitingRegisterViewModel` 내 데이터 로딩 로직 보완** * `loadPlaceSummary` 시 `waitingInfoRepository.getWaitingInfo()` 호출 결과에 대해 `onSuccess`와 `onFailure` 블록을 사용하도록 개선했습니다. * 웨이팅 정보 조회 실패 시 UI 상태를 `WaitingRegisterUiState.Error`로 업데이트하는 로직을 추가했습니다. * 기존의 `getOrNull` 기반 조건문을 `onSuccess` 내 흐름으로 변경하여 가독성을 높였습니다. * **style: 코드 포맷팅 수정** * `MutableSharedFlow` 선언부 및 `navigateToPhoneRegistrationEvent` 등 이벤트 변수의 줄바꿈을 조정하여 코드 컨벤션을 맞췄습니다. --------- Co-authored-by: Claude Sonnet 4.6 --- .../img_waiting_push_notification_alert.png | Bin 0 -> 10360 bytes .../composeResources/values/strings.xml | 24 + .../local/WaitingLocalDataSource.kt | 11 + .../local/WaitingLocalDataSourceImpl.kt | 16 + .../repository/MyWaitingRepositoryImpl.kt | 40 +- .../WaitingRegisterInfoRepositoryImpl.kt | 10 +- .../festabook/domain/model/MyWaiting.kt | 1 + .../common/format/PlaceTimeFormat.kt | 15 + .../presentation/main/FestabookRoute.kt | 9 +- .../presentation/main/component/MainScreen.kt | 24 +- .../component/PlaceDetailPreviewScreen.kt | 2 +- .../placeMap/navigation/PlaceMapNavigation.kt | 45 +- .../placeDetail/PlaceDetailViewModel.kt | 65 +++ .../component/PlaceDetailScreen.kt | 49 +- .../component/WaitingCancelConfirmDialog.kt | 109 ++++ .../component/WaitingDuplicateBottomSheet.kt | 133 +++++ .../placeDetail/model/PlaceDetailUiModel.kt | 12 +- .../WaitingRegisterViewModel.kt | 153 ++++++ .../WaitingRegisterConfirmBottomSheet.kt | 192 +++++++ .../component/WaitingRegisterScreen.kt | 488 ++++++++++++++++++ .../model/WaitingPlaceSummaryUiModel.kt | 31 ++ .../model/WaitingRegisterUiModel.kt | 16 + .../model/WaitingRegisterUiState.kt | 13 + .../setting/navigation/SettingNavigation.kt | 20 +- .../waitinginfo/WaitingInfoViewModel.kt | 11 +- .../component/AddWaitingInfoScreen.kt | 34 +- .../placeDetail/PlaceDetailTestFixture.kt | 13 + .../placeDetail/PlaceDetailViewModelTest.kt | 80 +++ .../WaitingRegisterViewModelTest.kt | 310 +++++++++++ 29 files changed, 1886 insertions(+), 40 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/img_waiting_push_notification_alert.png create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/format/PlaceTimeFormat.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingDuplicateBottomSheet.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterConfirmBottomSheet.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingPlaceSummaryUiModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt diff --git a/composeApp/src/commonMain/composeResources/drawable/img_waiting_push_notification_alert.png b/composeApp/src/commonMain/composeResources/drawable/img_waiting_push_notification_alert.png new file mode 100644 index 0000000000000000000000000000000000000000..601eea88e06e25052b68ab4415df17f2b21c0f1e GIT binary patch literal 10360 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 WN(0hs0tsCJ0000분실물 이미지 펼치기 정보 + + + 웨이팅 정보 입력 + 인원 수 + 9명 이상 웨이팅은 부스에 따로 문의해주세요 + 약관 동의 + (필수) 개인정보 제3자 제공 동의서 + 웨이팅 등록하기 + 웨이팅 등록이 완료되었습니다 + 이전 화면으로 돌아가기 + 인원 수 감소 + 인원 수 증가 + 호출 알림은 푸시로 전송돼요\n알림을 놓치지 않도록 허용 상태를 확인해주세요 + 제시간에 방문하지 않으면 노쇼로 처리될 수 있어요 + 네 , 확인했어요 + 다음에 웨이팅하기 + 확인 + 한 번에 하나의 플레이스만\n웨이팅을 할 수 있어요 + 이전에 등록한 웨이팅은 취소돼요 + 나의 웨이팅 + 새로 웨이팅하기 + 기존에 등록한 웨이팅을 취소하고\n새로 등록할까요? + 돌아가기 + 취소하기 위치 시간 북마크 diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt index 236fc8ee..4458fb76 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt @@ -6,4 +6,15 @@ interface WaitingLocalDataSource { suspend fun savePhoneNumber(phoneNumber: String) fun getPhoneNumber(): Flow + + // TODO: 서버에서 placeId 내려주면 삭제 + + suspend fun savePlaceId(placeId: Long) + + // TODO: 서버에서 placeId 내려주면 삭제 + fun getPlaceId(): Flow + + // TODO: 서버에서 placeId 내려주면 삭제 + + suspend fun clearPlaceId() } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt index ae9875b0..af127eb6 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.IOException import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -30,7 +31,22 @@ class WaitingLocalDataSourceImpl( if (it is IOException) emit(emptyPreferences()) else throw it }.map { it[KEY_PHONE_NUMBER] } + override suspend fun savePlaceId(placeId: Long) { + dataStore.edit { it[KEY_PLACE_ID] = placeId } + } + + override fun getPlaceId(): Flow = + dataStore.data + .catch { + if (it is IOException) emit(emptyPreferences()) else throw it + }.map { it[KEY_PLACE_ID] } + + override suspend fun clearPlaceId() { + dataStore.edit { it.remove(KEY_PLACE_ID) } + } + companion object { private val KEY_PHONE_NUMBER = stringPreferencesKey("waiting_phone_number") + private val KEY_PLACE_ID = longPreferencesKey("waiting_place_id") } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt index 2f05d9a2..441ab5a5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt @@ -1,24 +1,58 @@ package com.daedan.festabook.data.repository +import com.daedan.festabook.data.datasource.local.WaitingLocalDataSource import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource import com.daedan.festabook.data.model.response.waiting.toDomain import com.daedan.festabook.data.util.toResult import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.model.WaitingStatus import com.daedan.festabook.domain.repository.MyWaitingRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.first @ContributesBinding(AppScope::class) @Inject class MyWaitingRepositoryImpl( private val waitingRemoteDataSource: WaitingRemoteDataSource, + private val waitingLocalDataSource: WaitingLocalDataSource, ) : MyWaitingRepository { override suspend fun getMyWaiting(): Result { val existResult = waitingRemoteDataSource.fetchMyWaitingExist().toResult().getOrElse { return Result.failure(it) } - if (!existResult.exists) return Result.success(null) - return waitingRemoteDataSource.fetchMyWaiting().toResult().mapCatching { it.toDomain() } + + if (!existResult.exists) { + // 고아 복구: 서버에 없는데 로컬에 placeId가 남아 있으면 클리어 + if (waitingLocalDataSource.getPlaceId().first() != null) { + waitingLocalDataSource.clearPlaceId() + } + return Result.success(null) + } + + return waitingRemoteDataSource + .fetchMyWaiting() + .toResult() + .mapCatching { it.toDomain() } + .mapCatching { myWaiting -> + // 서버 측 종료 상태 감지 → placeId 클리어 후 null 반환 + if (myWaiting.waitingStatus in + setOf( + WaitingStatus.CANCELED, + WaitingStatus.ARRIVED, + WaitingStatus.NO_SHOW, + ) + ) { + waitingLocalDataSource.clearPlaceId() + return@mapCatching null + } + val placeId = waitingLocalDataSource.getPlaceId().first() + myWaiting.copy(placeId = placeId) + } } - override suspend fun cancelWaiting(waitingId: Long): Result = waitingRemoteDataSource.cancelWaiting(waitingId).toResult() + override suspend fun cancelWaiting(waitingId: Long): Result = + waitingRemoteDataSource + .cancelWaiting(waitingId) + .toResult() + .onSuccess { waitingLocalDataSource.clearPlaceId() } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt index 77bdcb73..c40de44e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.data.repository +import com.daedan.festabook.data.datasource.local.WaitingLocalDataSource import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource import com.daedan.festabook.data.model.request.WaitingRegisterRequest import com.daedan.festabook.data.model.response.waiting.toDomain @@ -10,11 +11,13 @@ import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import io.github.aakira.napier.Napier @ContributesBinding(AppScope::class) @Inject class WaitingRegisterInfoRepositoryImpl( private val waitingRemoteDataSource: WaitingRemoteDataSource, + private val waitingLocalDataSource: WaitingLocalDataSource, ) : WaitingRegisterInfoRepository { override suspend fun getPlaceWaiting(placeId: Long): Result { val response = waitingRemoteDataSource.fetchPlaceWaiting(placeId).toResult() @@ -27,6 +30,11 @@ class WaitingRegisterInfoRepositoryImpl( ): Result { val request = WaitingRegisterRequest(partySize = partySize) val response = waitingRemoteDataSource.registerWaiting(placeId, request).toResult() - return response.mapCatching { it.toDomain() } + return response.mapCatching { dto -> + // placeId DataStore 저장 — 실패해도 서버 등록 성공이므로 계속 + runCatching { waitingLocalDataSource.savePlaceId(placeId) } + .onFailure { Napier.w("placeId 저장 실패 (degraded mode)", it) } + dto.toDomain() + } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt index 97117a48..fca48045 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt @@ -8,4 +8,5 @@ data class MyWaiting( val totalWaitingTeams: Int, val estimatedWaitTime: Int, val phoneNumber: String, + val placeId: Long? = null, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/format/PlaceTimeFormat.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/format/PlaceTimeFormat.kt new file mode 100644 index 00000000..529ab84f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/format/PlaceTimeFormat.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.common.format + +import kotlinx.datetime.LocalTime +import kotlinx.datetime.format +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern + +@OptIn(FormatStringsInDatetimeFormats::class) +fun LocalTime?.toFormattedString(): String? { + val format = + LocalTime.Format { + byUnicodePattern("HH:mm") + } + return this?.format(format) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index 8134a658..fd7612f5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -12,6 +12,11 @@ sealed interface FestabookRoute { val placeId: Long, ) : FestabookRoute + @Serializable + data class WaitingRegister( + val placeId: Long, + ) : FestabookRoute + @Serializable data object Explore : FestabookRoute @@ -21,7 +26,9 @@ sealed interface FestabookRoute { ) : FestabookRoute @Serializable - data object AddWaitingInfo : FestabookRoute + data class AddWaitingInfo( + val placeId: Long? = null, + ) : FestabookRoute } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index dcdf6cab..7056fe7f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -257,8 +257,22 @@ private fun FestabookNavHost( ) placeMapNavGraph( innerPadding = innerPadding, + settingViewModel = settingViewModel, onBackToPreviousClick = { navigator.popBackStack() }, onShowErrorSnackbar = snackbarManager::showError, + onNavigateToAddWaitingInfo = { placeId -> + navigator.navigate(FestabookRoute.WaitingRegister(placeId)) + }, + onNavigateToPhoneRegistration = { placeId -> + navigator.navigate( + FestabookRoute.AddWaitingInfo(placeId = placeId), + navOptions { + popUpTo { inclusive = true } + }, + ) + }, + onShowSnackbar = snackbarManager::show, + notificationPermissionManager = notificationPermissionManager, ) newsNavGraph( innerPadding = innerPadding, @@ -273,7 +287,15 @@ private fun FestabookNavHost( notificationPermissionManager = notificationPermissionManager, onShowSnackBar = snackbarManager::show, onShowErrorSnackBar = snackbarManager::showError, - onNavigateToAddWaitingInfo = { navigator.navigate(FestabookRoute.AddWaitingInfo) }, + onNavigateToAddWaitingInfo = { navigator.navigate(FestabookRoute.AddWaitingInfo()) }, + onNavigateToWaitingRegister = { placeId -> + navigator.navigate( + FestabookRoute.WaitingRegister(placeId), + navOptions { + popUpTo { inclusive = true } + }, + ) + }, onBackClick = { navigator.popBackStack() }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index b7db1bd6..03d46ee8 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -72,7 +72,7 @@ fun PlaceDetailPreviewScreen( } @Composable -private fun PlaceDetailPreviewContent( +fun PlaceDetailPreviewContent( placeDetail: PlaceDetailUiModel, modifier: Modifier = Modifier, ) { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index 052ae43e..b3e9df54 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -19,20 +19,29 @@ import androidx.navigation.toRoute import androidx.savedstate.SavedState import androidx.savedstate.read import androidx.savedstate.write +import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.MainTabRoute import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeMap.placeDetail.component.PlaceDetailRoute import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.WaitingRegisterViewModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.component.WaitingRegisterRoute +import com.daedan.festabook.presentation.setting.SettingViewModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import kotlinx.serialization.json.Json import kotlin.reflect.typeOf fun NavGraphBuilder.placeMapNavGraph( innerPadding: PaddingValues, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, onBackToPreviousClick: () -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, + onNavigateToAddWaitingInfo: (Long) -> Unit, + onNavigateToPhoneRegistration: (Long) -> Unit, + onShowSnackbar: (String) -> Unit, ) { composable( enterTransition = { EnterTransition.None }, @@ -47,10 +56,10 @@ fun NavGraphBuilder.placeMapNavGraph( typeOf() to defaultNavType(), ), enterTransition = { - slideInVertically(initialOffsetY = { it / 10 }) + fadeIn() + slideInVertically(initialOffsetY = { it / INITIAL_OFFSET_CONSTANT }) + fadeIn() }, exitTransition = { - slideOutVertically(targetOffsetY = { it / 10 }) + fadeOut() + slideOutVertically(targetOffsetY = { it / INITIAL_OFFSET_CONSTANT }) + fadeOut() }, ) { backStackEntry -> val route = backStackEntry.toRoute() @@ -73,10 +82,42 @@ fun NavGraphBuilder.placeMapNavGraph( viewModel = viewModel, onBackToPreviousClick = onBackToPreviousClick, onShowErrorSnackbar = onShowErrorSnackbar, + onNavigateToWaitingRegister = onNavigateToAddWaitingInfo, + ) + } + + composable( + enterTransition = { + slideInVertically(initialOffsetY = { it / 10 }) + fadeIn() + }, + exitTransition = { + slideOutVertically(targetOffsetY = { it / 10 }) + fadeOut() + }, + ) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel = + assistedMetroViewModel( + extras = + MutableCreationExtras().apply { + set(WaitingRegisterViewModel.PlaceIdKey, route.placeId) + }, + ) + + WaitingRegisterRoute( + viewModel = viewModel, + settingViewModel = settingViewModel, + modifier = Modifier.padding(innerPadding), + onBackToPreviousClick = onBackToPreviousClick, + onShowErrorSnackbar = onShowErrorSnackbar, + onShowSnackbar = onShowSnackbar, + onNavigateToPhoneRegistration = { onNavigateToPhoneRegistration(route.placeId) }, + notificationPermissionManager = notificationPermissionManager, ) } } +private const val INITIAL_OFFSET_CONSTANT = 10 + private inline fun defaultNavType() = object : NavType(isNullableAllowed = true) { override fun put( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt index c990fb28..644b7e96 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/PlaceDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.daedan.festabook.domain.model.PlaceWaiting +import com.daedan.festabook.domain.repository.MyWaitingRepository import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel @@ -19,8 +20,11 @@ import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -28,6 +32,7 @@ import kotlinx.coroutines.launch class PlaceDetailViewModel( private val placeDetailRepository: PlaceDetailRepository, private val waitingRegisterInfoRepository: WaitingRegisterInfoRepository, + private val myWaitingRepository: MyWaitingRepository, @Assisted private val placeId: Long, ) : ViewModel() { @AssistedFactory @@ -48,6 +53,15 @@ class PlaceDetailViewModel( ) val placeDetail: StateFlow = _placeDetail + private val _navigateToWaitingRegisterEvent = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigateToWaitingRegisterEvent: SharedFlow = _navigateToWaitingRegisterEvent.asSharedFlow() + + private val _showDuplicateWaitingBottomSheetEvent = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val showDuplicateWaitingBottomSheetEvent: SharedFlow = _showDuplicateWaitingBottomSheetEvent.asSharedFlow() + + private val _cancelWaitingFailureEvent = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val cancelWaitingFailureEvent: SharedFlow = _cancelWaitingFailureEvent.asSharedFlow() + init { loadPlaceDetail(placeId) } @@ -88,8 +102,23 @@ class PlaceDetailViewModel( WaitingTeamUiState.Success(totalTeams = placeWaiting.totalWaitingTeams) updateInnerState { current -> + val nextWaitingStatus = + when (current.waitingStatus) { + is WaitingStatusUiState.Active -> { + WaitingStatusUiState.Active(estimatedMinutes = placeWaiting.estimatedWaitTime) + } + + is WaitingStatusUiState.Closed -> { + WaitingStatusUiState.Closed(estimatedMinutes = placeWaiting.estimatedWaitTime) + } + + else -> { + current.waitingStatus + } + } current.copy( waitingTeam = waitingTeamUiState, + waitingStatus = nextWaitingStatus, ) } }.onFailure { throwable -> @@ -116,6 +145,42 @@ class PlaceDetailViewModel( } } + fun onRegisterWaitingClick() { + val current = _placeDetail.value + if (current !is PlaceDetailUiState.Success) return + val placeId = current.placeDetail.place.id + + viewModelScope.launch { + myWaitingRepository + .getMyWaiting() + .onSuccess { myWaiting -> + if (myWaiting == null) { + _navigateToWaitingRegisterEvent.tryEmit(placeId) + } else { + _showDuplicateWaitingBottomSheetEvent.tryEmit(myWaiting.waitingId) + } + }.onFailure { + _navigateToWaitingRegisterEvent.tryEmit(placeId) + } + } + } + + fun cancelAndRegister(waitingId: Long) { + val current = _placeDetail.value + if (current !is PlaceDetailUiState.Success) return + val placeId = current.placeDetail.place.id + + viewModelScope.launch { + myWaitingRepository + .cancelWaiting(waitingId) + .onSuccess { + _navigateToWaitingRegisterEvent.tryEmit(placeId) + }.onFailure { throwable -> + _cancelWaitingFailureEvent.tryEmit(throwable) + } + } + } + // TODO UseCase 혹은 Domain Layer로 이동, 하지만 PlaceUiModel -> Place로 변환 불가, 아키텍쳐 변화 필요 private fun PlaceDetailUiState.isWaitingNotSupported(placeWaiting: PlaceWaiting): Boolean = if (this is PlaceDetailUiState.Success) { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt index bc88e36d..cc709b44 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -50,6 +51,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.LoadingStateScreen @@ -85,20 +87,62 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaceDetailRoute( viewModel: PlaceDetailViewModel, onBackToPreviousClick: () -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, + onNavigateToWaitingRegister: (placeId: Long) -> Unit, modifier: Modifier = Modifier, ) { val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + var duplicateWaitingId by remember { mutableStateOf(null) } + var showCancelConfirmDialog by remember { mutableStateOf(false) } + + ObserveAsEvents(viewModel.navigateToWaitingRegisterEvent) { placeId -> + duplicateWaitingId = null + showCancelConfirmDialog = false + onNavigateToWaitingRegister(placeId) + } + ObserveAsEvents(viewModel.showDuplicateWaitingBottomSheetEvent) { waitingId -> + duplicateWaitingId = waitingId + } + ObserveAsEvents(viewModel.cancelWaitingFailureEvent) { throwable -> + showCancelConfirmDialog = false + onShowErrorSnackbar(throwable) + } + + if (duplicateWaitingId != null && !showCancelConfirmDialog) { + WaitingDuplicateBottomSheet( + onMyWaitingClick = { + // TODO 나의 웨이팅 화면으로 이동 + }, + onRegisterNewClick = { showCancelConfirmDialog = true }, + onDismiss = { duplicateWaitingId = null }, + ) + } + + if (showCancelConfirmDialog) { + WaitingCancelConfirmDialog( + onDismissClick = { + duplicateWaitingId = null + showCancelConfirmDialog = false + }, + onCancelClick = { + duplicateWaitingId?.let { viewModel.cancelAndRegister(it) } + }, + onDismissRequest = { showCancelConfirmDialog = false }, + ) + } + PlaceDetailScreen( modifier = modifier, uiState = placeDetailUiState, onBackToPreviousClick = onBackToPreviousClick, onShowErrorSnackbar = onShowErrorSnackbar, + onRegisterWaitingClick = viewModel::onRegisterWaitingClick, onWaitingRefresh = { scope.launch { viewModel.refreshWaitingStatus() } }, @@ -112,6 +156,7 @@ fun PlaceDetailScreen( onWaitingRefresh: () -> Unit, modifier: Modifier = Modifier, onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 + onRegisterWaitingClick: () -> Unit = {}, ) { val scrollState = rememberScrollState() val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) @@ -185,9 +230,7 @@ fun PlaceDetailScreen( PlaceDetailBottomBar( waiting = uiState.waitingStatus, modifier = Modifier.align(Alignment.BottomCenter), - onRegisterWaitingClick = { - // TODO 웨이팅 등록 화면으로 연결 - }, + onRegisterWaitingClick = onRegisterWaitingClick, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt new file mode 100644 index 00000000..f02fa073 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt @@ -0,0 +1,109 @@ +package com.daedan.festabook.presentation.placeMap.placeDetail.component + +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 +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.waiting_cancel_confirm_dismiss +import festabookkmp.composeapp.generated.resources.waiting_cancel_confirm_submit +import festabookkmp.composeapp.generated.resources.waiting_cancel_confirm_title +import org.jetbrains.compose.resources.stringResource + +@Composable +fun WaitingCancelConfirmDialog( + onDismissClick: () -> Unit, + onCancelClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + Dialog(onDismissRequest = onDismissRequest) { + Column( + modifier = + modifier + .fillMaxWidth() + .clip(festabookShapes.radius4) + .background(FestabookColor.white) + .padding(festabookSpacing.paddingScreenGutter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.waiting_cancel_confirm_title), + style = FestabookTypography.displaySmall, + color = FestabookColor.black, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 32.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + ) { + CancelConfirmDialogButton( + text = stringResource(Res.string.waiting_cancel_confirm_dismiss), + isOutlined = true, + onClick = onDismissClick, + modifier = Modifier.weight(1f), + ) + + CancelConfirmDialogButton( + text = stringResource(Res.string.waiting_cancel_confirm_submit), + isOutlined = false, + onClick = onCancelClick, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun CancelConfirmDialogButton( + text: String, + isOutlined: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(festabookShapes.radius2) + .then( + if (isOutlined) { + Modifier + .border( + width = 1.dp, + color = FestabookColor.gray300, + shape = festabookShapes.radius2, + ).background(FestabookColor.white) + } else { + Modifier.background(FestabookColor.black) + }, + ).clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody4), + text = text, + style = FestabookTypography.titleSmall, + color = if (isOutlined) FestabookColor.black else FestabookColor.white, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingDuplicateBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingDuplicateBottomSheet.kt new file mode 100644 index 00000000..46a3e74b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingDuplicateBottomSheet.kt @@ -0,0 +1,133 @@ +package com.daedan.festabook.presentation.placeMap.placeDetail.component + +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 +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.waiting_duplicate_description +import festabookkmp.composeapp.generated.resources.waiting_duplicate_my_waiting +import festabookkmp.composeapp.generated.resources.waiting_duplicate_register_new +import festabookkmp.composeapp.generated.resources.waiting_duplicate_title +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WaitingDuplicateBottomSheet( + onMyWaitingClick: () -> Unit, + onRegisterNewClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = FestabookColor.white, + modifier = modifier, + dragHandle = {}, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(52.dp)) + + Text( + text = stringResource(Res.string.waiting_duplicate_title), + style = FestabookTypography.displaySmall, + color = FestabookColor.black, + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(Res.string.waiting_duplicate_description), + style = FestabookTypography.bodyLarge, + color = FestabookColor.black, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = festabookSpacing.paddingBody2), + ) + + Spacer(modifier = Modifier.height(28.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + ) { + DuplicateBottomSheetButton( + text = stringResource(Res.string.waiting_duplicate_my_waiting), + isOutlined = true, + onClick = onMyWaitingClick, + modifier = Modifier.weight(1f), + ) + + DuplicateBottomSheetButton( + text = stringResource(Res.string.waiting_duplicate_register_new), + isOutlined = false, + onClick = onRegisterNewClick, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun DuplicateBottomSheetButton( + text: String, + isOutlined: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(festabookShapes.radius2) + .then( + if (isOutlined) { + Modifier + .border( + width = 1.dp, + color = FestabookColor.gray300, + shape = festabookShapes.radius2, + ).background(FestabookColor.white) + } else { + Modifier.background(FestabookColor.black) + }, + ).clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody4), + text = text, + style = FestabookTypography.titleSmall, + color = if (isOutlined) FestabookColor.black else FestabookColor.white, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt index 8cac62bc..8c435f13 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/model/PlaceDetailUiModel.kt @@ -1,12 +1,11 @@ package com.daedan.festabook.presentation.placeMap.placeDetail.model import com.daedan.festabook.domain.model.PlaceDetail +import com.daedan.festabook.presentation.common.format.toFormattedString import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel import com.daedan.festabook.presentation.news.notice.model.toUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel -import kotlinx.datetime.LocalTime -import kotlinx.datetime.format.char data class PlaceDetailUiModel( val place: PlaceUiModel, @@ -28,12 +27,3 @@ fun PlaceDetail.toUiModel() = images = sortedImages.map { it.toUiModel() }, isWaitingActive = isWaitingActive, ) - -private fun LocalTime?.toFormattedString(): String? = this?.let { timeFormat.format(it) } - -private val timeFormat = - LocalTime.Format { - hour() - char(':') - minute() - } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt new file mode 100644 index 00000000..3cf67e1d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt @@ -0,0 +1,153 @@ +package com.daedan.festabook.presentation.placeMap.waitingRegister + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.domain.repository.WaitingInfoRepository +import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingRegisterUiModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingRegisterUiState +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.toWaitingPlaceSummaryUiModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@AssistedInject +class WaitingRegisterViewModel( + private val placeDetailRepository: PlaceDetailRepository, + private val waitingInfoRepository: WaitingInfoRepository, + private val waitingRegisterInfoRepository: WaitingRegisterInfoRepository, + @Assisted private val placeId: Long, +) : ViewModel() { + @AssistedFactory + @ViewModelAssistedFactoryKey(WaitingRegisterViewModel::class) + @ContributesIntoMap(AppScope::class) + interface Factory : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): WaitingRegisterViewModel = + create(placeId = extras[PlaceIdKey] ?: error("placeId must be required")) + + fun create(placeId: Long): WaitingRegisterViewModel + } + + private val _uiState = MutableStateFlow(WaitingRegisterUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _registerSuccessEvent = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val registerSuccessEvent: SharedFlow = _registerSuccessEvent.asSharedFlow() + + private val _registerFailureEvent = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val registerFailureEvent: SharedFlow = _registerFailureEvent.asSharedFlow() + + private val _navigateToPhoneRegistrationEvent = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigateToPhoneRegistrationEvent: SharedFlow = + _navigateToPhoneRegistrationEvent.asSharedFlow() + + init { + loadPlaceSummary() + } + + private fun loadPlaceSummary() { + viewModelScope.launch { + _uiState.value = WaitingRegisterUiState.Loading + waitingInfoRepository + .getWaitingInfo() + .onSuccess { info -> + if (info == null) { + _navigateToPhoneRegistrationEvent.tryEmit(Unit) + return@launch + } + }.onFailure { _uiState.value = WaitingRegisterUiState.Error(it) } + placeDetailRepository + .getPlaceDetail(placeId) + .onSuccess { placeDetail -> + _uiState.value = + WaitingRegisterUiState.Success( + waitingRegister = + WaitingRegisterUiModel( + placeSummary = placeDetail.toWaitingPlaceSummaryUiModel(), + ).withDerivedState(), + ) + }.onFailure { throwable -> + _uiState.value = WaitingRegisterUiState.Error(throwable) + } + } + } + + fun increasePartySize() { + updateRegister { current -> + if (!current.canIncreasePartySize) return@updateRegister current + current.copy(partySize = current.partySize + 1).withDerivedState() + } + } + + fun decreasePartySize() { + updateRegister { current -> + if (!current.canDecreasePartySize) return@updateRegister current + current.copy(partySize = current.partySize - 1).withDerivedState() + } + } + + fun toggleServiceAgreement() { + updateRegister { current -> + current.copy(isServiceAgreed = !current.isServiceAgreed).withDerivedState() + } + } + + fun submitWaitingRegister() { + val current = _uiState.value + if (current !is WaitingRegisterUiState.Success) return + if (!current.waitingRegister.canSubmit) return + + viewModelScope.launch { + updateRegister { it.copy(isSubmitting = true).withDerivedState() } + + runCatching { + waitingRegisterInfoRepository + .registerWaiting( + placeId = placeId, + partySize = current.waitingRegister.partySize, + ).getOrThrow() + }.onSuccess { + updateRegister { it.copy(isSubmitting = false).withDerivedState() } + _registerSuccessEvent.tryEmit(Unit) + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + updateRegister { it.copy(isSubmitting = false).withDerivedState() } + _registerFailureEvent.tryEmit(throwable) + } + } + } + + private inline fun updateRegister(transform: (WaitingRegisterUiModel) -> WaitingRegisterUiModel) { + _uiState.update { state -> + if (state !is WaitingRegisterUiState.Success) return@update state + state.copy(waitingRegister = transform(state.waitingRegister)) + } + } + + // TODO 도메인 로직으로 이동 + private fun WaitingRegisterUiModel.withDerivedState(): WaitingRegisterUiModel = + copy( + canDecreasePartySize = partySize > WaitingRegisterUiModel.MIN_PARTY_SIZE, + canIncreasePartySize = partySize < WaitingRegisterUiModel.MAX_PARTY_SIZE, + canSubmit = isServiceAgreed && !isSubmitting, + ) + + object PlaceIdKey : CreationExtras.Key +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterConfirmBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterConfirmBottomSheet.kt new file mode 100644 index 00000000..cd411844 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterConfirmBottomSheet.kt @@ -0,0 +1,192 @@ +package com.daedan.festabook.presentation.placeMap.waitingRegister.component + +import androidx.compose.foundation.Image +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 +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.img_waiting_push_notification_alert +import festabookkmp.composeapp.generated.resources.waiting_register_confirm_checkbox +import festabookkmp.composeapp.generated.resources.waiting_register_confirm_description +import festabookkmp.composeapp.generated.resources.waiting_register_confirm_later +import festabookkmp.composeapp.generated.resources.waiting_register_confirm_submit +import festabookkmp.composeapp.generated.resources.waiting_register_confirm_title +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WaitingRegisterConfirmBottomSheet( + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var isChecked by rememberSaveable { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = FestabookColor.white, + modifier = modifier, + dragHandle = {}, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Image( + painter = painterResource(Res.drawable.img_waiting_push_notification_alert), + contentDescription = null, + modifier = + Modifier + .padding(vertical = festabookSpacing.paddingTitleHorizontal) + .size(100.dp), + ) + + Text( + text = stringResource(Res.string.waiting_register_confirm_title), + style = FestabookTypography.displaySmall, + color = FestabookColor.black, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody2)) + + Text( + text = stringResource(Res.string.waiting_register_confirm_description), + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { isChecked = !isChecked }, + ) { + Checkbox( + modifier = Modifier.clip(festabookShapes.radius1), + checked = isChecked, + onCheckedChange = { isChecked = it }, + colors = + CheckboxDefaults.colors( + checkedColor = FestabookColor.black, + uncheckedColor = FestabookColor.gray400, + checkmarkColor = FestabookColor.white, + ), + ) + Text( + text = stringResource(Res.string.waiting_register_confirm_checkbox), + style = FestabookTypography.bodyMedium, + color = FestabookColor.black, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody4), + ) { + ConfirmBottomSheetButton( + text = stringResource(Res.string.waiting_register_confirm_later), + isEnabled = true, + isOutlined = true, + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) + + ConfirmBottomSheetButton( + text = stringResource(Res.string.waiting_register_confirm_submit), + isEnabled = isChecked, + isOutlined = false, + onClick = { + if (isChecked) { + onConfirm() + } + }, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun ConfirmBottomSheetButton( + text: String, + isEnabled: Boolean, + isOutlined: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(festabookShapes.radius2) + .then( + if (isOutlined) { + Modifier + .border( + width = 1.dp, + color = FestabookColor.gray300, + shape = festabookShapes.radius2, + ).background(FestabookColor.white) + } else { + Modifier.background( + if (isEnabled) FestabookColor.black else FestabookColor.gray300, + ) + }, + ).clickable(enabled = isEnabled || isOutlined) { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody4), + text = text, + style = FestabookTypography.displaySmall, + color = + when { + isOutlined -> FestabookColor.black + isEnabled -> FestabookColor.white + else -> FestabookColor.white + }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt new file mode 100644 index 00000000..82eed21f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt @@ -0,0 +1,488 @@ +package com.daedan.festabook.presentation.placeMap.waitingRegister.component + +import androidx.compose.foundation.Image +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 +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +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.platform.LocalUriHandler +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.PermissionState +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.ErrorStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.placeMap.component.PlaceDetailPreviewContent +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.WaitingRegisterViewModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingPlaceSummaryUiModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingRegisterUiState +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.component.NotificationPermissionDialog +import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.btn_back_to_previous +import festabookkmp.composeapp.generated.resources.content_description_waiting_party_size_decrease +import festabookkmp.composeapp.generated.resources.content_description_waiting_party_size_increase +import festabookkmp.composeapp.generated.resources.content_description_waiting_register_back +import festabookkmp.composeapp.generated.resources.waiting_register_agreement_service +import festabookkmp.composeapp.generated.resources.waiting_register_agreement_title +import festabookkmp.composeapp.generated.resources.waiting_register_party_size_limit_notice +import festabookkmp.composeapp.generated.resources.waiting_register_party_size_title +import festabookkmp.composeapp.generated.resources.waiting_register_submit +import festabookkmp.composeapp.generated.resources.waiting_register_success +import festabookkmp.composeapp.generated.resources.waiting_register_title +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +private const val PRIVACY_AGREEMENT_URL = + "https://www.notion.so/2026-04-01-335a540dc0b780fa9897e0a3bdf45bae" + +@Composable +fun WaitingRegisterRoute( + viewModel: WaitingRegisterViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onBackToPreviousClick: () -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + onShowSnackbar: (String) -> Unit, + onNavigateToPhoneRegistration: () -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isNotificationAllowed by settingViewModel.isAllowed.collectAsStateWithLifecycle() + val successMessage = stringResource(Res.string.waiting_register_success) + var showConfirmBottomSheet by rememberSaveable { mutableStateOf(false) } + var showPermissionDialog by remember { mutableStateOf(false) } + val onOpenAppSettings = rememberOpenAppSettings() + + // TODO 나의 웨이팅 화면으로 이동 + ObserveAsEvents(viewModel.registerSuccessEvent) { + onShowSnackbar(successMessage) + onBackToPreviousClick() + } + ObserveAsEvents(viewModel.registerFailureEvent) { throwable -> + onShowErrorSnackbar(throwable) + } + ObserveAsEvents(viewModel.navigateToPhoneRegistrationEvent) { + onNavigateToPhoneRegistration() + } + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + val permission = notificationPermissionManager.checkPermission() + + when (permission) { + PermissionState.GRANTED -> { + showConfirmBottomSheet = false + settingViewModel.saveNotificationId() + viewModel.submitWaitingRegister() + } + + PermissionState.NEED_RATIONALE, PermissionState.DENIED -> { + onOpenAppSettings() + } + } + } + + WaitingRegisterScreen( + uiState = uiState, + onBackToPreviousClick = onBackToPreviousClick, + onIncreasePartySize = viewModel::increasePartySize, + onDecreasePartySize = viewModel::decreasePartySize, + onToggleServiceAgreement = viewModel::toggleServiceAgreement, + onSubmitClick = { showConfirmBottomSheet = true }, + onShowErrorSnackbar = onShowErrorSnackbar, + modifier = modifier, + ) + + if (showConfirmBottomSheet) { + WaitingRegisterConfirmBottomSheet( + onConfirm = { + if (isNotificationAllowed) { + showConfirmBottomSheet = false + viewModel.submitWaitingRegister() + } else { + showPermissionDialog = true + } + }, + onDismiss = { showConfirmBottomSheet = false }, + ) + } + + if (showPermissionDialog) { + NotificationPermissionDialog( + onConfirm = { + showPermissionDialog = false + settingViewModel.notificationAllowClick() + }, + ) + } +} + +@Composable +fun WaitingRegisterScreen( + uiState: WaitingRegisterUiState, + onBackToPreviousClick: () -> Unit, + onIncreasePartySize: () -> Unit, + onDecreasePartySize: () -> Unit, + onToggleServiceAgreement: () -> Unit, + onSubmitClick: () -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + val state = rememberNavigationEventState(NavigationEventInfo.None) + val isSubmitting = (uiState as? WaitingRegisterUiState.Success)?.waitingRegister?.isSubmitting ?: false + + NavigationBackHandler( + state = state, + isBackEnabled = !isSubmitting, + ) { + onBackToPreviousClick() + } + + LaunchedEffect(uiState) { + if (uiState is WaitingRegisterUiState.Error) { + currentOnShowErrorSnackbar(uiState.throwable) + } + } + + Column(modifier = modifier.fillMaxSize()) { + WaitingRegisterTopBar(onBackClick = onBackToPreviousClick) + + when (uiState) { + is WaitingRegisterUiState.Loading -> { + Box(modifier = Modifier.weight(1f)) { + LoadingStateScreen() + } + } + + is WaitingRegisterUiState.Error -> { + Box(modifier = Modifier.weight(1f)) { + ErrorStateScreen() + } + } + + is WaitingRegisterUiState.Success -> { + val waitingRegister = uiState.waitingRegister + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + WaitingPlaceSummaryCard( + summary = waitingRegister.placeSummary, + modifier = Modifier.padding(top = 40.dp), + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 24.dp), + thickness = 4.dp, + color = FestabookColor.gray200, + ) + + PartySizeSection( + partySize = waitingRegister.partySize, + canDecrease = waitingRegister.canDecreasePartySize, + canIncrease = waitingRegister.canIncreasePartySize, + onDecrease = onDecreasePartySize, + onIncrease = onIncreasePartySize, + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 24.dp), + thickness = 4.dp, + color = FestabookColor.gray200, + ) + + AgreementSection( + isAgreed = waitingRegister.isServiceAgreed, + onToggle = onToggleServiceAgreement, + ) + + Spacer(modifier = Modifier.height(80.dp)) + } + + WaitingRegisterSubmitButton( + isEnabled = waitingRegister.canSubmit, + isSubmitting = waitingRegister.isSubmitting, + onClick = onSubmitClick, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + } + } +} + +@Composable +private fun WaitingRegisterTopBar( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .height(56.dp), + ) { + Image( + modifier = + Modifier + .align(Alignment.CenterStart) + .padding(start = festabookSpacing.paddingScreenGutter) + .size(30.dp) + .clickable { onBackClick() }, + painter = painterResource(Res.drawable.btn_back_to_previous), + contentDescription = stringResource(Res.string.content_description_waiting_register_back), + ) + Text( + modifier = Modifier.align(Alignment.Center), + text = stringResource(Res.string.waiting_register_title), + style = FestabookTypography.titleMedium, + ) + } +} + +@Composable +private fun WaitingPlaceSummaryCard( + summary: WaitingPlaceSummaryUiModel, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .cardBackground( + backgroundColor = FestabookColor.white, + shape = festabookShapes.radius4, + ), + ) { + PlaceDetailPreviewContent( + placeDetail = + PlaceDetailUiModel( + place = + PlaceUiModel( + id = summary.placeId, + imageUrl = summary.imageUrl, + category = summary.category, + title = summary.title, + description = summary.description, + location = summary.location, + isBookmarked = false, + timeTagId = emptyList(), + ), + notices = emptyList(), + host = summary.host, + startTime = summary.startTime, + endTime = summary.endTime, + images = emptyList(), + ), + ) + } +} + +@Composable +private fun PartySizeSection( + partySize: Int, + canDecrease: Boolean, + canIncrease: Boolean, + onDecrease: () -> Unit, + onIncrease: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.waiting_register_party_size_title), + style = FestabookTypography.displayMedium, + ) + + Row( + modifier = Modifier.padding(top = festabookSpacing.paddingBody4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + PartySizeButton( + label = "-", + enabled = canDecrease, + onClick = onDecrease, + contentDescription = stringResource(Res.string.content_description_waiting_party_size_decrease), + ) + + Text( + text = partySize.toString(), + style = FestabookTypography.titleLarge.copy(fontSize = 40.sp), + ) + + PartySizeButton( + label = "+", + enabled = canIncrease, + onClick = onIncrease, + contentDescription = stringResource(Res.string.content_description_waiting_party_size_increase), + ) + } + + Text( + modifier = Modifier.padding(top = festabookSpacing.paddingBody2), + text = stringResource(Res.string.waiting_register_party_size_limit_notice), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun PartySizeButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, + contentDescription: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .size(44.dp) + .border( + width = 1.dp, + color = if (enabled) FestabookColor.gray400 else FestabookColor.gray200, + shape = CircleShape, + ).clip(CircleShape) + .clickable(enabled = enabled, onClickLabel = contentDescription) { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = TextStyle(fontSize = 28.sp), + color = if (enabled) FestabookColor.gray500 else FestabookColor.gray300, + ) + } +} + +@Composable +private fun AgreementSection( + isAgreed: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = festabookSpacing.paddingScreenGutter), + ) { + Text( + text = stringResource(Res.string.waiting_register_agreement_title), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody1)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + modifier = Modifier.clip(festabookShapes.radius1), + checked = isAgreed, + onCheckedChange = { onToggle() }, + colors = + CheckboxDefaults.colors( + checkedColor = FestabookColor.black, + uncheckedColor = FestabookColor.gray400, + checkmarkColor = FestabookColor.white, + ), + ) + Text( + modifier = Modifier.clickable { uriHandler.openUri(PRIVACY_AGREEMENT_URL) }, + text = stringResource(Res.string.waiting_register_agreement_service), + style = FestabookTypography.bodyMedium.copy(textDecoration = TextDecoration.Underline), + color = FestabookColor.gray600, + ) + } + } +} + +@Composable +private fun WaitingRegisterSubmitButton( + isEnabled: Boolean, + isSubmitting: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.paddingBody4, + ).fillMaxWidth() + .height(52.dp) + .clip(festabookShapes.radius2) + .background(if (isEnabled) FestabookColor.black else FestabookColor.gray300) + .clickable(enabled = isEnabled) { onClick() }, + contentAlignment = Alignment.Center, + ) { + if (isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = FestabookColor.white, + strokeWidth = 4.dp, + ) + } else { + Text( + text = stringResource(Res.string.waiting_register_submit), + style = FestabookTypography.displayMedium, + color = FestabookColor.white, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingPlaceSummaryUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingPlaceSummaryUiModel.kt new file mode 100644 index 00000000..0009ef86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingPlaceSummaryUiModel.kt @@ -0,0 +1,31 @@ +package com.daedan.festabook.presentation.placeMap.waitingRegister.model + +import com.daedan.festabook.domain.model.PlaceDetail +import com.daedan.festabook.presentation.common.format.toFormattedString +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel + +data class WaitingPlaceSummaryUiModel( + val placeId: Long, + val title: String?, + val description: String?, + val category: PlaceCategoryUiModel, + val location: String?, + val host: String?, + val startTime: String?, + val endTime: String?, + val imageUrl: String?, +) + +fun PlaceDetail.toWaitingPlaceSummaryUiModel(): WaitingPlaceSummaryUiModel = + WaitingPlaceSummaryUiModel( + placeId = place.id, + title = place.title, + description = place.description, + category = place.category.toUiModel(), + location = place.location, + host = host, + startTime = startTime.toFormattedString(), + endTime = endTime.toFormattedString(), + imageUrl = sortedImages.firstOrNull()?.imageUrl, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiModel.kt new file mode 100644 index 00000000..540cecaa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiModel.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.presentation.placeMap.waitingRegister.model + +data class WaitingRegisterUiModel( + val placeSummary: WaitingPlaceSummaryUiModel, + val partySize: Int = MIN_PARTY_SIZE, + val isServiceAgreed: Boolean = false, + val isSubmitting: Boolean = false, + val canDecreasePartySize: Boolean = false, + val canIncreasePartySize: Boolean = true, + val canSubmit: Boolean = false, +) { + companion object { + const val MIN_PARTY_SIZE = 1 + const val MAX_PARTY_SIZE = 8 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt new file mode 100644 index 00000000..48ef4366 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt @@ -0,0 +1,13 @@ +package com.daedan.festabook.presentation.placeMap.waitingRegister.model + +sealed interface WaitingRegisterUiState { + data object Loading : WaitingRegisterUiState + + data class Success( + val waitingRegister: WaitingRegisterUiModel, + ) : WaitingRegisterUiState + + data class Error( + val throwable: Throwable, + ) : WaitingRegisterUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt index 9ae23d3d..2fd69669 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import androidx.navigation.toRoute import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.main.FestabookRoute @@ -15,6 +16,9 @@ import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.setting.component.SettingRoute import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel import com.daedan.festabook.presentation.setting.waitinginfo.component.AddWaitingInfoRoute +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.setting_waiting_info_save_success +import org.jetbrains.compose.resources.stringResource fun NavGraphBuilder.settingNavGraph( innerPadding: PaddingValues, @@ -25,6 +29,7 @@ fun NavGraphBuilder.settingNavGraph( onShowSnackBar: (String) -> Unit, onShowErrorSnackBar: (Throwable) -> Unit, onNavigateToAddWaitingInfo: () -> Unit, + onNavigateToWaitingRegister: (Long) -> Unit, onBackClick: () -> Unit, ) { composable( @@ -42,12 +47,23 @@ fun NavGraphBuilder.settingNavGraph( onPhoneNumberClick = onNavigateToAddWaitingInfo, ) } - composable { + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val saveSuccessMessage = stringResource(Res.string.setting_waiting_info_save_success) + AddWaitingInfoRoute( viewModel = waitingInfoViewModel, onBackClick = onBackClick, - onShowSnackBar = onShowSnackBar, onShowErrorSnackBar = onShowErrorSnackBar, + onSaveSuccess = { + val placeId = route.placeId + if (placeId != null) { + onNavigateToWaitingRegister(placeId) + } else { + onShowSnackBar(saveSuccessMessage) + onBackClick() + } + }, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt index 05db1d1f..994181e6 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt @@ -36,13 +36,14 @@ class WaitingInfoViewModel( private val _isTermsAgreed = MutableStateFlow(false) val isTermsAgreed: StateFlow = _isTermsAgreed.asStateFlow() - private val isSaving = MutableStateFlow(false) + private val _isSaving = MutableStateFlow(false) + val isSaving: StateFlow = _isSaving.asStateFlow() val isSaveEnabled: StateFlow = combine( _phoneNumber, _isTermsAgreed, - isSaving, + _isSaving, ) { phone, terms, saving -> phone.count { it.isDigit() } >= MIN_DIGIT_COUNT && terms && !saving }.stateIn( @@ -67,12 +68,12 @@ class WaitingInfoViewModel( } fun saveWaitingInfo(phoneNumber: String) { - if (isSaving.value) return + if (_isSaving.value) return val currentState = _waitingInfoUiState.value if (currentState is WaitingInfoUiState.Loading) return viewModelScope.launch { - isSaving.value = true + _isSaving.value = true val result = if (currentState is WaitingInfoUiState.Registered) { waitingInfoRepository.updateWaitingInfo(WaitingInfo(phoneNumber)) @@ -87,7 +88,7 @@ class WaitingInfoViewModel( }.onFailure { throwable -> _errorEvent.emit(throwable) }.also { - isSaving.value = false + _isSaving.value = false } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt index 5733fe7e..fd320f25 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Phone @@ -19,6 +20,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -63,7 +65,6 @@ import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_term import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_terms_title import festabookkmp.composeapp.generated.resources.setting_waiting_info_add_title import festabookkmp.composeapp.generated.resources.setting_waiting_info_back -import festabookkmp.composeapp.generated.resources.setting_waiting_info_save_success import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -75,19 +76,18 @@ private const val TERMS_URL = fun AddWaitingInfoRoute( viewModel: WaitingInfoViewModel, onBackClick: () -> Unit, - onShowSnackBar: (String) -> Unit, onShowErrorSnackBar: (Throwable) -> Unit, modifier: Modifier = Modifier, + onSaveSuccess: () -> Unit = onBackClick, ) { val waitingInfoUiState by viewModel.waitingInfoUiState.collectAsStateWithLifecycle() val phoneNumber by viewModel.phoneNumber.collectAsStateWithLifecycle() val isTermsAgreed by viewModel.isTermsAgreed.collectAsStateWithLifecycle() val isSaveEnabled by viewModel.isSaveEnabled.collectAsStateWithLifecycle() - val saveSuccessMessage = stringResource(Res.string.setting_waiting_info_save_success) + val isSaving by viewModel.isSaving.collectAsStateWithLifecycle() ObserveAsEvents(flow = viewModel.saveSuccessEvent) { - onShowSnackBar(saveSuccessMessage) - onBackClick() + onSaveSuccess() } ObserveAsEvents(flow = viewModel.errorEvent) { onShowErrorSnackBar(it) @@ -110,6 +110,7 @@ fun AddWaitingInfoRoute( phoneNumber = phoneNumber, isTermsAgreed = isTermsAgreed, isSaveEnabled = isSaveEnabled, + isSaving = isSaving, onPhoneNumberChange = viewModel::updatePhoneNumber, onTermsAgreedChange = viewModel::setTermsAgreed, onSaveClick = viewModel::saveWaitingInfo, @@ -125,6 +126,7 @@ fun AddWaitingInfoScreen( phoneNumber: String, isTermsAgreed: Boolean, isSaveEnabled: Boolean, + isSaving: Boolean, onPhoneNumberChange: (String) -> Unit, onTermsAgreedChange: (Boolean) -> Unit, onSaveClick: (String) -> Unit, @@ -219,6 +221,7 @@ fun AddWaitingInfoScreen( ConfirmButton( phoneNumber = phoneNumber, isSaveEnabled = isSaveEnabled, + isSaving = isSaving, onSaveClick = onSaveClick, ) } @@ -330,6 +333,7 @@ private fun WaitingInfoAddTerms( private fun ConfirmButton( phoneNumber: String, isSaveEnabled: Boolean, + isSaving: Boolean, onSaveClick: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -349,11 +353,19 @@ private fun ConfirmButton( disabledContentColor = FestabookColor.gray400, ), ) { - Text( - text = stringResource(Res.string.setting_waiting_info_add_button), - style = FestabookTypography.displaySmall, - modifier = Modifier.padding(vertical = festabookSpacing.paddingBody2), - ) + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = FestabookColor.white, + strokeWidth = 4.dp, + ) + } else { + Text( + text = stringResource(Res.string.setting_waiting_info_add_button), + style = FestabookTypography.displaySmall, + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody2), + ) + } } } @@ -364,6 +376,7 @@ private fun AddWaitingInfoScreenEmptyPreview() { phoneNumber = "", isTermsAgreed = false, isSaveEnabled = false, + isSaving = false, onPhoneNumberChange = {}, onTermsAgreedChange = {}, onSaveClick = {}, @@ -378,6 +391,7 @@ private fun AddWaitingInfoScreenFilledPreview() { phoneNumber = "010-1234-5678", isTermsAgreed = true, isSaveEnabled = true, + isSaving = false, onPhoneNumberChange = {}, onTermsAgreedChange = {}, onSaveClick = {}, diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt index 25a7b706..bb819bdd 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt @@ -1,11 +1,13 @@ package com.daedan.festabook.placeMap.placeDetail +import com.daedan.festabook.domain.model.MyWaiting import com.daedan.festabook.domain.model.Place import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.domain.model.PlaceDetail import com.daedan.festabook.domain.model.PlaceDetailImage import com.daedan.festabook.domain.model.PlaceWaiting import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.model.WaitingStatus import com.daedan.festabook.news.FAKE_NOTICES import com.daedan.festabook.placeMap.FAKE_PLACES import kotlinx.datetime.LocalTime @@ -61,3 +63,14 @@ val FAKE_ETC_PLACE_DETAIL = images = emptyList(), isWaitingActive = false, ) + +val FAKE_MY_WAITING = + MyWaiting( + waitingId = 42L, + waitingOrder = 3, + partySize = 2, + waitingStatus = WaitingStatus.WAITING, + totalWaitingTeams = 10, + estimatedWaitTime = 15, + phoneNumber = "010-1234-5678", + ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt index c06bd882..dda2a6ee 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailViewModelTest.kt @@ -1,8 +1,10 @@ package com.daedan.festabook.placeMap.placeDetail +import com.daedan.festabook.domain.repository.MyWaitingRepository import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository import com.daedan.festabook.news.FAKE_NOTICES +import com.daedan.festabook.observeEvent import com.daedan.festabook.placeMap.FAKE_PLACES import com.daedan.festabook.presentation.news.notice.model.toUiModel import com.daedan.festabook.presentation.placeMap.placeDetail.PlaceDetailViewModel @@ -35,6 +37,7 @@ class PlaceDetailViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var placeDetailRepository: PlaceDetailRepository private lateinit var waitingRegisterInfoRepository: WaitingRegisterInfoRepository + private lateinit var myWaitingRepository: MyWaitingRepository private lateinit var placeDetailViewModel: PlaceDetailViewModel @BeforeTest @@ -42,16 +45,20 @@ class PlaceDetailViewModelTest { Dispatchers.setMain(testDispatcher) placeDetailRepository = mock() waitingRegisterInfoRepository = mock() + myWaitingRepository = mock() everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns Result.success( FAKE_PLACE_DETAIL, ) everySuspend { waitingRegisterInfoRepository.getPlaceWaiting(any()) } returns Result.success(FAKE_PLACE_WAITING) + everySuspend { myWaitingRepository.getMyWaiting() } returns Result.success(null) + everySuspend { myWaitingRepository.cancelWaiting(any()) } returns Result.success(Unit) placeDetailViewModel = PlaceDetailViewModel( placeDetailRepository, waitingRegisterInfoRepository, + myWaitingRepository, FAKE_PLACES.first().id, ) } @@ -121,6 +128,7 @@ class PlaceDetailViewModelTest { PlaceDetailViewModel( placeDetailRepository, waitingRegisterInfoRepository, + myWaitingRepository, expected.place.id, ) advanceUntilIdle() @@ -155,4 +163,76 @@ class PlaceDetailViewModelTest { assertEquals(expected, actual) } + + @Test + fun `onRegisterWaitingClick 호출 시 등록된 웨이팅이 없으면 navigateToWaitingRegisterEvent 를 발행한다`() = + runTest { + // given + advanceUntilIdle() + everySuspend { myWaitingRepository.getMyWaiting() } returns Result.success(null) + + // when + val event = observeEvent(placeDetailViewModel.navigateToWaitingRegisterEvent) + placeDetailViewModel.onRegisterWaitingClick() + advanceUntilIdle() + + // then + assertEquals(FAKE_PLACES.first().id, event.await()) + } + + @Test + fun `onRegisterWaitingClick 호출 시 이미 등록된 웨이팅이 있으면 showDuplicateWaitingBottomSheetEvent 를 발행한다`() = + runTest { + // given + advanceUntilIdle() + everySuspend { myWaitingRepository.getMyWaiting() } returns + Result.success( + FAKE_MY_WAITING, + ) + + // when + val event = observeEvent(placeDetailViewModel.showDuplicateWaitingBottomSheetEvent) + placeDetailViewModel.onRegisterWaitingClick() + advanceUntilIdle() + + // then + assertEquals(FAKE_MY_WAITING.waitingId, event.await()) + } + + @Test + fun `cancelAndRegister 성공 시 cancelWaiting 호출 후 navigateToWaitingRegisterEvent 를 발행한다`() = + runTest { + // given + advanceUntilIdle() + everySuspend { myWaitingRepository.cancelWaiting(any()) } returns Result.success(Unit) + + // when + val event = observeEvent(placeDetailViewModel.navigateToWaitingRegisterEvent) + placeDetailViewModel.cancelAndRegister(FAKE_MY_WAITING.waitingId) + advanceUntilIdle() + + // then + verifySuspend { myWaitingRepository.cancelWaiting(FAKE_MY_WAITING.waitingId) } + assertEquals(FAKE_PLACES.first().id, event.await()) + } + + @Test + fun `cancelAndRegister 실패 시 cancelWaitingFailureEvent 를 발행한다`() = + runTest { + // given + advanceUntilIdle() + val exception = Throwable("웨이팅 취소 실패") + everySuspend { myWaitingRepository.cancelWaiting(any()) } returns + Result.failure( + exception, + ) + + // when + val event = observeEvent(placeDetailViewModel.cancelWaitingFailureEvent) + placeDetailViewModel.cancelAndRegister(FAKE_MY_WAITING.waitingId) + advanceUntilIdle() + + // then + assertEquals(exception, event.await()) + } } diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt new file mode 100644 index 00000000..aa5c4f11 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt @@ -0,0 +1,310 @@ +package com.daedan.festabook.viewModel.placeMap.waitingRegister + +import com.daedan.festabook.domain.model.WaitingInfo +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.domain.repository.WaitingInfoRepository +import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.placeMap.placeDetail.FAKE_MY_WAITING +import com.daedan.festabook.placeMap.placeDetail.FAKE_PLACE_DETAIL +import com.daedan.festabook.presentation.placeMap.waitingRegister.WaitingRegisterViewModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingRegisterUiModel +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingRegisterUiState +import com.daedan.festabook.presentation.placeMap.waitingRegister.model.toWaitingPlaceSummaryUiModel +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify.VerifyMode +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WaitingRegisterViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var placeDetailRepository: PlaceDetailRepository + private lateinit var waitingInfoRepository: WaitingInfoRepository + private lateinit var waitingRegisterInfoRepository: WaitingRegisterInfoRepository + private lateinit var viewModel: WaitingRegisterViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + placeDetailRepository = mock() + waitingInfoRepository = mock() + waitingRegisterInfoRepository = mock() + everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + everySuspend { waitingInfoRepository.getWaitingInfo() } returns + Result.success( + WaitingInfo( + phoneNumber = "010-1234-5678", + ), + ) + viewModel = + WaitingRegisterViewModel( + placeDetailRepository = placeDetailRepository, + waitingInfoRepository = waitingInfoRepository, + waitingRegisterInfoRepository = waitingRegisterInfoRepository, + placeId = FAKE_PLACE_DETAIL.place.id, + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `전화번호 미등록 상태에서 초기화 시 navigateToPhoneRegistrationEvent 를 발행한다`() = + runTest(UnconfinedTestDispatcher()) { + // given + everySuspend { waitingInfoRepository.getWaitingInfo() } returns Result.success(null) + viewModel = + WaitingRegisterViewModel( + placeDetailRepository = placeDetailRepository, + waitingInfoRepository = waitingInfoRepository, + waitingRegisterInfoRepository = waitingRegisterInfoRepository, + placeId = FAKE_PLACE_DETAIL.place.id, + ) + + // when + val event = observeEvent(viewModel.navigateToPhoneRegistrationEvent) + advanceUntilIdle() + + // then + assertEquals(Unit, event.await()) + } + + @Test + fun `초기화 시 PlaceDetail 로드에 성공하면 Success 상태가 된다`() = + runTest { + // when + advanceUntilIdle() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals( + FAKE_PLACE_DETAIL.toWaitingPlaceSummaryUiModel(), + state.waitingRegister.placeSummary, + ) + } + + @Test + fun `초기화 시 PlaceDetail 로드에 실패하면 Error 상태가 된다`() = + runTest { + // given + val exception = Throwable("로드 실패") + everySuspend { placeDetailRepository.getPlaceDetail(any()) } returns + Result.failure( + exception, + ) + + // when + viewModel = + WaitingRegisterViewModel( + placeDetailRepository = placeDetailRepository, + waitingInfoRepository = waitingInfoRepository, + waitingRegisterInfoRepository = waitingRegisterInfoRepository, + placeId = FAKE_PLACE_DETAIL.place.id, + ) + advanceUntilIdle() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals(exception, state.throwable) + } + + @Test + fun `increasePartySize 호출 시 partySize 가 1 증가한다`() = + runTest { + // given + advanceUntilIdle() + + // when + viewModel.increasePartySize() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals(WaitingRegisterUiModel.MIN_PARTY_SIZE + 1, state.waitingRegister.partySize) + } + + @Test + fun `partySize 가 MAX 일 때 increasePartySize 를 호출해도 변경되지 않는다`() = + runTest { + // given + advanceUntilIdle() + repeat(WaitingRegisterUiModel.MAX_PARTY_SIZE - WaitingRegisterUiModel.MIN_PARTY_SIZE) { + viewModel.increasePartySize() + } + + // when + viewModel.increasePartySize() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals(WaitingRegisterUiModel.MAX_PARTY_SIZE, state.waitingRegister.partySize) + } + + @Test + fun `decreasePartySize 호출 시 partySize 가 1 감소한다`() = + runTest { + // given + advanceUntilIdle() + viewModel.increasePartySize() + + // when + viewModel.decreasePartySize() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals(WaitingRegisterUiModel.MIN_PARTY_SIZE, state.waitingRegister.partySize) + } + + @Test + fun `partySize 가 MIN 일 때 decreasePartySize 를 호출해도 변경되지 않는다`() = + runTest { + // given + advanceUntilIdle() + + // when + viewModel.decreasePartySize() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals(WaitingRegisterUiModel.MIN_PARTY_SIZE, state.waitingRegister.partySize) + } + + @Test + fun `toggleServiceAgreement 호출 시 isServiceAgreed 가 토글된다`() = + runTest { + // given + advanceUntilIdle() + + // when + viewModel.toggleServiceAgreement() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertTrue(state.waitingRegister.isServiceAgreed) + } + + @Test + fun `약관 미동의 상태에서 submitWaitingRegister 를 호출해도 API 를 호출하지 않는다`() = + runTest { + // given + advanceUntilIdle() + + // when - isServiceAgreed 가 false 인 기본 상태 + viewModel.submitWaitingRegister() + advanceUntilIdle() + + // then + verifySuspend(VerifyMode.not) { + waitingRegisterInfoRepository.registerWaiting(any(), any()) + } + } + + @Test + fun `약관 동의 후 submitWaitingRegister 성공 시 registerSuccessEvent 를 발행한다`() = + runTest { + // given + advanceUntilIdle() + everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns + Result.success(FAKE_MY_WAITING) + viewModel.toggleServiceAgreement() + + // when + val event = observeEvent(viewModel.registerSuccessEvent) + viewModel.submitWaitingRegister() + advanceUntilIdle() + + // then + assertEquals(Unit, event.await()) + } + + @Test + fun `submitWaitingRegister 성공 후 Success 상태가 유지되고 isSubmitting 이 false 가 된다`() = + runTest { + // given + advanceUntilIdle() + everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns + Result.success(FAKE_MY_WAITING) + viewModel.toggleServiceAgreement() + + // when + viewModel.submitWaitingRegister() + advanceUntilIdle() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals(false, state.waitingRegister.isSubmitting) + } + + @Test + fun `partySize 를 변경하면 registerWaiting 에 변경된 값이 전달된다`() = + runTest { + // given + advanceUntilIdle() + everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns + Result.success(FAKE_MY_WAITING) + viewModel.increasePartySize() + viewModel.increasePartySize() + viewModel.toggleServiceAgreement() + + // when + viewModel.submitWaitingRegister() + advanceUntilIdle() + + // then + verifySuspend { + waitingRegisterInfoRepository.registerWaiting( + placeId = FAKE_PLACE_DETAIL.place.id, + partySize = WaitingRegisterUiModel.MIN_PARTY_SIZE + 2, + ) + } + } + + @Test + fun `submitWaitingRegister 실패 시 registerFailureEvent 를 발행하고 Success 상태를 유지한다`() = + runTest { + // given + advanceUntilIdle() + val exception = Throwable("등록 실패") + everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns + Result.failure(exception) + viewModel.toggleServiceAgreement() + + // when + val event = observeEvent(viewModel.registerFailureEvent) + viewModel.submitWaitingRegister() + advanceUntilIdle() + + // then + assertEquals(exception, event.await()) + assertIs(viewModel.uiState.value) + } +} From 28c41d14416aff74847124e1a4ca3cb137fd71e2 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:07:27 +0900 Subject: [PATCH 27/54] =?UTF-8?q?[Feat]=20=EB=82=98=EC=9D=98=20=EC=9B=A8?= =?UTF-8?q?=EC=9D=B4=ED=8C=85=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: '나의 웨이팅' 화면 및 홈 웨이팅바 기본 구조 구현 - MyWaitingViewModel/UiState/Screen 및 WaitingStatusCard 추가 - MyWaitingNavigation 경로 등록 및 FestabookRoute 연동 - 홈 웨이팅바(HomeWaitingBar) UI 및 WaitingBarUiState 추가 - HomeViewModel·HomeScreen·MainScreen 연동 * [Test] 웨이팅 로컬/리모트 레포지토리 및 HomeViewModel 테스트 추가 Co-Authored-By: Claude Sonnet 4.6 * refactor: 홈 웨이팅바 및 '나의 웨이팅' 화면 UI 고도화 - 홈 웨이팅바 디자인 개선 (ic_waiting_bar 적용, 레이아웃 구조 개선) - '나의 웨이팅' 화면 UI 고도화 (MyWaitingScreen, WaitingStatusCard) 및 데이터 구조 개선 * feat: 웨이팅 등록/취소 흐름 통합 및 다이얼로그 범용성 개선 - 웨이팅 등록 성공 및 중복 시 '나의 웨이팅' 화면으로 이동하는 네비게이션 연결 - WaitingCancelConfirmDialog를 범용 컴포넌트로 추출하여 '나의 웨이팅' 화면에 적용 * refactor: 로컬 저장소 기반 `placeId` 관리 로직 제거 및 서버 응답 연동 * **refactor: `placeId` 관리 방식 변경 및 관련 로컬 데이터 소스 제거** * 서버 응답(`MyWaitingResponse`)에 `placeId`가 추가됨에 따라, 로컬(`WaitingLocalDataSource`)에서 `placeId`를 별도로 저장, 조회, 삭제하던 로직과 관련 상수를 모두 제거했습니다. * `MyWaiting` 도메인 모델 및 `MyWaitingUiState.Success`에서 `placeId` 타입을 `Long?`에서 필수 값인 `Long`으로 변경했습니다. * **refactor: 레포지토리 레이어 로직 최적화** * `MyWaitingRepositoryImpl`: 웨이팅 상태 체크 시 로컬 `placeId`를 클리어하거나 별도로 조회하여 병합하던 로직을 삭제하고 서버 데이터만 사용하도록 단순화했습니다. * `WaitingRegisterInfoRepositoryImpl`: 웨이팅 등록 성공 후 로컬에 `placeId`를 저장하던 과정을 제거했습니다. * **refactor: UI 및 뷰모델 데이터 흐름 개선** * `MyWaitingViewModel` & `MyWaitingScreen`: `placeId`가 항상 존재함에 따라 관련 널 처리(Null-safe) 로직을 제거하고, 장소 상세 이동 시 UI 모델의 `placeId`를 직접 참조하도록 수정했습니다. * **test: 데이터 소스 및 레포지토리 변경 사항 반영** * `WaitingLocalDataSourceTest`: 제거된 `placeId` 저장/조회/삭제 기능에 대한 테스트 케이스를 삭제했습니다. * `MyWaitingRepositoryImplTest` & `WaitingRegisterInfoRepositoryImplTest`: 로컬 데이터 소스 의존성을 제거하고, 서버 응답에서 `placeId`를 직접 검증하도록 테스트 코드를 수정했습니다. * `HomeViewModelTest`, `PlaceDetailViewModelTest` 등: 테스트 데이터(Fixture)에 `placeId` 필수 값을 추가했습니다. * refactor: '나의 웨이팅' UiState 모델 분리 및 도메인 모델 정리 - MyWaitingUiState.Success를 MyWaitingUiModel로 분리하여 상태 구조 단순화 - 웨이팅 등록 시 알림 권한 요청 로직 세분화 (DENIED 상태 시 시스템 권한 요청) - 웨이팅 순번(Order) 계산 로직 개선 및 MyWaiting 도메인 모델 수정 * feat: 홈 화면 웨이팅 바 UI 상태에 웨이팅 상태(Status) 반영 * **feat: `WaitingBarUiState` 및 `HomeWaitingBar` 내 웨이팅 상태 정보 추가** * `WaitingBarUiState.Visible`: 현재 웨이팅의 진행 상태를 나타내는 `status` 필드를 추가했습니다. * `HomeWaitingBar`: 전달받은 `status` 값에 따라 텍스트를 다르게 표시하는 로직을 추가했습니다. 호출 상태(`CALLED`)인 경우 "지금 입장해 주세요" 메시지를 노출합니다. * **refactor: `HomeViewModel` 및 `HomeScreen` 연동 로직 수정** * `HomeViewModel`: 나의 웨이팅 정보 조회 시, 응답 모델의 `waitingStatus`를 `WaitingBarUiState`에 포함하도록 업데이트했습니다. * `HomeScreen`: `HomeWaitingBar` 호출 시 뷰모델로부터 받은 상태 값을 전달하도록 수정했습니다. * feat: FCM 알림 기반 딥링크 처리 기능 고도화 * **feat: FCM 메시지 타입 및 딥링크 액션 모델 도입** * `FcmMessageType` 열거형을 추가하여 공지사항, 웨이팅 호출, 웨이팅 거의 완료, 장소 접근 취소 등 알림 유형을 정의했습니다. * `FcmDeepLinkAction` 실드 인터페이스를 통해 공지사항 상세(`OpenAnnouncement`), 나의 웨이팅(`OpenMyWaiting`), 장소 상세(`OpenPlaceDetail`)로의 이동 액션을 구조화했습니다. * `DeepLinkKeys`에 `KEY_TYPE`, `KEY_PLACE_ID` 등 신규 키 상수를 추가했습니다. * **feat: 알림 채널 분리 및 확장 (Android)** * `NotificationHelper`: 기존 공지사항 채널 외에 웨이팅 관련 알림을 위한 `waiting_channel`을 추가로 생성하고 중요도를 높게 설정했습니다. * 알림 생성 시 `FcmDeepLinkAction`에 따라 적절한 알림 채널을 할당하고, `Intent`에 필요한 데이터를 `Extra`로 포함하도록 개선했습니다. * **refactor: 플랫폼별 딥링크 핸들링 로직 통합 및 개선** * `RememberDeepLinkHandler`: 콜백 파라미터를 단순 ID에서 `FcmDeepLinkAction` 객체를 전달하도록 변경했습니다. * **Android**: `MyFirebaseMessagingService`에서 수신된 데이터를 `FcmDeepLinkAction`으로 변환하는 로직을 추가하고, `Intent`에서 액션 정보를 추출하여 소비하는 로직을 보완했습니다. * **iOS**: `DefaultUserNotificationDelegate`에서 `userInfo`를 기반으로 `FcmDeepLinkAction`을 판별하여 `PendingFcmNotification`에 저장하도록 수정했습니다. * **feat: `MainScreen` 및 네비게이션 내 딥링크 분기 처리** * `FestabookRoute.Main`: 펜딩 상태의 장소 상세 ID(`pendingPlaceDetailId`)와 나의 웨이팅 이동 여부(`pendingMyWaiting`) 필드를 추가했습니다. * `MainScreen`: `RememberDeepLinkHandler`로부터 수신한 액션 타입에 따라 공지사항, 나의 웨이팅, 장소 상세 화면으로 이동하는 네비게이션 분기 로직을 구현했습니다. * 축제(Festival) 정보가 변경된 경우에도 펜딩된 액션을 유지하며 메인 화면을 재진입할 수 있도록 개선했습니다. * refactor: 웨이팅 등록 시 알림 권한 처리 로직 개선 및 Factory 패턴 적용 * **refactor: `NotificationPermissionManager` 생성 방식 변경 및 의존성 주입 구조 개선** * `MainScreen` 및 `placeMapNavGraph`: `NotificationPermissionManager` 인스턴스 대신 `NotificationPermissionManager.Factory`를 주입받도록 변경했습니다. * `WaitingRegisterRoute`: `rememberNotificationPermissionManager`를 사용하여 컴포지션 생명주기에 맞게 권한 매니저를 생성하도록 수정했습니다. * **refactor: 웨이팅 등록 화면(`WaitingRegisterRoute`) 내 권한 획득 콜백 로직 보완** * 알림 권한 허용 시(`onPermissionGrant`), 바텀시트를 닫고 알림 ID를 저장한 뒤 즉시 웨이팅 등록(`submitWaitingRegister`)이 진행되도록 흐름을 개선했습니다. * `SettingViewModel`과의 연동을 통해 권한 상태 변경을 동기화했습니다. * style: 코드 포맷팅 정렬 및 미사용 임포트 제거 * **style: 전반적인 코드 포맷팅 및 가독성 개선** * `WaitingRegisterInfoRepositoryImplTest`, `MyWaitingRepositoryImplTest`: 저장소 초기화 및 테스트 데이터 생성 시의 들여쓰기 스타일을 수정했습니다. * `DefaultUserNotificationDelegate.kt` (iOS): 변수 할당 및 제어문 내 줄바꿈을 적용하여 가독성을 높였습니다. * `HomeWaitingBar.kt`, `MyWaitingUiState.kt`: `when` 조건문 및 데이터 클래스 선언부의 포맷을 일관되게 정리했습니다. * **chore: 사용하지 않는 임포트 및 리소스 참조 정리** * `MyWaitingScreen.kt`: 불필요한 `border`, `RoundedCornerShape`, `Button` 관련 임포트와 사용되지 않는 문자열 리소스(`waiting_register_submit`)를 제거했습니다. * `MyWaitingNavigation.kt`: 네비게이션 설정 내에서 사용되지 않는 `Modifier`, `PaddingValues` 등의 임포트를 정리했습니다. * `WaitingLocalDataSourceTest.kt`: 파일 끝의 불필요한 공백을 제거했습니다. * refactor: 나의 웨이팅 취소 버튼 상태 연동 및 UX 개선 * **refactor: `MyWaitingScreen` 내 취소 버튼의 활성화 상태 로직 수정** * 웨이팅 취소 중인 상태(`isCanceling`)를 UI 상태와 연동하여, 취소 프로세스가 진행 중일 때 버튼이 비활성화되고 로딩 상태가 표시되도록 개선했습니다. * 기존에 `true`/`false`로 고정되어 있던 `isEnabled`와 `isCancelling` 속성을 `uiState.myWaiting.isCanceling` 값에 따라 동적으로 변하도록 수정했습니다. * refactor: `HomeViewModel` 초기화 시 `loadWaitingBar` 호출 제거 * **refactor: `HomeViewModel` 초기화 로직 수정** * 뷰모델 생성 시점에 `loadWaitingBar()`를 자동으로 호출하던 로직을 삭제했습니다. * refactor: 웨이팅 상태 카드 임계값 상수화 및 홈 웨이팅 바 그림자 UI 수정 * **refactor: `WaitingStatusCard` 내 대기 순번 임계값 상수 도입** * 매직 넘버 `3`을 `WAITING_NEAR_THRESHOLD` 상수로 정의하여 가독성을 개선했습니다. * 대기 순번에 따른 헤드라인 텍스트(`waiting_far`, `waiting_near`) 결정 로직에 해당 상수를 적용했습니다. * **style: `HomeWaitingBar` 컴포넌트 Modifier 순서 조정** * `shadow` 속성이 배경 및 클릭 이벤트보다 먼저 적용되도록 수정하여 UI 그림자 렌더링 순서를 최적화했습니다. --------- Co-authored-by: Claude Sonnet 4.6 --- .../RememberDeepLinkHandler.android.kt | 43 +- .../service/MyFirebaseMessagingService.kt | 58 ++- .../festabook/service/NotificationHelper.kt | 71 ++- .../drawable/ic_waiting_bar.xml | 14 + .../composeResources/values/strings.xml | 23 + .../local/WaitingLocalDataSource.kt | 11 - .../local/WaitingLocalDataSourceImpl.kt | 16 - .../response/waiting/MyWaitingResponse.kt | 5 +- .../repository/MyWaitingRepositoryImpl.kt | 13 +- .../WaitingRegisterInfoRepositoryImpl.kt | 10 +- .../festabook/domain/model/MyWaiting.kt | 8 +- .../presentation/home/HomeViewModel.kt | 34 +- .../presentation/home/WaitingBarUiState.kt | 13 + .../presentation/home/component/HomeScreen.kt | 51 ++- .../home/component/HomeWaitingBar.kt | 103 +++++ .../home/navigation/HomeNavigation.kt | 2 + .../presentation/main/FestabookRoute.kt | 5 + .../presentation/main/component/MainScreen.kt | 90 +++- .../main/navigation/MainNavigation.kt | 2 + .../placeMap/navigation/PlaceMapNavigation.kt | 7 +- .../component/PlaceDetailScreen.kt | 6 +- .../component/WaitingCancelConfirmDialog.kt | 4 +- .../component/WaitingRegisterScreen.kt | 24 +- .../platform/RememberDeepLinkHandler.kt | 30 +- .../presentation/waiting/MyWaitingUiState.kt | 17 + .../waiting/MyWaitingViewModel.kt | 141 ++++++ .../waiting/component/MyWaitingScreen.kt | 403 ++++++++++++++++++ .../waiting/component/WaitingStatusCard.kt | 262 ++++++++++++ .../waiting/model/MyWaitingUiModel.kt | 18 + .../waiting/navigation/MyWaitingNavigation.kt | 25 ++ .../remote/waiting/WaitingTestFixture.kt | 1 + .../repository/MyWaitingRepositoryImplTest.kt | 119 ++++++ .../WaitingRegisterInfoRepositoryImplTest.kt | 62 +++ .../viewModel/home/HomeViewModelTest.kt | 68 ++- .../placeDetail/PlaceDetailTestFixture.kt | 3 +- .../DefaultUserNotificationDelegate.kt | 28 +- .../platform/PendingFcmNotification.kt | 6 +- .../platform/RememberDeepLinkHandler.ios.kt | 4 +- 38 files changed, 1669 insertions(+), 131 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_waiting_bar.xml create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/WaitingBarUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeWaitingBar.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/model/MyWaitingUiModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/navigation/MyWaitingNavigation.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImplTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImplTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt index a4494850..a21d79f8 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.util.Consumer @Composable -actual fun RememberDeepLinkHandler(onNotificationClick: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) { +actual fun RememberDeepLinkHandler(onNotificationClick: (action: FcmDeepLinkAction, festivalIdChanged: Boolean) -> Unit) { val context = LocalContext.current val activity = context as? ComponentActivity ?: return val currentOnNotificationClicked by rememberUpdatedState(onNotificationClick) @@ -30,12 +30,39 @@ actual fun RememberDeepLinkHandler(onNotificationClick: (announcementId: Long, f } } -private fun Intent.handleDeepLink(onNotificationClicked: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) { - val announcementId = - getLongExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, DeepLinkKeys.INITIALIZED_ID) - if (announcementId != DeepLinkKeys.INITIALIZED_ID) { - onNotificationClicked(announcementId, false) - removeExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND) - removeExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS) +private fun Intent.handleDeepLink(onNotificationClicked: (action: FcmDeepLinkAction, festivalIdChanged: Boolean) -> Unit) { + val action = consumeDeepLinkAction() ?: return + onNotificationClicked(action, false) +} + +private fun Intent.consumeDeepLinkAction(): FcmDeepLinkAction? { + val type = FcmMessageType.from(getStringExtra(DeepLinkKeys.KEY_TYPE)) + return when (type) { + FcmMessageType.WAITING_CALL, + FcmMessageType.WAITING_ALMOST_CALL, + -> { + removeExtra(DeepLinkKeys.KEY_TYPE) + FcmDeepLinkAction.OpenMyWaiting + } + + FcmMessageType.WAITING_PLACE_ACCESS_CANCEL -> { + val placeId = getLongExtra(DeepLinkKeys.KEY_PLACE_ID, DeepLinkKeys.INITIALIZED_ID) + removeExtra(DeepLinkKeys.KEY_TYPE) + removeExtra(DeepLinkKeys.KEY_PLACE_ID) + if (placeId == DeepLinkKeys.INITIALIZED_ID) null else FcmDeepLinkAction.OpenPlaceDetail(placeId) + } + + FcmMessageType.ANNOUNCEMENT, null -> { + val announcementId = + getLongExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, DeepLinkKeys.INITIALIZED_ID) + if (announcementId == DeepLinkKeys.INITIALIZED_ID) { + null + } else { + removeExtra(DeepLinkKeys.KEY_TYPE) + removeExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND) + removeExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS) + FcmDeepLinkAction.OpenAnnouncement(announcementId) + } + } } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt index 439e774d..0a477aa3 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt @@ -3,6 +3,8 @@ package com.daedan.festabook.service import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.di.androidAppGraph import com.daedan.festabook.presentation.platform.DeepLinkKeys +import com.daedan.festabook.presentation.platform.FcmDeepLinkAction +import com.daedan.festabook.presentation.platform.FcmMessageType import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import festabookkmp.composeapp.generated.resources.Res @@ -26,19 +28,45 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } override fun onMessageReceived(remoteMessage: RemoteMessage) { - if (remoteMessage.data.isNotEmpty()) { - ioCoroutineScope.launch { - val title = - remoteMessage.data["title"] ?: getString(Res.string.default_notification_title) - val content = - remoteMessage.data["body"] ?: getString(Res.string.default_notification_body) - val noticeIdToExpand = - remoteMessage.data["announcementId"]?.toLongOrNull() - ?: DeepLinkKeys.INITIALIZED_ID - val festivalId = - remoteMessage.data["festivalId"]?.toLongOrNull() ?: DeepLinkKeys.INITIALIZED_ID - - handleMessageData(festivalId, title, content, noticeIdToExpand) + if (remoteMessage.data.isEmpty()) return + + ioCoroutineScope.launch { + val title = + remoteMessage.data["title"] ?: getString(Res.string.default_notification_title) + val content = + remoteMessage.data["body"] ?: getString(Res.string.default_notification_body) + val festivalId = + remoteMessage.data[DeepLinkKeys.KEY_FESTIVAL_ID]?.toLongOrNull() + ?: DeepLinkKeys.INITIALIZED_ID + val action = remoteMessage.data.toDeepLinkAction() + + handleMessageData( + festivalId = festivalId, + title = title, + content = content, + action = action, + ) + } + } + + private fun Map.toDeepLinkAction(): FcmDeepLinkAction? { + val type = FcmMessageType.from(this[DeepLinkKeys.KEY_TYPE]) + return when (type) { + FcmMessageType.WAITING_CALL, + FcmMessageType.WAITING_ALMOST_CALL, + -> { + FcmDeepLinkAction.OpenMyWaiting + } + + FcmMessageType.WAITING_PLACE_ACCESS_CANCEL -> { + val placeId = this[DeepLinkKeys.KEY_PLACE_ID]?.toLongOrNull() ?: return null + FcmDeepLinkAction.OpenPlaceDetail(placeId) + } + + FcmMessageType.ANNOUNCEMENT, null -> { + val announcementId = + this[DeepLinkKeys.KEY_ANNOUNCEMENT_ID]?.toLongOrNull() ?: return null + FcmDeepLinkAction.OpenAnnouncement(announcementId) } } } @@ -47,7 +75,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { festivalId: Long, title: String, content: String, - noticeIdToExpand: Long, + action: FcmDeepLinkAction?, ) { if (festivalId != DeepLinkKeys.INITIALIZED_ID) { festivalLocalDataSource.saveFestivalId(festivalId) @@ -57,7 +85,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { context = applicationContext, title = title, content = content, - announcementId = noticeIdToExpand, + action = action, ) } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt index d666a108..eb995f19 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt @@ -4,32 +4,44 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.Intent import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import com.daedan.festabook.MainActivity import com.daedan.festabook.R import com.daedan.festabook.presentation.platform.DeepLinkKeys +import com.daedan.festabook.presentation.platform.FcmDeepLinkAction +import com.daedan.festabook.presentation.platform.FcmMessageType import com.daedan.festabook.presentation.theme.FestabookColorPalette import com.daedan.festabook.vectorToBitmap object NotificationHelper { - private const val CHANNEL_ID = "notice_channel" - private const val CHANNEL_NAME = "공지사항" + private const val ANNOUNCEMENT_CHANNEL_ID = "notice_channel" + private const val ANNOUNCEMENT_CHANNEL_NAME = "공지사항" + private const val ANNOUNCEMENT_CHANNEL_DESCRIPTION = "앱 공지사항 알림 채널" + + private const val WAITING_CHANNEL_ID = "waiting_channel" + private const val WAITING_CHANNEL_NAME = "웨이팅" + private const val WAITING_CHANNEL_DESCRIPTION = "웨이팅 호출 및 상태 알림 채널" // Android 8.0에서 알림 채널을 생성 fun createNotificationChannel(context: Context) { - val channel = - NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = "앱 공지사항 알림 채널" - } - val notificationManager = context.getSystemService(NotificationManager::class.java) - notificationManager.createNotificationChannel(channel) + notificationManager.createNotificationChannel( + NotificationChannel( + ANNOUNCEMENT_CHANNEL_ID, + ANNOUNCEMENT_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + ).apply { description = ANNOUNCEMENT_CHANNEL_DESCRIPTION }, + ) + notificationManager.createNotificationChannel( + NotificationChannel( + WAITING_CHANNEL_ID, + WAITING_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + ).apply { description = WAITING_CHANNEL_DESCRIPTION }, + ) } // 실제 알림을 생성 @@ -37,12 +49,11 @@ object NotificationHelper { context: Context, title: String, content: String, - announcementId: Long, + action: FcmDeepLinkAction?, ) { val intent = MainActivity.newIntent(context).apply { - putExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, true) - putExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, announcementId) + action?.applyAsExtras(this) } val pendingIntent = @@ -55,7 +66,7 @@ object NotificationHelper { val notificationBuilder = NotificationCompat - .Builder(context, CHANNEL_ID) + .Builder(context, action.toChannelId()) .setSmallIcon(R.drawable.ic_festabook_logo_notification_small) .setColor(FestabookColorPalette().white.toArgb()) .setContentTitle(title) @@ -72,4 +83,32 @@ object NotificationHelper { context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build()) } + + private fun FcmDeepLinkAction.applyAsExtras(intent: Intent) { + when (this) { + is FcmDeepLinkAction.OpenAnnouncement -> { + intent.putExtra(DeepLinkKeys.KEY_TYPE, FcmMessageType.ANNOUNCEMENT.name) + intent.putExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, true) + intent.putExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, announcementId) + } + + FcmDeepLinkAction.OpenMyWaiting -> { + intent.putExtra(DeepLinkKeys.KEY_TYPE, FcmMessageType.WAITING_CALL.name) + } + + is FcmDeepLinkAction.OpenPlaceDetail -> { + intent.putExtra(DeepLinkKeys.KEY_TYPE, FcmMessageType.WAITING_PLACE_ACCESS_CANCEL.name) + intent.putExtra(DeepLinkKeys.KEY_PLACE_ID, placeId) + } + } + } + + private fun FcmDeepLinkAction?.toChannelId(): String = + when (this) { + FcmDeepLinkAction.OpenMyWaiting, + is FcmDeepLinkAction.OpenPlaceDetail, + -> WAITING_CHANNEL_ID + + is FcmDeepLinkAction.OpenAnnouncement, null -> ANNOUNCEMENT_CHANNEL_ID + } } diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_waiting_bar.xml b/composeApp/src/commonMain/composeResources/drawable/ic_waiting_bar.xml new file mode 100644 index 00000000..3efbf9d3 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_waiting_bar.xml @@ -0,0 +1,14 @@ + + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 577d2d2d..719b7d73 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -228,4 +228,27 @@ + + + 나의 웨이팅 + 현재 대기 중 + 근처에서 기다려주세요 + 지금 바로 입장해주세요! + 번째 + 지금 입장 + %1$d팀 + 총 대기 + 예상 대기시간 + 약 %1$d분 + 방문 인원 수 + %1$d명 + 전화번호 + 웨이팅 취소하기 + 등록한 웨이팅을\n정말 취소할까요? + 웨이팅을 취소했어요 + 입장 순서가 되면 푸시 알림으로 안내드립니다. 10분 이내 미입장 시 대기가 노쇼로 처리될 수 있습니다. + + + %1$d번째 | 약 %2$d분 + 나의 웨이팅 diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt index 4458fb76..236fc8ee 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSource.kt @@ -6,15 +6,4 @@ interface WaitingLocalDataSource { suspend fun savePhoneNumber(phoneNumber: String) fun getPhoneNumber(): Flow - - // TODO: 서버에서 placeId 내려주면 삭제 - - suspend fun savePlaceId(placeId: Long) - - // TODO: 서버에서 placeId 내려주면 삭제 - fun getPlaceId(): Flow - - // TODO: 서버에서 placeId 내려주면 삭제 - - suspend fun clearPlaceId() } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt index af127eb6..ae9875b0 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/datasource/local/WaitingLocalDataSourceImpl.kt @@ -5,7 +5,6 @@ import androidx.datastore.core.IOException import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -31,22 +30,7 @@ class WaitingLocalDataSourceImpl( if (it is IOException) emit(emptyPreferences()) else throw it }.map { it[KEY_PHONE_NUMBER] } - override suspend fun savePlaceId(placeId: Long) { - dataStore.edit { it[KEY_PLACE_ID] = placeId } - } - - override fun getPlaceId(): Flow = - dataStore.data - .catch { - if (it is IOException) emit(emptyPreferences()) else throw it - }.map { it[KEY_PLACE_ID] } - - override suspend fun clearPlaceId() { - dataStore.edit { it.remove(KEY_PLACE_ID) } - } - companion object { private val KEY_PHONE_NUMBER = stringPreferencesKey("waiting_phone_number") - private val KEY_PLACE_ID = longPreferencesKey("waiting_place_id") } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt index 84f13a3d..6b890f66 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/waiting/MyWaitingResponse.kt @@ -21,15 +21,18 @@ data class MyWaitingResponse( val estimatedWaitTime: Int, @SerialName("phoneNumber") val phoneNumber: String, + @SerialName("placeId") + val placeId: Long, ) fun MyWaitingResponse.toDomain() = MyWaiting( waitingId = waitingId, - waitingOrder = waitingOrder, + waitingOrderFromZero = waitingOrder, partySize = partySize, waitingStatus = waitingStatus, totalWaitingTeams = totalWaitingTeams, estimatedWaitTime = estimatedWaitTime, phoneNumber = phoneNumber, + placeId = placeId, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt index 441ab5a5..dc62e0de 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.daedan.festabook.data.repository -import com.daedan.festabook.data.datasource.local.WaitingLocalDataSource import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource import com.daedan.festabook.data.model.response.waiting.toDomain import com.daedan.festabook.data.util.toResult @@ -10,22 +9,16 @@ import com.daedan.festabook.domain.repository.MyWaitingRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject -import kotlinx.coroutines.flow.first @ContributesBinding(AppScope::class) @Inject class MyWaitingRepositoryImpl( private val waitingRemoteDataSource: WaitingRemoteDataSource, - private val waitingLocalDataSource: WaitingLocalDataSource, ) : MyWaitingRepository { override suspend fun getMyWaiting(): Result { val existResult = waitingRemoteDataSource.fetchMyWaitingExist().toResult().getOrElse { return Result.failure(it) } if (!existResult.exists) { - // 고아 복구: 서버에 없는데 로컬에 placeId가 남아 있으면 클리어 - if (waitingLocalDataSource.getPlaceId().first() != null) { - waitingLocalDataSource.clearPlaceId() - } return Result.success(null) } @@ -34,7 +27,6 @@ class MyWaitingRepositoryImpl( .toResult() .mapCatching { it.toDomain() } .mapCatching { myWaiting -> - // 서버 측 종료 상태 감지 → placeId 클리어 후 null 반환 if (myWaiting.waitingStatus in setOf( WaitingStatus.CANCELED, @@ -42,11 +34,9 @@ class MyWaitingRepositoryImpl( WaitingStatus.NO_SHOW, ) ) { - waitingLocalDataSource.clearPlaceId() return@mapCatching null } - val placeId = waitingLocalDataSource.getPlaceId().first() - myWaiting.copy(placeId = placeId) + myWaiting } } @@ -54,5 +44,4 @@ class MyWaitingRepositoryImpl( waitingRemoteDataSource .cancelWaiting(waitingId) .toResult() - .onSuccess { waitingLocalDataSource.clearPlaceId() } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt index c40de44e..77bdcb73 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.daedan.festabook.data.repository -import com.daedan.festabook.data.datasource.local.WaitingLocalDataSource import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource import com.daedan.festabook.data.model.request.WaitingRegisterRequest import com.daedan.festabook.data.model.response.waiting.toDomain @@ -11,13 +10,11 @@ import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject -import io.github.aakira.napier.Napier @ContributesBinding(AppScope::class) @Inject class WaitingRegisterInfoRepositoryImpl( private val waitingRemoteDataSource: WaitingRemoteDataSource, - private val waitingLocalDataSource: WaitingLocalDataSource, ) : WaitingRegisterInfoRepository { override suspend fun getPlaceWaiting(placeId: Long): Result { val response = waitingRemoteDataSource.fetchPlaceWaiting(placeId).toResult() @@ -30,11 +27,6 @@ class WaitingRegisterInfoRepositoryImpl( ): Result { val request = WaitingRegisterRequest(partySize = partySize) val response = waitingRemoteDataSource.registerWaiting(placeId, request).toResult() - return response.mapCatching { dto -> - // placeId DataStore 저장 — 실패해도 서버 등록 성공이므로 계속 - runCatching { waitingLocalDataSource.savePlaceId(placeId) } - .onFailure { Napier.w("placeId 저장 실패 (degraded mode)", it) } - dto.toDomain() - } + return response.mapCatching { it.toDomain() } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt index fca48045..ab95b070 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/MyWaiting.kt @@ -2,11 +2,13 @@ package com.daedan.festabook.domain.model data class MyWaiting( val waitingId: Long, - val waitingOrder: Int, + val waitingOrderFromZero: Int, val partySize: Int, val waitingStatus: WaitingStatus, val totalWaitingTeams: Int, val estimatedWaitTime: Int, val phoneNumber: String, - val placeId: Long? = null, -) + val placeId: Long, +) { + val waitingOrder = waitingOrderFromZero + 1 +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt index 3c5a34a1..29efb300 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -4,10 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.presentation.home.FestivalUiState +import com.daedan.festabook.domain.repository.MyWaitingRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import io.github.aakira.napier.Napier import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -21,6 +22,7 @@ import kotlinx.coroutines.launch @Inject class HomeViewModel( private val festivalRepository: FestivalRepository, + private val myWaitingRepository: MyWaitingRepository, ) : ViewModel() { private val _festivalUiState = MutableStateFlow(FestivalUiState.Loading) val festivalUiState: StateFlow = _festivalUiState.asStateFlow() @@ -32,6 +34,13 @@ class HomeViewModel( MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val navigateToScheduleEvent: SharedFlow = _navigateToScheduleEvent.asSharedFlow() + private val _waitingBarUiState = MutableStateFlow(WaitingBarUiState.Hidden) + val waitingBarUiState: StateFlow = _waitingBarUiState.asStateFlow() + + private val _navigateToMyWaitingEvent = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigateToMyWaitingEvent: SharedFlow = _navigateToMyWaitingEvent.asSharedFlow() + init { loadFestival() loadLineup() @@ -55,6 +64,29 @@ class HomeViewModel( _navigateToScheduleEvent.tryEmit(Unit) } + fun navigateToMyWaitingClick() { + _navigateToMyWaitingEvent.tryEmit(Unit) + } + + fun loadWaitingBar() { + viewModelScope.launch { + myWaitingRepository + .getMyWaiting() + .onSuccess { result -> + _waitingBarUiState.value = result?.let { + WaitingBarUiState.Visible( + order = it.waitingOrder, + estimatedWaitTime = it.estimatedWaitTime, + status = it.waitingStatus, + ) + } ?: WaitingBarUiState.Hidden + }.onFailure { t -> + Napier.e("웨이팅 바 로딩 실패", t) + _waitingBarUiState.value = WaitingBarUiState.Hidden + } + } + } + private fun loadLineup() { viewModelScope.launch { _lineupUiState.value = LineupUiState.Loading diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/WaitingBarUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/WaitingBarUiState.kt new file mode 100644 index 00000000..1336339e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/WaitingBarUiState.kt @@ -0,0 +1,13 @@ +package com.daedan.festabook.presentation.home + +import com.daedan.festabook.domain.model.WaitingStatus + +sealed interface WaitingBarUiState { + data object Hidden : WaitingBarUiState + + data class Visible( + val order: Int, + val estimatedWaitTime: Int, + val status: WaitingStatus, + ) : WaitingBarUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index a6027c41..6b6c634b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -30,8 +31,10 @@ import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel import com.daedan.festabook.presentation.home.LineupItemUiModel import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.error_fail_to_load_info import festabookkmp.composeapp.generated.resources.setting_notice_enabled @@ -50,12 +53,14 @@ fun HomeScreen( settingViewModel: SettingViewModel, notificationPermissionManager: NotificationPermissionManager, onNavigateToExplore: () -> Unit, + onNavigateToMyWaiting: () -> Unit, onShowSnackBar: (String) -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, modifier: Modifier = Modifier, ) { val festivalUiState by viewModel.festivalUiState.collectAsStateWithLifecycle() val lineupUiState by viewModel.lineupUiState.collectAsStateWithLifecycle() + val waitingBarUiState by viewModel.waitingBarUiState.collectAsStateWithLifecycle() val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) val settingEnabledText = stringResource(Res.string.setting_notice_enabled) @@ -71,6 +76,14 @@ fun HomeScreen( currentOnShowErrorSnackbar(it) } + ObserveAsEvents(flow = viewModel.navigateToMyWaitingEvent) { + onNavigateToMyWaiting() + } + + LaunchedEffect(Unit) { + viewModel.loadWaitingBar() + } + LaunchedEffect(festivalUiState) { when (val state = festivalUiState) { is FestivalUiState.Error -> { @@ -98,13 +111,37 @@ fun HomeScreen( } is FestivalUiState.Success -> { - FestivalOverview( - festivalUiState = state, - lineupUiState = lineupUiState, - onNavigateToExplore = onNavigateToExplore, - onNavigateToSchedule = viewModel::navigateToScheduleClick, - modifier = modifier, - ) + Box(modifier = modifier.fillMaxSize()) { + FestivalOverview( + festivalUiState = state, + lineupUiState = lineupUiState, + onNavigateToExplore = onNavigateToExplore, + onNavigateToSchedule = viewModel::navigateToScheduleClick, + ) + when (val waitingBarUiState = waitingBarUiState) { + is WaitingBarUiState.Visible -> { + HomeWaitingBar( + order = waitingBarUiState.order, + estimatedMinutes = waitingBarUiState.estimatedWaitTime, + status = waitingBarUiState.status, + onClick = viewModel::navigateToMyWaitingClick, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding( + start = festabookSpacing.paddingBody4, + end = festabookSpacing.paddingBody4, + bottom = festabookSpacing.paddingBody4, + ), + ) + } + + else -> { + Unit + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeWaitingBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeWaitingBar.kt new file mode 100644 index 00000000..3d218e21 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeWaitingBar.kt @@ -0,0 +1,103 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import com.daedan.festabook.domain.model.WaitingStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.home_waiting_bar_action +import festabookkmp.composeapp.generated.resources.home_waiting_bar_format +import festabookkmp.composeapp.generated.resources.ic_arrow_forward_right +import festabookkmp.composeapp.generated.resources.ic_waiting_bar +import festabookkmp.composeapp.generated.resources.move +import festabookkmp.composeapp.generated.resources.my_waiting_enter_now +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun HomeWaitingBar( + order: Int, + estimatedMinutes: Int, + status: WaitingStatus, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .shadow( + elevation = 4.dp, + shape = festabookShapes.radiusFull, + ).background(FestabookColor.gray800, festabookShapes.radiusFull) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = + Modifier + .padding( + vertical = festabookSpacing.paddingBody3, + horizontal = festabookSpacing.paddingBody4, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(32.dp) + .background(FestabookColor.gray700, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(Res.drawable.ic_waiting_bar), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = FestabookColor.white, + ) + } + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody3)) + val barText = + when (status) { + WaitingStatus.CALLED -> stringResource(Res.string.my_waiting_enter_now) + else -> stringResource(Res.string.home_waiting_bar_format, order, estimatedMinutes) + } + Text( + text = barText, + style = FestabookTypography.titleSmall, + color = FestabookColor.white, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(Res.string.home_waiting_bar_action), + style = FestabookTypography.labelMedium, + color = FestabookColor.white, + ) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody2)) + Icon( + painter = painterResource(Res.drawable.ic_arrow_forward_right), + contentDescription = stringResource(Res.string.move), + tint = FestabookColor.gray300, + modifier = Modifier.size(16.dp), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt index 6b8bf3d9..52a58bb5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -27,6 +27,7 @@ fun NavGraphBuilder.homeNavGraph( onShowErrorSnackbar: (Throwable) -> Unit, onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, + onNavigateToMyWaiting: () -> Unit, ) { composable( enterTransition = { EnterTransition.None }, @@ -44,6 +45,7 @@ fun NavGraphBuilder.homeNavGraph( viewModel = viewModel, onShowErrorSnackbar = onShowErrorSnackbar, onNavigateToExplore = onNavigateToExplore, + onNavigateToMyWaiting = onNavigateToMyWaiting, settingViewModel = settingViewModel, notificationPermissionManager = notificationPermissionManager, onShowSnackBar = onShowSnackbar, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index fd7612f5..f2f4fe2a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -23,12 +23,17 @@ sealed interface FestabookRoute { @Serializable data class Main( val pendingAnnouncementId: Long? = null, + val pendingPlaceDetailId: Long? = null, + val pendingMyWaiting: Boolean = false, ) : FestabookRoute @Serializable data class AddWaitingInfo( val placeId: Long? = null, ) : FestabookRoute + + @Serializable + data object MyWaiting : FestabookRoute } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index 7056fe7f..cd91676d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -42,6 +42,7 @@ import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph import com.daedan.festabook.presentation.placeMap.platform.LocationSource +import com.daedan.festabook.presentation.platform.FcmDeepLinkAction import com.daedan.festabook.presentation.platform.RememberDeepLinkHandler import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.navigation.scheduleNavGraph @@ -50,6 +51,7 @@ import com.daedan.festabook.presentation.setting.component.platform.rememberNoti import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings import com.daedan.festabook.presentation.setting.navigation.settingNavGraph import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel +import com.daedan.festabook.presentation.waiting.navigation.myWaitingNavGraph import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.back_press_exit_message import org.jetbrains.compose.resources.stringResource @@ -63,6 +65,8 @@ fun MainScreen( onAppFinish: () -> Unit, festabookNavigator: FestabookNavigator, pendingAnnouncementId: Long?, + pendingPlaceDetailId: Long?, + pendingMyWaiting: Boolean, mainViewModel: MainViewModel, homeViewModel: HomeViewModel, scheduleViewModel: ScheduleViewModel, @@ -114,16 +118,58 @@ fun MainScreen( } } - RememberDeepLinkHandler { announcementId, festivalIdChanged -> - if (festivalIdChanged) { - festabookNavigator.navigate( - FestabookRoute.Main(pendingAnnouncementId = announcementId), - navOptions { - popUpTo { inclusive = true } - }, - ) - } else { - navigateToNewsScreen(newsViewModel, mainViewModel, announcementId) + LaunchedEffect(pendingPlaceDetailId) { + pendingPlaceDetailId?.let { placeId -> + mainNavigator.navigate(FestabookRoute.PlaceDetail(placeId)) + } + } + + LaunchedEffect(pendingMyWaiting) { + if (pendingMyWaiting) { + mainNavigator.navigate(FestabookRoute.MyWaiting) + } + } + + RememberDeepLinkHandler { action, festivalIdChanged -> + when (action) { + is FcmDeepLinkAction.OpenAnnouncement -> { + if (festivalIdChanged) { + festabookNavigator.navigate( + FestabookRoute.Main(pendingAnnouncementId = action.announcementId), + navOptions { + popUpTo { inclusive = true } + }, + ) + } else { + navigateToNewsScreen(newsViewModel, mainViewModel, action.announcementId) + } + } + + FcmDeepLinkAction.OpenMyWaiting -> { + if (festivalIdChanged) { + festabookNavigator.navigate( + FestabookRoute.Main(pendingMyWaiting = true), + navOptions { + popUpTo { inclusive = true } + }, + ) + } else { + mainNavigator.navigate(FestabookRoute.MyWaiting) + } + } + + is FcmDeepLinkAction.OpenPlaceDetail -> { + if (festivalIdChanged) { + festabookNavigator.navigate( + FestabookRoute.Main(pendingPlaceDetailId = action.placeId), + navOptions { + popUpTo { inclusive = true } + }, + ) + } else { + mainNavigator.navigate(FestabookRoute.PlaceDetail(action.placeId)) + } + } } } Scaffold( @@ -202,6 +248,7 @@ fun MainScreen( waitingInfoViewModel = waitingInfoViewModel, newsViewModel = newsViewModel, notificationPermissionManager = notificationPermissionManager, + notificationPermissionManagerFactory = appGraph.notificationPermissionManagerFactory, snackbarManager = snackbarManager, ) } @@ -228,6 +275,7 @@ private fun FestabookNavHost( settingViewModel: SettingViewModel, waitingInfoViewModel: WaitingInfoViewModel, notificationPermissionManager: NotificationPermissionManager, + notificationPermissionManagerFactory: NotificationPermissionManager.Factory, snackbarManager: SnackbarManager, modifier: Modifier = Modifier, ) { @@ -249,6 +297,7 @@ private fun FestabookNavHost( onShowErrorSnackbar = snackbarManager::showError, settingViewModel = settingViewModel, notificationPermissionManager = notificationPermissionManager, + onNavigateToMyWaiting = { navigator.navigate(FestabookRoute.MyWaiting) }, ) scheduleNavGraph( innerPadding = innerPadding, @@ -271,8 +320,16 @@ private fun FestabookNavHost( }, ) }, + onNavigateToMyWaiting = { + navigator.navigate( + FestabookRoute.MyWaiting, + navOptions { + popUpTo { inclusive = true } + }, + ) + }, onShowSnackbar = snackbarManager::show, - notificationPermissionManager = notificationPermissionManager, + notificationPermissionManagerFactory = notificationPermissionManagerFactory, ) newsNavGraph( innerPadding = innerPadding, @@ -298,5 +355,16 @@ private fun FestabookNavHost( }, onBackClick = { navigator.popBackStack() }, ) + myWaitingNavGraph( + onBack = { + homeViewModel.loadWaitingBar() + navigator.popBackStack() + }, + onNavigateToPlaceDetail = { placeId -> + navigator.navigate(FestabookRoute.PlaceDetail(placeId)) + }, + onShowSnackbar = snackbarManager::show, + onShowErrorSnackbar = snackbarManager::showError, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt index df5665cf..e8c47fd4 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt @@ -26,6 +26,8 @@ fun NavGraphBuilder.mainNavGraph( onAppFinish = onAppFinish, festabookNavigator = festabookNavigator, pendingAnnouncementId = mainRoute.pendingAnnouncementId, + pendingPlaceDetailId = mainRoute.pendingPlaceDetailId, + pendingMyWaiting = mainRoute.pendingMyWaiting, homeViewModel = metroViewModel(mainBackEntry), scheduleViewModel = metroViewModel(mainBackEntry), placeMapViewModel = metroViewModel(mainBackEntry), diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index b3e9df54..d12c99d7 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -36,11 +36,12 @@ import kotlin.reflect.typeOf fun NavGraphBuilder.placeMapNavGraph( innerPadding: PaddingValues, settingViewModel: SettingViewModel, - notificationPermissionManager: NotificationPermissionManager, + notificationPermissionManagerFactory: NotificationPermissionManager.Factory, onBackToPreviousClick: () -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, onNavigateToAddWaitingInfo: (Long) -> Unit, onNavigateToPhoneRegistration: (Long) -> Unit, + onNavigateToMyWaiting: () -> Unit, onShowSnackbar: (String) -> Unit, ) { composable( @@ -83,6 +84,7 @@ fun NavGraphBuilder.placeMapNavGraph( onBackToPreviousClick = onBackToPreviousClick, onShowErrorSnackbar = onShowErrorSnackbar, onNavigateToWaitingRegister = onNavigateToAddWaitingInfo, + onNavigateToMyWaiting = onNavigateToMyWaiting, ) } @@ -111,7 +113,8 @@ fun NavGraphBuilder.placeMapNavGraph( onShowErrorSnackbar = onShowErrorSnackbar, onShowSnackbar = onShowSnackbar, onNavigateToPhoneRegistration = { onNavigateToPhoneRegistration(route.placeId) }, - notificationPermissionManager = notificationPermissionManager, + onNavigateToMyWaiting = onNavigateToMyWaiting, + notificationPermissionManagerFactory = notificationPermissionManagerFactory, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt index cc709b44..549fb3ce 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt @@ -82,6 +82,7 @@ import festabookkmp.composeapp.generated.resources.place_detail_default_time import festabookkmp.composeapp.generated.resources.place_list_default_description import festabookkmp.composeapp.generated.resources.place_list_default_location import festabookkmp.composeapp.generated.resources.place_list_default_title +import festabookkmp.composeapp.generated.resources.waiting_cancel_confirm_title import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -94,6 +95,7 @@ fun PlaceDetailRoute( onBackToPreviousClick: () -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, onNavigateToWaitingRegister: (placeId: Long) -> Unit, + onNavigateToMyWaiting: () -> Unit, modifier: Modifier = Modifier, ) { val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() @@ -117,7 +119,8 @@ fun PlaceDetailRoute( if (duplicateWaitingId != null && !showCancelConfirmDialog) { WaitingDuplicateBottomSheet( onMyWaitingClick = { - // TODO 나의 웨이팅 화면으로 이동 + duplicateWaitingId = null + onNavigateToMyWaiting() }, onRegisterNewClick = { showCancelConfirmDialog = true }, onDismiss = { duplicateWaitingId = null }, @@ -126,6 +129,7 @@ fun PlaceDetailRoute( if (showCancelConfirmDialog) { WaitingCancelConfirmDialog( + title = stringResource(Res.string.waiting_cancel_confirm_title), onDismissClick = { duplicateWaitingId = null showCancelConfirmDialog = false diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt index f02fa073..c17dd927 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/WaitingCancelConfirmDialog.kt @@ -24,11 +24,11 @@ import com.daedan.festabook.presentation.theme.festabookSpacing import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.waiting_cancel_confirm_dismiss import festabookkmp.composeapp.generated.resources.waiting_cancel_confirm_submit -import festabookkmp.composeapp.generated.resources.waiting_cancel_confirm_title import org.jetbrains.compose.resources.stringResource @Composable fun WaitingCancelConfirmDialog( + title: String, onDismissClick: () -> Unit, onCancelClick: () -> Unit, onDismissRequest: () -> Unit, @@ -45,7 +45,7 @@ fun WaitingCancelConfirmDialog( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stringResource(Res.string.waiting_cancel_confirm_title), + text = title, style = FestabookTypography.displaySmall, color = FestabookColor.black, textAlign = TextAlign.Center, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt index 82eed21f..08cfc62a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt @@ -57,6 +57,7 @@ import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingP import com.daedan.festabook.presentation.placeMap.waitingRegister.model.WaitingRegisterUiState import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.setting.component.NotificationPermissionDialog +import com.daedan.festabook.presentation.setting.component.platform.rememberNotificationPermissionManager import com.daedan.festabook.presentation.setting.component.platform.rememberOpenAppSettings import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography @@ -84,11 +85,12 @@ private const val PRIVACY_AGREEMENT_URL = fun WaitingRegisterRoute( viewModel: WaitingRegisterViewModel, settingViewModel: SettingViewModel, - notificationPermissionManager: NotificationPermissionManager, + notificationPermissionManagerFactory: NotificationPermissionManager.Factory, onBackToPreviousClick: () -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, onShowSnackbar: (String) -> Unit, onNavigateToPhoneRegistration: () -> Unit, + onNavigateToMyWaiting: () -> Unit, modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -98,10 +100,20 @@ fun WaitingRegisterRoute( var showPermissionDialog by remember { mutableStateOf(false) } val onOpenAppSettings = rememberOpenAppSettings() - // TODO 나의 웨이팅 화면으로 이동 + val notificationPermissionManager = + rememberNotificationPermissionManager( + notificationPermissionManagerFactory = notificationPermissionManagerFactory, + onPermissionGrant = { + showConfirmBottomSheet = false + settingViewModel.saveNotificationId() + viewModel.submitWaitingRegister() + }, + onPermissionDeny = {}, + ) + ObserveAsEvents(viewModel.registerSuccessEvent) { onShowSnackbar(successMessage) - onBackToPreviousClick() + onNavigateToMyWaiting() } ObserveAsEvents(viewModel.registerFailureEvent) { throwable -> onShowErrorSnackbar(throwable) @@ -119,9 +131,13 @@ fun WaitingRegisterRoute( viewModel.submitWaitingRegister() } - PermissionState.NEED_RATIONALE, PermissionState.DENIED -> { + PermissionState.NEED_RATIONALE -> { onOpenAppSettings() } + + PermissionState.DENIED -> { + notificationPermissionManager.requestPermission() + } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt index 85d3889c..f60a45c8 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt @@ -7,14 +7,40 @@ object DeepLinkKeys { const val KEY_ANNOUNCEMENT_ID = "announcementId" const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" + const val KEY_TYPE = "type" + const val KEY_PLACE_ID = "placeId" const val INITIALIZED_ID = -1L } +enum class FcmMessageType { + ANNOUNCEMENT, + WAITING_CALL, + WAITING_ALMOST_CALL, + WAITING_PLACE_ACCESS_CANCEL, + ; + + companion object { + fun from(raw: String?): FcmMessageType? = entries.firstOrNull { it.name == raw } + } +} + +sealed interface FcmDeepLinkAction { + data class OpenAnnouncement( + val announcementId: Long, + ) : FcmDeepLinkAction + + data object OpenMyWaiting : FcmDeepLinkAction + + data class OpenPlaceDetail( + val placeId: Long, + ) : FcmDeepLinkAction +} + /** * FCM 알림 클릭 시 호출되는 딥링크 핸들러. * - * @param onNotificationClick announcementId와 festivalId 변경 여부를 전달. + * @param onNotificationClick 분기된 [FcmDeepLinkAction]과 festivalId 변경 여부를 전달. * Android에서는 festivalIdChanged가 항상 false (Activity 재시작으로 처리). */ @Composable -expect fun RememberDeepLinkHandler(onNotificationClick: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) +expect fun RememberDeepLinkHandler(onNotificationClick: (action: FcmDeepLinkAction, festivalIdChanged: Boolean) -> Unit) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingUiState.kt new file mode 100644 index 00000000..6a3c1f1d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingUiState.kt @@ -0,0 +1,17 @@ +package com.daedan.festabook.presentation.waiting + +import com.daedan.festabook.presentation.waiting.model.MyWaitingUiModel + +sealed interface MyWaitingUiState { + data object Loading : MyWaitingUiState + + data object Empty : MyWaitingUiState + + data class Success( + val myWaiting: MyWaitingUiModel, + ) : MyWaitingUiState + + data class Error( + val throwable: Throwable, + ) : MyWaitingUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingViewModel.kt new file mode 100644 index 00000000..60e5609f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/MyWaitingViewModel.kt @@ -0,0 +1,141 @@ +package com.daedan.festabook.presentation.waiting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.repository.MyWaitingRepository +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.waiting.model.MyWaitingUiModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@ContributesIntoMap(AppScope::class) +@ViewModelKey(MyWaitingViewModel::class) +@Inject +class MyWaitingViewModel( + private val myWaitingRepository: MyWaitingRepository, + private val placeDetailRepository: PlaceDetailRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(MyWaitingUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _cancelSuccessEvent = MutableSharedFlow(extraBufferCapacity = 1) + val cancelSuccessEvent: SharedFlow = _cancelSuccessEvent.asSharedFlow() + + private val _errorEvent = MutableSharedFlow(extraBufferCapacity = 1) + val errorEvent: SharedFlow = _errorEvent.asSharedFlow() + + init { + loadMyWaiting() + } + + fun loadMyWaiting() { + viewModelScope.launch { + _uiState.value = MyWaitingUiState.Loading + myWaitingRepository + .getMyWaiting() + .onSuccess { myWaiting -> + if (myWaiting == null) { + _uiState.value = MyWaitingUiState.Empty + return@onSuccess + } + _uiState.value = + MyWaitingUiState.Success( + myWaiting = + myWaiting.toUiModel( + placeDetail = fetchPlaceDetail(myWaiting.placeId), + ), + ) + }.onFailure { _uiState.value = MyWaitingUiState.Error(it) } + } + } + + fun refresh() { + val current = _uiState.value as? MyWaitingUiState.Success ?: return + _uiState.value = current.copy(myWaiting = current.myWaiting.copy(isRefreshing = true)) + viewModelScope.launch { + myWaitingRepository + .getMyWaiting() + .onSuccess { myWaiting -> + if (myWaiting == null) { + _uiState.value = MyWaitingUiState.Empty + return@onSuccess + } + _uiState.value = + MyWaitingUiState.Success( + myWaiting = + myWaiting.toUiModel( + placeDetail = fetchPlaceDetail(myWaiting.placeId), + isRefreshing = false, + ), + ) + }.onFailure { + _uiState.update { state -> + (state as? MyWaitingUiState.Success) + ?.copy(myWaiting = state.myWaiting.copy(isRefreshing = false)) + ?: state + } + _errorEvent.tryEmit(it) + } + } + } + + fun cancelWaiting() { + val current = _uiState.value as? MyWaitingUiState.Success ?: return + _uiState.value = current.copy(myWaiting = current.myWaiting.copy(isCanceling = true)) + viewModelScope.launch { + myWaitingRepository + .cancelWaiting(current.myWaiting.waitingId) + .onSuccess { + _uiState.update { state -> + (state as? MyWaitingUiState.Success) + ?.copy(myWaiting = state.myWaiting.copy(isCanceling = false)) + ?: state + } + _cancelSuccessEvent.tryEmit(Unit) + }.onFailure { + _uiState.update { state -> + (state as? MyWaitingUiState.Success) + ?.copy(myWaiting = state.myWaiting.copy(isCanceling = false)) + ?: state + } + _errorEvent.tryEmit(it) + } + } + } + + private suspend fun fetchPlaceDetail(placeId: Long): PlaceDetailUiModel? = + placeDetailRepository + .getPlaceDetail(placeId) + .getOrNull() + ?.toUiModel() +} + +private fun MyWaiting.toUiModel( + placeDetail: PlaceDetailUiModel?, + isRefreshing: Boolean = false, +): MyWaitingUiModel = + MyWaitingUiModel( + waitingId = waitingId, + placeId = placeId, + placeDetail = placeDetail, + order = waitingOrder, + partySize = partySize, + phoneNumber = phoneNumber, + totalWaitingTeams = totalWaitingTeams, + estimatedWaitTime = estimatedWaitTime, + status = waitingStatus, + isRefreshing = isRefreshing, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt new file mode 100644 index 00000000..f6bb3ae5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt @@ -0,0 +1,403 @@ +package com.daedan.festabook.presentation.waiting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.ErrorStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.placeMap.component.PlaceDetailPreviewContent +import com.daedan.festabook.presentation.placeMap.placeDetail.component.WaitingCancelConfirmDialog +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import com.daedan.festabook.presentation.waiting.MyWaitingUiState +import com.daedan.festabook.presentation.waiting.MyWaitingViewModel +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.explore_back +import festabookkmp.composeapp.generated.resources.ic_arrow_back +import festabookkmp.composeapp.generated.resources.ic_info +import festabookkmp.composeapp.generated.resources.my_waiting_cancel_button +import festabookkmp.composeapp.generated.resources.my_waiting_cancel_confirm_title +import festabookkmp.composeapp.generated.resources.my_waiting_cancel_success +import festabookkmp.composeapp.generated.resources.my_waiting_notice_text +import festabookkmp.composeapp.generated.resources.my_waiting_party_size_format +import festabookkmp.composeapp.generated.resources.my_waiting_party_size_label +import festabookkmp.composeapp.generated.resources.my_waiting_phone_label +import festabookkmp.composeapp.generated.resources.my_waiting_title +import festabookkmp.composeapp.generated.resources.setting_waiting_info_section_title +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun MyWaitingRoute( + viewModel: MyWaitingViewModel, + onBack: () -> Unit, + onNavigateToPlaceDetail: (Long) -> Unit, + onShowSnackbar: (String) -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + val cancelSuccessText = stringResource(Res.string.my_waiting_cancel_success) + + ObserveAsEvents(flow = viewModel.cancelSuccessEvent) { + onShowSnackbar(cancelSuccessText) + onBack() + } + + ObserveAsEvents(flow = viewModel.errorEvent) { + currentOnShowErrorSnackbar(it) + } + + MyWaitingScreen( + uiState = uiState, + onBack = onBack, + onRefresh = viewModel::refresh, + onCancelWaiting = viewModel::cancelWaiting, + onNavigateToPlaceDetail = onNavigateToPlaceDetail, + modifier = modifier, + ) +} + +@Composable +fun MyWaitingScreen( + uiState: MyWaitingUiState, + onBack: () -> Unit, + onRefresh: () -> Unit, + onCancelWaiting: () -> Unit, + onNavigateToPlaceDetail: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberNavigationEventState(NavigationEventInfo.None) + NavigationBackHandler(state = state) { onBack() } + + Scaffold( + modifier = modifier, + topBar = { MyWaitingTopBar(onBack = onBack) }, + containerColor = FestabookColor.white, + ) { innerPadding -> + when (uiState) { + is MyWaitingUiState.Loading -> { + LoadingStateScreen(modifier = Modifier.padding(innerPadding)) + } + + is MyWaitingUiState.Empty -> { + EmptyStateScreen(modifier = Modifier.padding(innerPadding)) + } + + is MyWaitingUiState.Error -> { + ErrorStateScreen(modifier = Modifier.padding(innerPadding)) + } + + is MyWaitingUiState.Success -> { + MyWaitingContent( + uiState = uiState, + onRefresh = onRefresh, + onCancelWaiting = onCancelWaiting, + onNavigateToPlaceDetail = onNavigateToPlaceDetail, + modifier = Modifier.padding(innerPadding), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MyWaitingTopBar( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(Res.string.my_waiting_title), + style = FestabookTypography.titleMedium, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = stringResource(Res.string.explore_back), + tint = FestabookColor.black, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = FestabookColor.white, + ), + modifier = modifier, + ) +} + +@Composable +private fun MyWaitingContent( + uiState: MyWaitingUiState.Success, + onRefresh: () -> Unit, + onCancelWaiting: () -> Unit, + onNavigateToPlaceDetail: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + var showCancelConfirmDialog by remember { mutableStateOf(false) } + + if (showCancelConfirmDialog) { + WaitingCancelConfirmDialog( + title = stringResource(Res.string.my_waiting_cancel_confirm_title), + onDismissClick = { showCancelConfirmDialog = false }, + onCancelClick = { + showCancelConfirmDialog = false + onCancelWaiting() + }, + onDismissRequest = { showCancelConfirmDialog = false }, + ) + } + + Box( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = festabookSpacing.paddingScreenGutter), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + uiState.myWaiting.placeDetail?.let { placeDetail -> + Box( + modifier = + Modifier + .cardBackground( + backgroundColor = FestabookColor.white, + shape = festabookShapes.radius4, + ).clickable { onNavigateToPlaceDetail(uiState.myWaiting.placeId) }, + ) { + PlaceDetailPreviewContent(placeDetail = placeDetail) + } + } + + HorizontalDivider( + color = FestabookColor.gray200, + thickness = 4.dp, + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding(vertical = festabookSpacing.paddingBody5), + ) + + NoShowNoticeCard( + text = stringResource(Res.string.my_waiting_notice_text), + ) + + HorizontalDivider( + color = FestabookColor.gray200, + thickness = 4.dp, + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding(vertical = festabookSpacing.paddingBody5), + ) + + WaitingStatusCard( + order = uiState.myWaiting.order, + totalWaitingTeams = uiState.myWaiting.totalWaitingTeams, + estimatedWaitTime = uiState.myWaiting.estimatedWaitTime, + status = uiState.myWaiting.status, + isRefreshing = uiState.myWaiting.isRefreshing, + onRefresh = onRefresh, + ) + + HorizontalDivider( + color = FestabookColor.gray200, + thickness = 4.dp, + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding(vertical = festabookSpacing.paddingBody5), + ) + + WaitingRegistrationInfo( + partySize = uiState.myWaiting.partySize, + phoneNumber = uiState.myWaiting.phoneNumber, + ) + + Spacer(modifier = Modifier.height(100.dp)) + } + WaitingCancelButton( + isEnabled = !uiState.myWaiting.isCanceling, + isCancelling = uiState.myWaiting.isCanceling, + onClick = { showCancelConfirmDialog = true }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +@Composable +private fun NoShowNoticeCard( + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .cardBackground() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(Res.drawable.ic_info), + contentDescription = null, + tint = FestabookColor.gray600, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = text, + style = FestabookTypography.bodySmall, + color = FestabookColor.gray600, + ) + } +} + +@Composable +private fun WaitingRegistrationInfo( + partySize: Int, + phoneNumber: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(Res.string.setting_waiting_info_section_title), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = festabookSpacing.paddingBody4), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.my_waiting_party_size_label), + style = FestabookTypography.bodyMedium, + color = FestabookColor.gray500, + ) + Text( + text = stringResource(Res.string.my_waiting_party_size_format, partySize), + style = FestabookTypography.bodyMedium, + color = FestabookColor.black, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.my_waiting_phone_label), + style = FestabookTypography.bodyMedium, + color = FestabookColor.gray500, + ) + Text( + text = phoneNumber, + style = FestabookTypography.bodyMedium, + color = FestabookColor.black, + ) + } + } +} + +@Composable +private fun WaitingCancelButton( + isEnabled: Boolean, + isCancelling: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .padding( + vertical = festabookSpacing.paddingBody4, + ).fillMaxWidth() + .height(52.dp) + .clip(festabookShapes.radius2) + .background(if (isEnabled) FestabookColor.black else FestabookColor.gray300) + .clickable(enabled = isEnabled) { onClick() }, + contentAlignment = Alignment.Center, + ) { + if (isCancelling) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = FestabookColor.white, + strokeWidth = 4.dp, + ) + } else { + Text( + text = stringResource(Res.string.my_waiting_cancel_button), + style = FestabookTypography.displayMedium, + color = FestabookColor.white, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt new file mode 100644 index 00000000..ad16b090 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt @@ -0,0 +1,262 @@ +package com.daedan.festabook.presentation.waiting.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.daedan.festabook.domain.model.WaitingStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.my_waiting_enter_now +import festabookkmp.composeapp.generated.resources.my_waiting_estimated_time_format +import festabookkmp.composeapp.generated.resources.my_waiting_estimated_time_label +import festabookkmp.composeapp.generated.resources.my_waiting_headline_called +import festabookkmp.composeapp.generated.resources.my_waiting_headline_waiting_far +import festabookkmp.composeapp.generated.resources.my_waiting_headline_waiting_near +import festabookkmp.composeapp.generated.resources.my_waiting_order_format +import festabookkmp.composeapp.generated.resources.my_waiting_total_teams_format +import festabookkmp.composeapp.generated.resources.my_waiting_total_teams_label +import org.jetbrains.compose.resources.stringResource + +private const val WAITING_NEAR_THRESHOLD = 3 + +@Composable +fun WaitingStatusCard( + order: Int, + totalWaitingTeams: Int, + estimatedWaitTime: Int, + status: WaitingStatus, + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + val headline = statusHeadline(status, order) + val showEnterNow = status == WaitingStatus.CALLED + + Column( + modifier = + modifier + .fillMaxWidth() + .background(FestabookColor.black, festabookShapes.radius4) + .padding(24.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = headline, + style = FestabookTypography.displaySmall, + color = FestabookColor.white, + modifier = Modifier.weight(1f), + ) + RefreshButton( + onClick = onRefresh, + isRefreshing = isRefreshing, + ) + } + + Spacer(Modifier.height(festabookSpacing.paddingBody5)) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + contentAlignment = Alignment.Center, + ) { + if (showEnterNow) { + Text( + text = stringResource(Res.string.my_waiting_enter_now), + style = FestabookTypography.displayLarge, + color = FestabookColor.white, + ) + } else { + val orderFormatted = stringResource(Res.string.my_waiting_order_format) + val numberText = order.toString() + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.offset(y = (-4).dp), + ) { + Text( + text = numberText, + style = FestabookTypography.displayLarge.copy(fontSize = 48.sp), + color = FestabookColor.white, + ) + Text( + text = orderFormatted, + style = FestabookTypography.titleLarge, + color = FestabookColor.white, + modifier = Modifier.padding(start = 2.dp), + ) + } + } + } + + Spacer(Modifier.height(festabookSpacing.paddingBody3)) + HorizontalDivider(color = FestabookColor.white) + Spacer(Modifier.height(festabookSpacing.paddingBody2)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + ) { + WaitingInfoItem( + label = stringResource(Res.string.my_waiting_total_teams_label), + value = stringResource(Res.string.my_waiting_total_teams_format, totalWaitingTeams), + modifier = + Modifier + .padding(top = festabookSpacing.paddingBody1) + .weight(1f), + ) + VerticalDivider(color = FestabookColor.white) + WaitingInfoItem( + label = stringResource(Res.string.my_waiting_estimated_time_label), + value = + stringResource( + Res.string.my_waiting_estimated_time_format, + estimatedWaitTime, + ), + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody1, + start = festabookSpacing.paddingBody2, + ).weight(1f), + ) + } + } +} + +@Composable +private fun WaitingInfoItem( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = 8.dp), + ) { + Text( + text = label, + style = FestabookTypography.bodySmall, + color = FestabookColor.white, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = FestabookTypography.displaySmall, + color = FestabookColor.white, + ) + } +} + +@Composable +private fun RefreshButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + isRefreshing: Boolean = false, +) { + val rotation = remember { Animatable(0f) } + val durationMillis = 500 + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + while (true) { + rotation.animateTo( + targetValue = rotation.value + 360f, + animationSpec = tween(durationMillis = durationMillis, easing = LinearEasing), + ) + } + } else { + val remainder = rotation.value % 360f + if (remainder > 0f) { + val target = rotation.value + (360f - remainder) + val remainingFraction = (360f - remainder) / 360f + rotation.animateTo( + targetValue = target, + animationSpec = + tween( + durationMillis = + (remainingFraction * durationMillis) + .toInt() + .coerceAtLeast(1), + easing = LinearEasing, + ), + ) + } + } + } + + Box( + modifier = + modifier + .background( + color = FestabookColor.white, + shape = festabookShapes.radiusFull, + ).clickable( + onClick = onClick, + ).padding(festabookSpacing.paddingBody1), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + tint = FestabookColor.black, + modifier = Modifier.size(20.dp).rotate(rotation.value), + ) + } +} + +@Composable +private fun statusHeadline( + status: WaitingStatus, + order: Int, +): String = + when (status) { + WaitingStatus.WAITING if order >= WAITING_NEAR_THRESHOLD -> { + stringResource(Res.string.my_waiting_headline_waiting_far) + } + + WaitingStatus.WAITING if order < WAITING_NEAR_THRESHOLD -> { + stringResource(Res.string.my_waiting_headline_waiting_near) + } + + WaitingStatus.CALLED -> { + stringResource(Res.string.my_waiting_headline_called) + } + + else -> { + stringResource(Res.string.my_waiting_headline_waiting_far) + } + } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/model/MyWaitingUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/model/MyWaitingUiModel.kt new file mode 100644 index 00000000..ca40f75f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/model/MyWaitingUiModel.kt @@ -0,0 +1,18 @@ +package com.daedan.festabook.presentation.waiting.model + +import com.daedan.festabook.domain.model.WaitingStatus +import com.daedan.festabook.presentation.placeMap.placeDetail.model.PlaceDetailUiModel + +data class MyWaitingUiModel( + val waitingId: Long, + val placeId: Long, + val placeDetail: PlaceDetailUiModel?, + val order: Int, + val partySize: Int, + val phoneNumber: String, + val totalWaitingTeams: Int, + val estimatedWaitTime: Int, + val status: WaitingStatus, + val isRefreshing: Boolean = false, + val isCanceling: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/navigation/MyWaitingNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/navigation/MyWaitingNavigation.kt new file mode 100644 index 00000000..a3d3debd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/navigation/MyWaitingNavigation.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.waiting.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.waiting.MyWaitingViewModel +import com.daedan.festabook.presentation.waiting.component.MyWaitingRoute +import dev.zacsweers.metrox.viewmodel.metroViewModel + +fun NavGraphBuilder.myWaitingNavGraph( + onBack: () -> Unit, + onNavigateToPlaceDetail: (Long) -> Unit, + onShowSnackbar: (String) -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, +) { + composable { + MyWaitingRoute( + viewModel = metroViewModel(), + onBack = onBack, + onNavigateToPlaceDetail = onNavigateToPlaceDetail, + onShowSnackbar = onShowSnackbar, + onShowErrorSnackbar = onShowErrorSnackbar, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt index 389302bb..2300655c 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/waiting/WaitingTestFixture.kt @@ -26,6 +26,7 @@ val FAKE_MY_WAITING_RESPONSE = totalWaitingTeams = 10, estimatedWaitTime = 15, phoneNumber = "010-1234-5678", + placeId = 1L, ) val FAKE_PLACE_WAITING_RESPONSE = diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImplTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImplTest.kt new file mode 100644 index 00000000..a949541a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImplTest.kt @@ -0,0 +1,119 @@ +package com.daedan.festabook.data.repository + +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.datasource.remote.waiting.FAKE_MY_WAITING_RESPONSE +import com.daedan.festabook.data.datasource.remote.waiting.FAKE_WAITING_EXIST_RESPONSE +import com.daedan.festabook.data.datasource.remote.waiting.FAKE_WAITING_NOT_EXIST_RESPONSE +import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource +import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.model.WaitingStatus +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class MyWaitingRepositoryImplTest { + private lateinit var waitingRemoteDataSource: WaitingRemoteDataSource + private lateinit var repository: MyWaitingRepositoryImpl + + @BeforeTest + fun setUp() { + waitingRemoteDataSource = mock() + repository = + MyWaitingRepositoryImpl( + waitingRemoteDataSource = waitingRemoteDataSource, + ) + } + + @Test + fun `웨이팅이 존재하고 WAITING 상태면 placeId를 포함한 MyWaiting을 반환한다`() = + runTest { + // given + everySuspend { waitingRemoteDataSource.fetchMyWaitingExist() } returns + ApiResult.Success(FAKE_WAITING_EXIST_RESPONSE) + everySuspend { waitingRemoteDataSource.fetchMyWaiting() } returns + ApiResult.Success(FAKE_MY_WAITING_RESPONSE) + + // when + val result = repository.getMyWaiting() + + // then + assertTrue(result.isSuccess) + val myWaiting = result.getOrNull() + assertIs(myWaiting) + assertEquals(WaitingStatus.WAITING, myWaiting.waitingStatus) + assertEquals(FAKE_MY_WAITING_RESPONSE.placeId, myWaiting.placeId) + } + + @Test + fun `서버가 exists=false를 반환하면 null을 반환한다`() = + runTest { + // given + everySuspend { waitingRemoteDataSource.fetchMyWaitingExist() } returns + ApiResult.Success(FAKE_WAITING_NOT_EXIST_RESPONSE) + + // when + val result = repository.getMyWaiting() + + // then + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + + @Test + fun `서버가 CANCELED 상태를 반환하면 null을 반환한다`() = + runTest { + // given + val canceledResponse = + FAKE_MY_WAITING_RESPONSE.copy( + waitingStatus = WaitingStatus.CANCELED, + ) + everySuspend { waitingRemoteDataSource.fetchMyWaitingExist() } returns + ApiResult.Success(FAKE_WAITING_EXIST_RESPONSE) + everySuspend { waitingRemoteDataSource.fetchMyWaiting() } returns + ApiResult.Success(canceledResponse) + + // when + val result = repository.getMyWaiting() + + // then + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + + @Test + fun `cancelWaiting 성공 시 Result_success를 반환한다`() = + runTest { + // given + everySuspend { waitingRemoteDataSource.cancelWaiting(any()) } returns ApiResult.Success(Unit) + + // when + val result = repository.cancelWaiting(waitingId = 1L) + + // then + assertTrue(result.isSuccess) + } + + @Test + fun `cancelWaiting 실패 시 Result_failure를 반환한다`() = + runTest { + // given + everySuspend { waitingRemoteDataSource.cancelWaiting(any()) } returns + ApiResult.ServerError(500, null, null) + + // when + val result = repository.cancelWaiting(waitingId = 1L) + + // then + assertTrue(result.isFailure) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImplTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImplTest.kt new file mode 100644 index 00000000..21b8ae4d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImplTest.kt @@ -0,0 +1,62 @@ +package com.daedan.festabook.data.repository + +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.datasource.remote.waiting.FAKE_MY_WAITING_RESPONSE +import com.daedan.festabook.data.datasource.remote.waiting.WaitingRemoteDataSource +import com.daedan.festabook.data.model.request.WaitingRegisterRequest +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WaitingRegisterInfoRepositoryImplTest { + private lateinit var waitingRemoteDataSource: WaitingRemoteDataSource + private lateinit var repository: WaitingRegisterInfoRepositoryImpl + + private val placeId = 42L + private val partySize = 2 + + @BeforeTest + fun setUp() { + waitingRemoteDataSource = mock() + repository = + WaitingRegisterInfoRepositoryImpl( + waitingRemoteDataSource = waitingRemoteDataSource, + ) + } + + @Test + fun `웨이팅 등록 성공 시 Result_success를 반환한다`() = + runTest { + // given + everySuspend { + waitingRemoteDataSource.registerWaiting(placeId, WaitingRegisterRequest(partySize)) + } returns ApiResult.Success(FAKE_MY_WAITING_RESPONSE) + + // when + val result = repository.registerWaiting(placeId, partySize) + + // then + assertTrue(result.isSuccess) + } + + @Test + fun `서버 오류 시 Result_failure를 반환한다`() = + runTest { + // given + everySuspend { + waitingRemoteDataSource.registerWaiting(placeId, WaitingRegisterRequest(partySize)) + } returns ApiResult.ServerError(500, null, null) + + // when + val result = repository.registerWaiting(placeId, partySize) + + // then + assertTrue(result.isFailure) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt index ec38ba52..cff20e2d 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt @@ -1,10 +1,14 @@ package com.daedan.festabook.home +import com.daedan.festabook.domain.model.MyWaiting +import com.daedan.festabook.domain.model.WaitingStatus import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.domain.repository.MyWaitingRepository import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.home.toUiModel import dev.mokkery.answering.returns import dev.mokkery.everySuspend @@ -30,11 +34,25 @@ class HomeViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var homeViewModel: HomeViewModel private lateinit var festivalRepository: FestivalRepository + private lateinit var myWaitingRepository: MyWaitingRepository + + private val fakeMyWaiting = + MyWaiting( + waitingId = 1L, + waitingOrderFromZero = 3, + partySize = 2, + waitingStatus = WaitingStatus.WAITING, + totalWaitingTeams = 10, + estimatedWaitTime = 15, + phoneNumber = "010-1234-5678", + placeId = 1L, + ) @BeforeTest fun setup() { Dispatchers.setMain(testDispatcher) festivalRepository = mock() + myWaitingRepository = mock() everySuspend { festivalRepository.getFestivalInfo() } returns Result.success( FAKE_ORGANIZATION, @@ -45,8 +63,9 @@ class HomeViewModelTest { FAKE_LINEUP[0].performanceAt.date to FAKE_LINEUP, ), ) + everySuspend { myWaitingRepository.getMyWaiting() } returns Result.success(null) - homeViewModel = HomeViewModel(festivalRepository) + homeViewModel = HomeViewModel(festivalRepository, myWaitingRepository) } @AfterTest @@ -156,4 +175,51 @@ class HomeViewModelTest { job.cancel() } + + @Test + fun `웨이팅이 있으면 WaitingBarUiState가 Visible이 된다`() = + runTest { + // given + everySuspend { myWaitingRepository.getMyWaiting() } returns + Result.success(fakeMyWaiting) + + // when + homeViewModel.loadWaitingBar() + advanceUntilIdle() + + // then + val actual = homeViewModel.waitingBarUiState.value + assertIs(actual) + assertEquals(fakeMyWaiting.waitingOrder, actual.order) + assertEquals(fakeMyWaiting.estimatedWaitTime, actual.estimatedWaitTime) + } + + @Test + fun `웨이팅이 없으면 WaitingBarUiState가 Hidden이 된다`() = + runTest { + // given + everySuspend { myWaitingRepository.getMyWaiting() } returns Result.success(null) + + // when + homeViewModel.loadWaitingBar() + advanceUntilIdle() + + // then + assertIs(homeViewModel.waitingBarUiState.value) + } + + @Test + fun `웨이팅 조회 실패 시 WaitingBarUiState가 Hidden이 된다`() = + runTest { + // given + everySuspend { myWaitingRepository.getMyWaiting() } returns + Result.failure(Throwable("Network Error")) + + // when + homeViewModel.loadWaitingBar() + advanceUntilIdle() + + // then + assertIs(homeViewModel.waitingBarUiState.value) + } } diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt index bb819bdd..4b18937f 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/placeDetail/PlaceDetailTestFixture.kt @@ -67,10 +67,11 @@ val FAKE_ETC_PLACE_DETAIL = val FAKE_MY_WAITING = MyWaiting( waitingId = 42L, - waitingOrder = 3, + waitingOrderFromZero = 3, partySize = 2, waitingStatus = WaitingStatus.WAITING, totalWaitingTeams = 10, estimatedWaitTime = 15, phoneNumber = "010-1234-5678", + placeId = 1, ) diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt index f8da52d1..a68bf931 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt @@ -2,6 +2,8 @@ package com.daedan.festabook.delegate import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.presentation.platform.DeepLinkKeys +import com.daedan.festabook.presentation.platform.FcmDeepLinkAction +import com.daedan.festabook.presentation.platform.FcmMessageType import com.daedan.festabook.presentation.platform.PendingFcmNotification import dev.zacsweers.metro.Inject import kotlinx.coroutines.CoroutineScope @@ -44,8 +46,8 @@ class DefaultUserNotificationDelegate( (userInfo[DeepLinkKeys.KEY_FESTIVAL_ID] as? String)?.toLongOrNull() ?: DeepLinkKeys.INITIALIZED_ID - val announcementId = - (userInfo[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String)?.toLongOrNull() ?: run { + val action = + userInfo.toDeepLinkAction() ?: run { withCompletionHandler() return } @@ -60,7 +62,7 @@ class DefaultUserNotificationDelegate( } PendingFcmNotification.store( - announcementId = announcementId, + action = action, festivalIdChanged = festivalIdChanged, ) withContext(Dispatchers.Main) { @@ -68,4 +70,24 @@ class DefaultUserNotificationDelegate( } } } + + private fun Map.toDeepLinkAction(): FcmDeepLinkAction? { + val type = FcmMessageType.from(this[DeepLinkKeys.KEY_TYPE] as? String) + return when (type) { + FcmMessageType.WAITING_CALL, + FcmMessageType.WAITING_ALMOST_CALL, + -> FcmDeepLinkAction.OpenMyWaiting + + FcmMessageType.WAITING_PLACE_ACCESS_CANCEL -> { + val placeId = (this[DeepLinkKeys.KEY_PLACE_ID] as? String)?.toLongOrNull() ?: return null + FcmDeepLinkAction.OpenPlaceDetail(placeId) + } + + FcmMessageType.ANNOUNCEMENT, null -> { + val announcementId = + (this[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String)?.toLongOrNull() ?: return null + FcmDeepLinkAction.OpenAnnouncement(announcementId) + } + } + } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt index 87400e30..68dca791 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/PendingFcmNotification.kt @@ -17,19 +17,19 @@ object PendingFcmNotification { val pending: Flow = _pending.receiveAsFlow() fun store( - announcementId: Long, + action: FcmDeepLinkAction, festivalIdChanged: Boolean, ) { _pending.trySend( Data( - announcementId = announcementId, + action = action, festivalIdChanged = festivalIdChanged, ), ) } data class Data( - val announcementId: Long, + val action: FcmDeepLinkAction, val festivalIdChanged: Boolean, ) } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt index baa090a7..7e6bf6e8 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.ios.kt @@ -7,12 +7,12 @@ import androidx.compose.runtime.rememberUpdatedState import kotlinx.coroutines.flow.collectLatest @Composable -actual fun RememberDeepLinkHandler(onNotificationClick: (announcementId: Long, festivalIdChanged: Boolean) -> Unit) { +actual fun RememberDeepLinkHandler(onNotificationClick: (action: FcmDeepLinkAction, festivalIdChanged: Boolean) -> Unit) { val currentOnNotificationClicked by rememberUpdatedState(onNotificationClick) LaunchedEffect(Unit) { PendingFcmNotification.pending.collectLatest { data -> - currentOnNotificationClicked(data.announcementId, data.festivalIdChanged) + currentOnNotificationClicked(data.action, data.festivalIdChanged) } } } From 32974bb98e0e5b586ea24e16fcc1ade25e03d8ec Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:23:12 +0900 Subject: [PATCH 28/54] =?UTF-8?q?[CI/CD]=20Fastlane=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=84=B1=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Fastlane 기반 Android 및 iOS CI/CD 배포 자동화 환경 구축 * **feat: Fastlane 설정 파일(`Appfile`, `Fastfile`) 및 의존성 추가** * `Fastfile`: Android(Firebase App Distribution) 및 iOS(TestFlight) 배포를 위한 빌드/업로드 lane을 정의했습니다. * `local.properties` 기반으로 API 키, 인증서 경로, Slack 웹훅 등 환경 변수를 관리하도록 설계했습니다. * `Appfile`: Android 패키지명(Release/Debug) 정보를 설정했습니다. * `Gemfile`, `Gemfile.lock`: `fastlane`, `cocoapods`, `firebase_app_distribution` 플러그인 의존성을 명시했습니다. * **feat: 배포 상태 알림 및 버전 동기화 로직 추가** * `notify_slack`: 빌드 결과(성공/실패), 플랫폼, 버전 정보를 Slack으로 전송하는 헬퍼 함수를 구현했습니다. * iOS 빌드 전 `updateIosVersion` Gradle 태스크를 실행하여 프로젝트 버전 정보를 동기화하도록 설정했습니다. * **feat: iOS 프로젝트 설정 보완** * `iosApp.xcscheme`: CI 환경에서 빌드가 가능하도록 공유 스키마 파일을 추가하고, 디버깅을 위한 Firebase Analytics 인자값을 설정했습니다. * **chore: `.gitignore` 및 보안 설정 업데이트** * Fastlane 빌드 결과물(`report.xml`, `build/` 등) 및 민감한 파일(`*.p8`, `*.p12`, `*.mobileprovision`, `firebase-services.json`)을 무시 목록에 추가했습니다. * 공유가 필요한 iOS 스키마 파일은 git 추적 대상에 포함시켰습니다. * chore: 앱 버전 정보 업데이트 (2.1.0 → 2.1.1) * **chore: iOS 프로젝트 버전 및 빌드 번호 상향** * `Info.plist`: `CFBundleShortVersionString`을 `2.1.1`로, `CFBundleVersion`을 `20101`로 업데이트했습니다. * **chore: Gradle 프로젝트 버전 정보 상향** * `gradle.properties`: `APP_VERSION_NAME`을 `2.1.1`로, `APP_VERSION_CODE`를 `20101`로 변경했습니다. --- .gitignore | 13 + Gemfile | 7 + Gemfile.lock | 478 ++++++++++++++++++ fastlane/Appfile | 12 + fastlane/Fastfile | 349 +++++++++++++ fastlane/Pluginfile | 2 + gradle.properties | 4 +- .../xcshareddata/xcschemes/iosApp.xcscheme | 92 ++++ iosApp/iosApp/Info.plist | 4 +- 9 files changed, 957 insertions(+), 4 deletions(-) create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 fastlane/Appfile create mode 100644 fastlane/Fastfile create mode 100644 fastlane/Pluginfile create mode 100644 iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme diff --git a/.gitignore b/.gitignore index 30becebc..80356aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ captures/ **/xcshareddata/WorkspaceSettings.xcsettings *.xcuserstate *.xcscheme +!iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme *.xcworkspace xcuserdata/ @@ -66,6 +67,7 @@ local.properties *.jks *.keystore google-services.json +firebase-services.json iosApp/iosApp/GoogleService-Info-DEBUG.plist iosApp/iosApp/GoogleService-Info-RELEASE.plist iosApp/iosApp/GoogleService-Info.plist @@ -74,3 +76,14 @@ iosApp/iosApp/GoogleService-Info.plist /iosApp/Configuration/Debug.xcconfig /iosApp/Configuration/Release.xcconfig .worktrees/ + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/ +fastlane/test_output/ +fastlane/README.md +fastlane/build/ +*.p8 +*.p12 +*.mobileprovision diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..14eeb4c5 --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "cocoapods" + +plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile") +eval_gemfile(plugins_path) if File.exist?(plugins_path) \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..2111a82c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,478 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1240.0) + aws-sdk-core (3.245.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.219.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + drb (2.2.3) + emoji_regex (3.2.3) + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + excon (0.112.0) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.233.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + nkf (~> 0.2.0) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-firebase_app_distribution (1.0.0) + fastlane (>= 2.232.0) + google-apis-firebaseappdistribution_v1 (>= 0.9.0) + google-apis-firebaseappdistribution_v1alpha (>= 0.12.0) + fastlane-sirp (1.1.0) + ffi (1.17.4) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86-linux-musl) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.99.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-firebaseappdistribution_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-firebaseappdistribution_v1alpha (0.26.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-iamcredentials_v1 (0.26.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.61.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + jmespath (1.6.2) + json (2.19.4) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + minitest (5.27.0) + molinillo (0.8.0) + multi_json (1.20.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + naturally (2.3.0) + netrc (0.11.0) + nkf (0.2.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (4.0.7) + rake (13.4.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.4.1) + rexml (3.4.4) + rouge (3.28.0) + ruby-macho (2.5.1) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + securerandom (0.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + cocoapods + fastlane + fastlane-plugin-firebase_app_distribution + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853 + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1240.0) sha256=b885b21712fdad699c56a28434756ed27f1389b07231320796dda9e7551de4a7 + aws-sdk-core (3.245.0) sha256=94f43b40508c346f1625b3d31f811c5841b667c92de14d5fb3c473b1284cb4ad + aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20 + aws-sdk-s3 (1.219.0) sha256=6a755d7377978525758b3c29185ca6a10128ce2b07555ca37c4549de10c2f1c7 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507 + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + cocoapods (1.16.2) sha256=0ff1c860f32df3db8b16df09b58da1a6bb2a12fe55f6d5e8be994a74fadd1e5e + cocoapods-core (1.16.2) sha256=4bb1b5c420691e60cf36fa227dec6bc48c096c34c97bb7aa512ea7f3246fc12b + cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2 + cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1 + cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a + cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589 + cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31 + cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169 + ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2 + excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0 + faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 + faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 + faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 + faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 + faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 + faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 + faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 + faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa + faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 + fastlane (2.233.0) sha256=b07087aaf13e7a4bcb07f853d91780193262b9f048861a24aa5e0f24069b8092 + fastlane-plugin-firebase_app_distribution (1.0.0) sha256=321f554d16194ea465c71202274c75609cf986f147fc7203901d44a1a6df736f + fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 + ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf + ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809 + ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9 + fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + google-apis-androidpublisher_v3 (0.99.0) sha256=a0452fdd99cb7672cc95cac07429305f8aaee54c22e4875224cf675b1ab59729 + google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-firebaseappdistribution_v1 (0.18.0) sha256=5b3407a2c789c52a971a83a48ed70b360f5d5020ef6436ce7c459666f9705d02 + google-apis-firebaseappdistribution_v1alpha (0.26.0) sha256=6f495025debbf745ad09e158b509f3c728b35e1193c30fb7ed7bc348c373c9b6 + google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3 + google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78 + google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8 + google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf + google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999 + google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1 + google-cloud-storage (1.59.0) sha256=b8c9a5661d775d65ccb279bb1d6be07fd8152576eb0146c2026bd023c4b186b9 + googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac + jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d + multi_json (1.20.1) sha256=2f3934e805cc45ef91b551a1f89d0e9191abd06a5e04a2ef09a6a036c452ca6d + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f + nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace + retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3 + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + +BUNDLED WITH + 4.0.8 diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 00000000..fdfe91b4 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,12 @@ +for_platform :android do + package_name("com.daedan.festabook") + + for_lane :dev do + package_name("com.daedan.festabook.debug") + end +end + +for_platform :ios do + # bundle id는 Fastfile의 lane 내부에서 local.properties 기반으로 해석합니다. + # Appfile 레벨에서는 apple_id / itc_team_id를 고정하지 않습니다. +end \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 00000000..0c8c78ac --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,349 @@ +fastlane_version "2.219.0" + +# 프로젝트 루트(= fastlane 디렉토리의 상위). 호출 CWD 와 무관하게 동작하도록 절대경로로 고정. +PROJECT_ROOT = File.expand_path("..", __dir__) + +# --------------------------------------------------------------------------- +# local.properties 기반 설정 +# +# 다음 키들이 프로젝트 루트의 local.properties 에 정의되어 있어야 합니다. +# (값은 각자 환경에 맞게 채워 넣습니다 — local.properties 는 git 에서 제외됨) +# +# # Firebase App Distribution +# FIREBASE_APP_ID_ANDROID_DEV=1:xxxxxxxxxxxx:android:xxxxxxxxxxxxxxxx +# FIREBASE_APP_ID_ANDROID_RELEASE=1:xxxxxxxxxxxx:android:xxxxxxxxxxxxxxxx +# FIREBASE_SERVICE_ACCOUNT_PATH=/absolute/path/to/firebase-service-account.json +# FIREBASE_TESTER_GROUPS=internal-testers # 선택, 기본값 "internal-testers" +# +# # iOS / App Store Connect API Key (TestFlight 업로드용 — build_* lane 에서는 불필요) +# APPLE_TEAM_ID=ABCDE12345 +# ASC_KEY_ID=XXXXXXXXXX +# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +# +# # iOS 서명 (p12 + mobileprovision) +# IOS_CERT_P12_PATH=/absolute/path/to/distribution.p12 +# IOS_CERT_P12_PASSWORD=xxxxxxxx +# IOS_PROVISIONING_PROFILE_STAGING_PATH=/absolute/path/to/staging.mobileprovision +# IOS_PROVISIONING_PROFILE_STAGING_NAME=Festabook Staging AppStore +# IOS_PROVISIONING_PROFILE_RELEASE_PATH=/absolute/path/to/release.mobileprovision +# IOS_PROVISIONING_PROFILE_RELEASE_NAME=Festabook Release AppStore +# +# # Slack 알림 (선택 — 비어 있으면 알림 생략) +# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ +# SLACK_CHANNEL=#festabook-deploy # 선택, webhook default 채널 사용하려면 비워둠 +# +# 기존에 이미 있던 키 (재사용): +# APP_BUNDLE_ID=eoehdeksruf.festabook +# APP_BUNDLE_ID_DEV=debug.eoehdeksruf.festabook +# +# 환경 변수 (CI 전용, 로컬 개발자는 login 키체인이 이미 unlock 돼 있으므로 불필요): +# FASTLANE_KEYCHAIN_NAME = fastlane_tmp.keychain-db # CI 에서 만든 임시 키체인 이름 +# FASTLANE_KEYCHAIN_PASSWORD = <임시 키체인 비밀번호> # import_certificate unlock 용 +# +# --------------------------------------------------------------------------- +# 제공되는 Lane +# +# bundle exec fastlane android build_dev # Debug APK 빌드만 +# bundle exec fastlane android build_release # Release APK 빌드만 (prod API) +# bundle exec fastlane android dev # 빌드 + Firebase App Distribution(dev) 업로드 +# bundle exec fastlane android release # 빌드 + Firebase App Distribution(release) 업로드 +# +# bundle exec fastlane ios build_dev # Staging IPA 빌드만 +# bundle exec fastlane ios build_release # Release IPA 빌드만 +# bundle exec fastlane ios dev # 빌드 + TestFlight 업로드 (Staging) +# bundle exec fastlane ios release # 빌드 + TestFlight 업로드 (Release) +# --------------------------------------------------------------------------- + +def local_properties + @local_properties ||= begin + path = File.expand_path("../local.properties", __dir__) + hash = {} + if File.exist?(path) + File.readlines(path).each do |raw| + line = raw.strip + next if line.empty? || line.start_with?("#") + key, _, value = line.partition("=") + next if key.strip.empty? + hash[key.strip] = value.strip + end + end + hash + end +end + +def local_property(key) + value = local_properties[key] + UI.user_error!("#{key} 가 local.properties 에 없습니다.") if value.nil? || value.empty? + value +end + +def optional_local_property(key, default) + value = local_properties[key] + (value.nil? || value.empty?) ? default : value +end + +def app_version + @app_version ||= begin + content = File.read(File.expand_path("../gradle.properties", __dir__)) + name = content[/^APP_VERSION_NAME=(\S+)/, 1] || "?" + code = content[/^APP_VERSION_CODE=(\S+)/, 1] || "?" + "#{name} (#{code})" + end +end + +def notify_slack(platform:, build_type:, success:, target: nil, error: nil) + webhook = optional_local_property("SLACK_WEBHOOK_URL", "") + if webhook.empty? + UI.message("SLACK_WEBHOOK_URL 가 비어 있어 Slack 알림을 생략합니다.") + return + end + channel = optional_local_property("SLACK_CHANNEL", "") + + platform_icon = platform == "Android" ? ":robot_face:" : ":apple:" + status_icon = success ? ":rocket:" : ":x:" + status_text = success ? "배포 완료" : "배포 실패" + + lines = [ + "#{status_icon} #{platform_icon} #{platform} #{status_text}", + "빌드 타입: #{build_type}", + "버전: v#{app_version}", + ] + lines << "Target: #{target}" if target + lines << "에러: #{error}" if error + + slack( + message: lines.join("\n"), + success: success, + slack_url: webhook, + channel: channel.empty? ? nil : channel, + default_payloads: [:git_branch, :git_author, :last_git_commit_message], + ) +end + +# --- Android helpers (private_lane: `fastlane lanes` 출력에서 숨김) --- + +private_lane :android_build do |options| + flavor = options[:flavor] + case flavor + when "dev" + gradle( + task: "composeApp:assembleDebug", + project_dir: PROJECT_ROOT, + ) + File.join(PROJECT_ROOT, "composeApp/build/outputs/apk/debug/composeApp-debug.apk") + when "release" + gradle( + task: "composeApp:assembleRelease", + project_dir: PROJECT_ROOT, + properties: { "buildkonfig.flavor" => "release" }, + ) + File.join(PROJECT_ROOT, "composeApp/build/outputs/apk/release/composeApp-release.apk") + else + UI.user_error!("알 수 없는 Android flavor: #{flavor}") + end +end + +private_lane :android_upload do |options| + flavor = options[:flavor] + apk_path = options[:apk_path] + app_id_key = flavor == "dev" ? "FIREBASE_APP_ID_ANDROID_DEV" : "FIREBASE_APP_ID_ANDROID_RELEASE" + firebase_app_distribution( + app: local_property(app_id_key), + service_credentials_file: local_property("FIREBASE_SERVICE_ACCOUNT_PATH"), + groups: optional_local_property("FIREBASE_TESTER_GROUPS", "festabook"), + release_notes: changelog_from_git_commits(commits_count: 10, merge_commit_filtering: "exclude_merges"), + android_artifact_type: "APK", + android_artifact_path: apk_path, + ) + notify_slack( + platform: "Android", + build_type: flavor == "dev" ? "Dev" : "Release", + success: true, + target: "Firebase App Distribution", + ) +end + +# --- iOS helpers (private_lane) --- + +private_lane :ios_prepare_signing do |options| + profile_path = options[:profile_path] + import_certificate( + certificate_path: local_property("IOS_CERT_P12_PATH"), + certificate_password: local_property("IOS_CERT_P12_PASSWORD"), + keychain_name: ENV["FASTLANE_KEYCHAIN_NAME"] || "login.keychain-db", + keychain_password: ENV["FASTLANE_KEYCHAIN_PASSWORD"], + log_output: false, + ) + install_provisioning_profile(path: profile_path) +end + +private_lane :ios_build do |options| + configuration = options[:configuration] + bundle_id = options[:bundle_id] + profile_path = options[:profile_path] + profile_name = options[:profile_name] + team_id = local_property("APPLE_TEAM_ID") + + ios_prepare_signing(profile_path: profile_path) + + cocoapods( + podfile: File.join(PROJECT_ROOT, "iosApp/Podfile"), + try_repo_update_on_error: true, + ) + + output_directory = File.join(PROJECT_ROOT, "fastlane/build/ios") + + # Release configuration 은 pbxproj 상 CODE_SIGN_STYLE=Automatic 이므로 + # xcargs 로 Manual + 지정 프로파일을 강제해서 CI/로컬 모두 동일하게 동작하도록 한다. + build_app( + workspace: File.join(PROJECT_ROOT, "iosApp/iosApp.xcworkspace"), + scheme: "iosApp", + configuration: configuration, + clean: true, + export_method: "app-store", + output_directory: output_directory, + output_name: "iosApp-#{configuration}.ipa", + export_options: { + signingStyle: "manual", + teamID: team_id, + provisioningProfiles: { + bundle_id => profile_name, + }, + }, + xcargs: [ + "DEVELOPMENT_TEAM=#{team_id}", + "CODE_SIGN_STYLE=Manual", + "PROVISIONING_PROFILE_SPECIFIER=\"#{profile_name}\"", + ].join(" "), + ) + + File.join(output_directory, "iosApp-#{configuration}.ipa") +end + +private_lane :ios_upload do |options| + configuration = options[:configuration] + bundle_id = options[:bundle_id] + ipa_path = options[:ipa_path] + api_key = app_store_connect_api_key( + key_id: local_property("ASC_KEY_ID"), + issuer_id: local_property("ASC_ISSUER_ID"), + key_filepath: local_property("ASC_KEY_PATH"), + duration: 1200, + in_house: false, + ) + + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true, + ipa: ipa_path, + ) + + notify_slack( + platform: "iOS", + build_type: configuration == "Staging" ? "Dev" : "Release", + success: true, + target: "TestFlight", + ) +end + +# --- Android lanes --- + +platform :android do + desc "Debug APK 빌드만 (배포 없음)" + lane :build_dev do + apk = android_build(flavor: "dev") + UI.success("Debug APK 빌드 완료: #{apk}") + end + + desc "Release APK 빌드만 (prod API, 배포 없음)" + lane :build_release do + apk = android_build(flavor: "release") + UI.success("Release APK 빌드 완료: #{apk}") + end + + desc "Debug APK 빌드 + Firebase App Distribution(dev) 업로드" + lane :dev do + apk = android_build(flavor: "dev") + android_upload(flavor: "dev", apk_path: apk) + end + + desc "Release APK 빌드(prod API) + Firebase App Distribution(release) 업로드" + lane :release do + apk = android_build(flavor: "release") + android_upload(flavor: "release", apk_path: apk) + end + + error do |lane, exception| + notify_slack( + platform: "Android", + build_type: lane.to_s.include?("release") ? "Release" : "Dev", + success: false, + error: exception.message, + ) + end +end + +# --- iOS lanes --- + +platform :ios do + before_all do + # gradle.properties → xcconfig/Info.plist 동기화 (iOS 빌드 전 필수) + sh("cd '#{PROJECT_ROOT}' && ./gradlew updateIosVersion") + end + + desc "Staging configuration 으로 IPA 빌드만 (배포 없음)" + lane :build_dev do + ipa = ios_build( + configuration: "Staging", + bundle_id: local_property("APP_BUNDLE_ID_DEV"), + profile_path: local_property("IOS_PROVISIONING_PROFILE_STAGING_PATH"), + profile_name: local_property("IOS_PROVISIONING_PROFILE_STAGING_NAME"), + ) + UI.success("Staging IPA 빌드 완료: #{ipa}") + end + + desc "Release configuration 으로 IPA 빌드만 (배포 없음)" + lane :build_release do + ipa = ios_build( + configuration: "Release", + bundle_id: local_property("APP_BUNDLE_ID"), + profile_path: local_property("IOS_PROVISIONING_PROFILE_RELEASE_PATH"), + profile_name: local_property("IOS_PROVISIONING_PROFILE_RELEASE_NAME"), + ) + UI.success("Release IPA 빌드 완료: #{ipa}") + end + + desc "Staging 빌드 + TestFlight 업로드 (dev)" + lane :dev do + bundle_id = local_property("APP_BUNDLE_ID_DEV") + ipa = ios_build( + configuration: "Staging", + bundle_id: bundle_id, + profile_path: local_property("IOS_PROVISIONING_PROFILE_STAGING_PATH"), + profile_name: local_property("IOS_PROVISIONING_PROFILE_STAGING_NAME"), + ) + ios_upload(configuration: "Staging", bundle_id: bundle_id, ipa_path: ipa) + end + + desc "Release 빌드 + TestFlight 업로드 (release)" + lane :release do + bundle_id = local_property("APP_BUNDLE_ID") + ipa = ios_build( + configuration: "Release", + bundle_id: bundle_id, + profile_path: local_property("IOS_PROVISIONING_PROFILE_RELEASE_PATH"), + profile_name: local_property("IOS_PROVISIONING_PROFILE_RELEASE_NAME"), + ) + ios_upload(configuration: "Release", bundle_id: bundle_id, ipa_path: ipa) + end + + error do |lane, exception| + notify_slack( + platform: "iOS", + build_type: lane.to_s.include?("release") ? "Release" : "Dev", + success: false, + error: exception.message, + ) + end +end \ No newline at end of file diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 00000000..6bad135d --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,2 @@ +gem "fastlane-plugin-firebase_app_distribution" +gem "cocoapods" \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index f3e0f481..35595697 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,5 +17,5 @@ kotlin.native.disableCompilerDaemon=false buildkonfig.flavor=dev -APP_VERSION_NAME=2.1.0 -APP_VERSION_CODE=20100 +APP_VERSION_NAME=2.1.1 +APP_VERSION_CODE=20101 diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 00000000..7ab66129 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 994be07b..61396ab5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -15,8 +15,8 @@ remote-notification CFBundleShortVersionString - 2.1.0 + 2.1.1 CFBundleVersion - 20100 + 20101 From eb33caa25d364e3c330bd431ba73333eca923ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 22 Apr 2026 19:09:00 +0900 Subject: [PATCH 29/54] =?UTF-8?q?[Feat]=20=ED=8E=98=EC=8A=A4=ED=83=80?= =?UTF-8?q?=ED=8C=85=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 페스타팅 View 구현 * refactor: 홈 화면 관련 UI 모델 패키지 구조 개선 * **refactor: UI 모델 클래스 패키지 이동** * `LineupItemUiModel`, `LineUpItemOfDayUiModel`, `LineUpItemGroupUiModel` 등 홈 화면에서 사용하는 UI 모델 클래스들을 `presentation.home` 패키지에서 하위 패키지인 `presentation.home.model`로 이동했습니다. * 패키지 변경에 따라 `HomeViewModel`, `HomeScreen`, `HomeLineupItem` 및 관련 테스트 코드의 임포트 경로를 수정했습니다. * **refactor: LineupUiState 의존성 수정** * `LineupUiState`에서 이동된 패키지의 `LineUpItemOfDayUiModel`을 참조하도록 수정했습니다. * feat: 홈 화면 UI 모델 도입 및 리팩터링 * **feat: Presentation 계층 전용 UI 모델 추가** * `FestivalPosterUiModel`, `FestivalUiModel`, `OrganizationUiModel`을 추가하여 도메인 모델과 UI 계층의 의존성을 분리했습니다. * 도메인 모델(`Poster`, `Festival`, `Organization`)을 UI 모델로 변환하기 위한 `toUiModel()` 확장 함수를 구현했습니다. * **refactor: `HomeViewModel` 및 UI 상태 관리 로직 수정** * `FestivalUiState`에서 사용하는 데이터 타입을 `Organization`에서 `OrganizationUiModel`로 변경했습니다. * `HomeViewModel`에서 페스티벌 정보를 가져올 때 `toUiModel()`을 사용하여 UI 상태를 업데이트하도록 수정했습니다. * `HomeViewModel` 내에 `navigateToFestatingClick` 메서드 placeholder를 추가했습니다. * **refactor: UI 컴포넌트 내 도메인 모델 의존성 제거** * `HomeScreen.kt` 및 `SettingScreen.kt`에서 기존 도메인 모델 임포트를 제거하고 새롭게 정의된 UI 모델을 사용하도록 수정했습니다. * Preview 코드 내 데이터 생성 로직을 `OrganizationUiModel` 등 UI 모델 기반으로 업데이트했습니다. * test: HomeViewModelTest 내 패키지 경로 및 테스트 케이스 수정 * **refactor: `HomeViewModelTest` 패키지 경로 변경** * `com.daedan.festabook.home`에서 `com.daedan.festabook.viewModel.home`으로 패키지 위치를 수정했습니다. * **test: `HomeViewModelTest` 내 UI 모델 변환 로직 반영** * 축제 정보를 불러오는 테스트 케이스에서 예상 결과값을 도메인 모델(`FAKE_ORGANIZATION`) 대신 UI 모델(`FAKE_ORGANIZATION.toUiModel()`)을 사용하도록 수정했습니다. * 필요한 픽스처(`FAKE_LINEUP`, `FAKE_ORGANIZATION`)에 대한 임포트 문을 추가했습니다. * refactor: 축제 기간 포맷팅 로직 개선 * **refactor: 축제 기간 포맷팅 로직 리팩터링** * `TextUtil.kt`에 정의되어 있던 `formatFestivalPeriod` 유틸리티 함수를 삭제하고, `HomeScreen.kt` 내부에 `@Composable` 함수로 재구현했습니다. * 기존의 하드코딩된 날짜 단위("년", "월", "일")를 `strings.xml` 리소스를 사용하도록 변경하여 다국어 대응이 가능하도록 수정했습니다. * **chore: 불필요한 코드 및 파일 제거** * 사용되지 않는 인터페이스인 `OnMenuItemReClickListener.kt` 파일을 삭제했습니다. * `formatFestivalPeriod` 이동에 따라 더 이상 필요 없는 `TextUtil.kt` 파일을 제거했습니다. * **style: 문자열 리소스 추가** * 날짜 포맷팅을 위한 `format_festival_period_year` 및 `format_festival_period_date` 문자열 리소스를 `strings.xml`에 추가했습니다. * feat: 페스타팅(Festating) 웹뷰 화면 추가 및 네비게이션 연결 * **feat: 페스타팅(Festating) 화면 구현 및 라우팅 추가** * `FestatingScreen`: `BuildKonfig.FESTA_TING_URL`을 로드하는 웹뷰 기반의 화면을 추가했습니다. * `FestabookRoute`: 새로운 경로인 `Festating`을 정의했습니다. * `FestatingNavigation`: 페스타팅 화면으로 이동하기 위한 `festatingNavGraph`를 구현했습니다. * `FestabookScreen`: 전체 네비게이션 그래프에 `festatingNavGraph`를 포함했습니다. * **refactor: 홈 화면 내 페스타팅 포스터 컴포넌트 개선 및 이동 로직 연결** * `FestatingPoster`를 `presentation.home.component`에서 `presentation.festating.component` 패키지로 이동하고, 클릭 이벤트를 상위에서 전달받을 수 있도록 수정했습니다. * `HomeScreen` 및 `homeNavGraph`에 페스타팅 화면 이동을 위한 `onNavigateToFestating` 콜백을 추가하고, 포스터 클릭 시 해당 로직이 실행되도록 연결했습니다. * `HomeViewModel`에서 더 이상 사용되지 않는 `navigateToFestatingClick` 메서드를 제거했습니다. * **chore: 웹뷰 라이브러리 추가 및 빌드 환경 설정** * `build.gradle.kts`: `compose-webview-multiplatform` 의존성을 추가했습니다. * `BuildKonfig` 설정에 `FESTA_TING_URL` 필드를 추가하고, `local.properties`로부터 환경별(dev/release) URL을 로드하도록 구성했습니다. * `MainScreen`의 `homeNavGraph` 호출 시 `viewModel` 파라미터 명칭을 `homeViewModel`로 명확하게 변경했습니다. * feat: Festating 화면에 축제 ID 전달 로직 구현 및 ViewModel 주입 방식 개선 * **feat: Festating 경로에 festivalId 파라미터 추가** * `FestabookRoute.Festating`을 `data object`에서 `data class`로 변경하여 `festivalId: Long`을 전달받도록 수정했습니다. * `HomeNavigation` 및 `HomeScreen` 내 네비게이션 콜백 함수가 `festivalId`를 인자로 받도록 업데이트했습니다. * `FestatingPoster` 클릭 시 현재 축제 조직의 ID를 전달하도록 변경했습니다. * **feat: FestatingScreen 웹뷰 연동 강화** * `FestatingScreen` 호출 시 `festivalId`를 전달받아 웹뷰 로드 시 HTTP 헤더(`festivalId`)에 포함하도록 구현했습니다. * `festatingNavGraph`에서 `toRoute()`를 통해 네비게이션 인자를 추출하여 화면에 전달하도록 수정했습니다. * **refactor: Explore 및 Home 관련 로직 개선** * `ExploreNavigation`에서 `LocalMetroViewModelFactory`를 직접 사용하는 대신 `metroViewModel()` 확장 함수를 사용하도록 간소화했습니다. * `HomeViewModel` 내 축제 정보 로드 시 변수 명칭을 `festival`에서 `organization`으로 변경하여 가독성을 높였습니다. * **style: 코드 정리** * `MainScreen` 내 불필요한 `Unit` 반환 로직을 제거했습니다. * refactor: 상수화 * chore: `compose-webview-multiplatform` 의존성 관리 방식 개선 * **chore: `libs.versions.toml` 내 라이브러리 추가 및 정리** * `composeWebviewMultiplatform` 버전을 정의하고 `compose-webview-multiplatform` 라이브러리를 추가했습니다. * `firebaseCrashlytcis` 플러그인 선언부의 불필요한 공백을 정리했습니다. * **refactor: `build.gradle.kts` 내 의존성 참조 방식 변경** * `composeApp/build.gradle.kts`에서 하드코딩된 문자열로 관리하던 `compose-webview-multiplatform` 의존성을 `libs.versions.toml` 내의 Version Catalog 참조 방식으로 수정했습니다. * refactor: FestabookTopAppBar 내비게이션 아이콘 추가 및 구조 개선 * **refactor: `FestabookTopAppBar` 컴포넌트 구조 변경** * 기존 `Text` 단일 컴포넌트에서 `Row` 레이아웃으로 변경하여 내비게이션 아이콘과 제목을 가로로 배치할 수 있도록 수정했습니다. * `navigationIcon` 슬롯 파라미터를 추가하여 뒤로 가기 버튼 등 커스텀 아이콘을 삽입할 수 있도록 개선했습니다. * 기본 배경색을 `FestabookColor.white`로 설정하고 아이템 수직 정렬 로직을 추가했습니다. * **style: 프리뷰 코드 업데이트** * `FestabookTopAppBarPreview`에 `ic_arrow_back` 아이콘을 포함한 샘플 코드를 추가하여 변경된 레이아웃을 확인하도록 수정했습니다. * [Feat] FestatingScreen 내 웹뷰 뒤로가기 기능 구현 * **feat: 웹뷰 내비게이션 백 핸들링 추가** * `NavigationBackHandler`를 사용하여 웹뷰 내에서 뒤로 이동이 가능한 경우(`navigator.canGoBack`), 시스템 뒤로가기 동작 시 웹뷰의 이전 페이지로 이동하도록 로직을 구현했습니다. * 뒤로가기 이벤트 상태 관리를 위해 `rememberNavigationEventState`를 추가했습니다. * **refactor: 웹뷰 상태 변수명 변경** * 기존 `state`로 정의되었던 웹뷰 상태 변수를 `webViewState`로 변경하여 용도를 명확히 했습니다. * refactor: WebView 상태 생성 로직 분리 및 DOM Storage 활성화 * **refactor: WebView 설정 로직 캡슐화 및 Android 환경 최적화** * `rememberConfiguredWebViewState` 컴포저블을 추가하여 `WebViewState` 생성 및 초기 설정 로직을 분리했습니다. * `LaunchedEffect`를 통해 Android WebView의 `domStorageEnabled` 설정을 `true`로 활성화하여 웹 콘텐츠 렌더링 호환성을 개선했습니다. * `FestatingScreen` 내 `WebView` 컴포넌트의 `Modifier` 코드 스타일을 가독성 있게 수정했습니다. * feat: 직렬화 어노테이션 추가 * refactor: `rememberConfiguredWebViewState` 내 WebView 설정 로직 간소화 * **refactor: WebViewState 설정 방식 변경** * `rememberConfiguredWebViewState` 함수 내에서 `LaunchedEffect`를 제거하고, `apply` 확장 함수를 사용하여 `WebViewState` 생성 시점에 `domStorageEnabled` 설정을 직접 수행하도록 수정했습니다. * refactor: WebView 내 DOM storage 설정 로직 개선 * **refactor: `rememberConfiguredWebViewState` 내 side-effect 처리 방식 수정** * `WebViewState` 초기화 시 `apply`를 통해 직접 설정하던 방식을 `remember(state)` 블록으로 감싸도록 변경하여 불필요한 재설정을 방지하고 상태 관리를 최적화했습니다. * refactor: FestabookTopAppBar 배경색 적용 로직 수정 * **refactor: `FestabookTopAppBar` 내부 배경색 처리 방식 변경** * `modifier` 파라미터의 기본값에서 `background(FestabookColor.white)`를 제거하고, 내부 `Row` 컴포넌트의 `modifier`에 직접 적용하여 UI 일관성을 보장하도록 수정했습니다. * `FestabookTopAppBarPreview`의 들여쓰기 및 코드 포맷팅을 정리했습니다. * style: 코드 포맷팅 * refactor: `FestabookNavigator` 내 네비게이션 상태 관찰 방식 개선 * **refactor: `collectAsStateWithLifecycle` 적용** * `FestabookNavigator`에서 `navController.visibleEntries`를 구독할 때, 생명주기를 고려한 안전한 데이터 수집을 위해 `collectAsState()` 대신 `collectAsStateWithLifecycle()`을 사용하도록 수정했습니다. --- composeApp/build.gradle.kts | 32 ++--- .../drawable/festa-ting_poster.png | Bin 0 -> 50663 bytes .../composeResources/values/strings.xml | 3 + .../festabook/presentation/FestabookScreen.kt | 2 + .../common/OnMenuItemReClickListener.kt | 5 - .../festabook/presentation/common/TextUtil.kt | 16 --- .../common/component/FestabookTopAppBar.kt | 30 ++++- .../explore/navigation/ExploreNavigation.kt | 6 +- .../festating/component/FestatingPoster.kt | 32 +++++ .../festating/component/FestatingScreen.kt | 65 +++++++++ .../navigation/FestatingNavigation.kt | 14 ++ .../presentation/home/FestivalUiState.kt | 4 +- .../presentation/home/HomeViewModel.kt | 5 +- .../presentation/home/LineupUiState.kt | 2 + .../home/component/HomeLineupItem.kt | 4 +- .../presentation/home/component/HomeScreen.kt | 126 +++++++++++++----- .../home/model/FestivalPosterUiModel.kt | 16 +++ .../home/model/FestivalUiModel.kt | 19 +++ .../{ => model}/LineUpItemOfDayUiModel.kt | 2 +- .../home/{ => model}/LineupItemUiModel.kt | 2 +- .../home/model/OrganizationUiModel.kt | 16 +++ .../home/navigation/HomeNavigation.kt | 6 +- .../presentation/main/FestabookNavigator.kt | 4 +- .../presentation/main/FestabookRoute.kt | 5 + .../presentation/main/component/MainScreen.kt | 9 +- .../setting/component/SettingScreen.kt | 8 +- .../viewModel/home/HomeViewModelTest.kt | 9 +- gradle/libs.versions.toml | 4 +- 28 files changed, 338 insertions(+), 108 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/festa-ting_poster.png delete mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/OnMenuItemReClickListener.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/TextUtil.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingPoster.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalPosterUiModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/{ => model}/LineUpItemOfDayUiModel.kt (95%) rename composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/{ => model}/LineupItemUiModel.kt (88%) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/OrganizationUiModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c10d36d6..1c76a93e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -45,6 +45,12 @@ private val appBundleIdDev = private val buildFlavor = project.properties["buildkonfig.flavor"]?.toString() ?: "dev" +private val festatingUrlDev = + getLocalProperty("FESTA_TING_URL_DEV") ?: error("FESTA_TING_URL_DEV가 local.properties에 없음") + +private val festatingUrl = + getLocalProperty("FESTA_TING_URL") ?: error("FESTA_TING_URL가 local.properties에 없음") + plugins { alias(libs.plugins.kotlinMultiplatform) @@ -138,6 +144,7 @@ kotlin { implementation(libs.androidx.datastore.preferences) implementation(libs.compottie) implementation(libs.metrox.viewmodel.compose) + implementation(libs.compose.webview.multiplatform) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -157,38 +164,26 @@ buildkonfig { buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) buildConfigField(STRING, "APP_BUNDLE_ID", appBundleIdDev) + buildConfigField(STRING, "FESTA_TING_URL", festatingUrlDev) } defaultConfigs("release") { - buildConfigField(STRING, "BUILD_FLAVOR", buildFlavor) - buildConfigField(STRING, "NAVER_MAP_STYLE_ID", naverMapStyleId) - buildConfigField(STRING, "NAVER_MAP_CLIENT_ID", naverMapClientId) buildConfigField(STRING, "FESTABOOK_URL", baseUrl) buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) buildConfigField(STRING, "APP_BUNDLE_ID", appBundleId) + buildConfigField(STRING, "FESTA_TING_URL", festatingUrl) } targetConfigs { - // android용 입니다. - create("debug") { - buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) - buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) - } - create("release") { - buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) - buildConfigField(STRING, "FESTABOOK_URL", baseUrl) - } - // ios용 입니다. - create("Debug") { - buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) - buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) - } create("Release") { buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrl) buildConfigField(STRING, "FESTABOOK_URL", baseUrl) + buildConfigField(STRING, "FESTA_TING_URL", festatingUrl) } + create("Staging") { buildConfigField(STRING, "FESTABOOK_IMAGE_URL", baseImageUrlDev) buildConfigField(STRING, "FESTABOOK_URL", baseUrlDev) buildConfigField(STRING, "APP_BUNDLE_ID", appBundleIdDev) + buildConfigField(STRING, "FESTA_TING_URL", festatingUrlDev) } } @@ -284,7 +279,8 @@ ktorfit { val updateIosVersion by tasks.registering { val plistFile = rootProject.layout.projectDirectory.file("iosApp/iosApp/Info.plist") - val xcconfigFile = rootProject.layout.projectDirectory.file("iosApp/Configuration/Config.xcconfig") + val xcconfigFile = + rootProject.layout.projectDirectory.file("iosApp/Configuration/Config.xcconfig") val versionName = providers.gradleProperty("APP_VERSION_NAME") val versionCode = providers.gradleProperty("APP_VERSION_CODE") diff --git a/composeApp/src/commonMain/composeResources/drawable/festa-ting_poster.png b/composeApp/src/commonMain/composeResources/drawable/festa-ting_poster.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bde4e47b149782755e367112d81ad707c3655d GIT binary patch literal 50663 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 719b7d73..85b9bcbf 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -26,6 +26,9 @@ 일정 확인하기 축제 라인업 탐색 화면으로 이동하는 버튼 + 페스타팅 이미지 + %1$s년 + %1$s월 %2$s일 diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt index 9662c448..d44d302d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import com.daedan.festabook.di.FestabookAppGraph import com.daedan.festabook.presentation.explore.navigation.exploreNavGraph +import com.daedan.festabook.presentation.festating.navigation.festatingNavGraph import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.navigation.mainNavGraph import com.daedan.festabook.presentation.main.rememberFestabookNavigator @@ -63,5 +64,6 @@ fun FestabookScreen( locationSource = locationSource, festabookNavigator = festabookNavigator, ) + festatingNavGraph() } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/OnMenuItemReClickListener.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/OnMenuItemReClickListener.kt deleted file mode 100644 index faa6eb22..00000000 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/OnMenuItemReClickListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.daedan.festabook.presentation.common - -fun interface OnMenuItemReClickListener { - fun onMenuItemReClick() -} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/TextUtil.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/TextUtil.kt deleted file mode 100644 index 0fafb272..00000000 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/TextUtil.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.daedan.festabook.presentation.common - -import kotlinx.datetime.LocalDate -import kotlinx.datetime.number - -fun formatFestivalPeriod( - start: LocalDate, - end: LocalDate, -): String = - if (start.year == end.year) { - "${start.year}년 ${start.month.number}월 ${start.day}일 ~ " + - "${end.month.number}월 ${end.day}일" - } else { - "${start.year}년 ${start.month.number}월 ${start.day}일 ~ " + - "${end.year}년 ${end.month.number}월 ${end.day}일" - } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt index fbaf30e8..4800a565 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt @@ -1,38 +1,58 @@ package com.daedan.festabook.presentation.common.component +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun FestabookTopAppBar( title: String, modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, style: TextStyle = FestabookTypography.displayLarge, ) { - Text( - text = title, - style = style, + Row( modifier = modifier .fillMaxWidth() + .background(FestabookColor.white) .padding( top = festabookSpacing.paddingTitleHorizontal, bottom = festabookSpacing.paddingBody4, start = festabookSpacing.paddingScreenGutter, end = festabookSpacing.paddingScreenGutter, ), - ) + verticalAlignment = Alignment.CenterVertically, + ) { + navigationIcon() + Text(text = title, style = style) + } } @Composable @Preview(showBackground = true) private fun FestabookTopAppBarPreview() { - FestabookTopAppBar(title = "FestaBook") + FestabookTopAppBar(title = "FestaBook", navigationIcon = { + IconButton(onClick = { }) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = null, + ) + } + }) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt index 1cfca105..3a6cc094 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt @@ -1,20 +1,18 @@ package com.daedan.festabook.presentation.explore.navigation -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.daedan.festabook.presentation.explore.component.ExploreScreen import com.daedan.festabook.presentation.main.FestabookRoute -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.metroViewModel fun NavGraphBuilder.exploreNavGraph( onBackClick: () -> Unit, onNavigateToMain: () -> Unit, ) { composable { - val viewModelFactory = LocalMetroViewModelFactory.current ExploreScreen( - viewModel = viewModel(factory = viewModelFactory), + viewModel = metroViewModel(), onBackClick = onBackClick, onNavigateToMain = { onNavigateToMain() }, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingPoster.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingPoster.kt new file mode 100644 index 00000000..291b30b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingPoster.kt @@ -0,0 +1,32 @@ +package com.daedan.festabook.presentation.festating.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.festa_ting_poster +import festabookkmp.composeapp.generated.resources.home_festating_poster_image +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun FestatingPoster( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Image( + painter = painterResource(Res.drawable.festa_ting_poster), + contentDescription = stringResource(Res.string.home_festating_poster_image), + contentScale = ContentScale.Crop, + modifier = modifier.clickable { onClick() }, + ) +} + +@Composable +@Preview +private fun FestatingItemPreview() { + FestatingPoster() +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt new file mode 100644 index 00000000..072b7adc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt @@ -0,0 +1,65 @@ +package com.daedan.festabook.presentation.festating.component + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.BuildKonfig +import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.WebViewState +import com.multiplatform.webview.web.rememberWebViewNavigator +import com.multiplatform.webview.web.rememberWebViewState + +private const val HEADER_FESTIVAL_ID = "festivalId" + +@Composable +fun FestatingScreen( + festivalId: Long, + modifier: Modifier = Modifier, +) { + val webViewState = + rememberConfiguredWebViewState( + url = BuildKonfig.FESTA_TING_URL, + headers = mapOf(HEADER_FESTIVAL_ID to festivalId.toString()), + ) + val backState = rememberNavigationEventState(NavigationEventInfo.None) + val navigator = rememberWebViewNavigator() + + NavigationBackHandler(state = backState, isBackEnabled = navigator.canGoBack) { + navigator.navigateBack() + } + + Scaffold(modifier = modifier) { innerPadding -> + WebView( + state = webViewState, + navigator = navigator, + modifier = + Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } +} + +@Composable +private fun rememberConfiguredWebViewState( + url: String, + headers: Map = emptyMap(), +): WebViewState { + val state = + rememberWebViewState( + url = url, + additionalHttpHeaders = headers, + ) + + remember(state) { + state.webSettings.androidWebSettings.domStorageEnabled = true + } + + return state +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt new file mode 100644 index 00000000..2df14f2a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt @@ -0,0 +1,14 @@ +package com.daedan.festabook.presentation.festating.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.daedan.festabook.presentation.festating.component.FestatingScreen +import com.daedan.festabook.presentation.main.FestabookRoute + +fun NavGraphBuilder.festatingNavGraph() { + composable { backStackEntry -> + val festivalId = backStackEntry.toRoute().festivalId + FestatingScreen(festivalId = festivalId) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt index 500ac145..fd518630 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/FestivalUiState.kt @@ -1,12 +1,12 @@ package com.daedan.festabook.presentation.home -import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.presentation.home.model.OrganizationUiModel sealed interface FestivalUiState { data object Loading : FestivalUiState data class Success( - val organization: Organization, + val organization: OrganizationUiModel, ) : FestivalUiState data class Error( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt index 29efb300..6bfb7757 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.presentation.home.model.toUiModel import com.daedan.festabook.domain.repository.MyWaitingRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap @@ -52,8 +53,8 @@ class HomeViewModel( val result = festivalRepository.getFestivalInfo() result - .onSuccess { festival -> - _festivalUiState.value = FestivalUiState.Success(festival) + .onSuccess { organization -> + _festivalUiState.value = FestivalUiState.Success(organization.toUiModel()) }.onFailure { _festivalUiState.value = FestivalUiState.Error(it) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt index dcfc03e8..3e5db48d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupUiState.kt @@ -1,5 +1,7 @@ package com.daedan.festabook.presentation.home +import com.daedan.festabook.presentation.home.model.LineUpItemOfDayUiModel + sealed interface LineupUiState { data object Loading : LineupUiState diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt index b3a4ce23..ebdde8e4 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -22,8 +22,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel -import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.home.model.LineUpItemOfDayUiModel +import com.daedan.festabook.presentation.home.model.LineupItemUiModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography import festabookkmp.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 6b6c634b..6fee9f7e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -19,27 +19,31 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.daedan.festabook.domain.model.Festival -import com.daedan.festabook.domain.model.Organization -import com.daedan.festabook.domain.model.Poster import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.formatFestivalPeriod +import com.daedan.festabook.presentation.festating.component.FestatingPoster import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel -import com.daedan.festabook.presentation.home.LineupItemUiModel import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.model.FestivalPosterUiModel +import com.daedan.festabook.presentation.home.model.FestivalUiModel +import com.daedan.festabook.presentation.home.model.LineUpItemGroupUiModel +import com.daedan.festabook.presentation.home.model.LineupItemUiModel +import com.daedan.festabook.presentation.home.model.OrganizationUiModel import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.error_fail_to_load_info +import festabookkmp.composeapp.generated.resources.format_festival_period_date +import festabookkmp.composeapp.generated.resources.format_festival_period_year import festabookkmp.composeapp.generated.resources.setting_notice_enabled import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.number import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.todayIn @@ -49,18 +53,19 @@ import kotlin.time.Clock @Composable fun HomeScreen( - viewModel: HomeViewModel, + homeViewModel: HomeViewModel, settingViewModel: SettingViewModel, notificationPermissionManager: NotificationPermissionManager, onNavigateToExplore: () -> Unit, + onNavigateToFestating: (festivalId: Long) -> Unit, onNavigateToMyWaiting: () -> Unit, onShowSnackBar: (String) -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, modifier: Modifier = Modifier, ) { - val festivalUiState by viewModel.festivalUiState.collectAsStateWithLifecycle() - val lineupUiState by viewModel.lineupUiState.collectAsStateWithLifecycle() - val waitingBarUiState by viewModel.waitingBarUiState.collectAsStateWithLifecycle() + val festivalUiState by homeViewModel.festivalUiState.collectAsStateWithLifecycle() + val lineupUiState by homeViewModel.lineupUiState.collectAsStateWithLifecycle() + val waitingBarUiState by homeViewModel.waitingBarUiState.collectAsStateWithLifecycle() val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) val settingEnabledText = stringResource(Res.string.setting_notice_enabled) @@ -76,12 +81,12 @@ fun HomeScreen( currentOnShowErrorSnackbar(it) } - ObserveAsEvents(flow = viewModel.navigateToMyWaitingEvent) { + ObserveAsEvents(flow = homeViewModel.navigateToMyWaitingEvent) { onNavigateToMyWaiting() } LaunchedEffect(Unit) { - viewModel.loadWaitingBar() + homeViewModel.loadWaitingBar() } LaunchedEffect(festivalUiState) { @@ -90,9 +95,7 @@ fun HomeScreen( currentOnShowErrorSnackbar(state.throwable) } - else -> { - Unit - } + else -> {} } } @@ -112,11 +115,12 @@ fun HomeScreen( is FestivalUiState.Success -> { Box(modifier = modifier.fillMaxSize()) { - FestivalOverview( + HomeContent( festivalUiState = state, lineupUiState = lineupUiState, onNavigateToExplore = onNavigateToExplore, - onNavigateToSchedule = viewModel::navigateToScheduleClick, + onNavigateToSchedule = homeViewModel::navigateToScheduleClick, + onFestatingClick = { festivalId -> onNavigateToFestating(festivalId) }, ) when (val waitingBarUiState = waitingBarUiState) { is WaitingBarUiState.Visible -> { @@ -124,7 +128,7 @@ fun HomeScreen( order = waitingBarUiState.order, estimatedMinutes = waitingBarUiState.estimatedWaitTime, status = waitingBarUiState.status, - onClick = viewModel::navigateToMyWaitingClick, + onClick = homeViewModel::navigateToMyWaitingClick, modifier = Modifier .align(Alignment.BottomCenter) @@ -137,9 +141,7 @@ fun HomeScreen( ) } - else -> { - Unit - } + else -> {} } } } @@ -147,11 +149,12 @@ fun HomeScreen( } @Composable -private fun FestivalOverview( +private fun HomeContent( festivalUiState: FestivalUiState.Success, lineupUiState: LineupUiState, onNavigateToExplore: () -> Unit, onNavigateToSchedule: () -> Unit, + onFestatingClick: (festivalId: Long) -> Unit, modifier: Modifier = Modifier, ) { val universityName = festivalUiState.organization.organizationName @@ -159,18 +162,24 @@ private fun FestivalOverview( Scaffold( modifier = modifier.fillMaxSize(), containerColor = FestabookColor.white, + topBar = { + HomeHeader( + universityName = universityName, + onExpandClick = onNavigateToExplore, + modifier = + Modifier.padding( + top = festabookSpacing.paddingTitleHorizontal, + bottom = festabookSpacing.paddingBody3, + ), + ) + }, ) { innerPadding -> Column( modifier = Modifier + .padding(innerPadding) .fillMaxSize(), ) { - HomeHeader( - universityName = universityName, - onExpandClick = onNavigateToExplore, - modifier = Modifier.padding(top = 40.dp, bottom = 12.dp), - ) - LazyColumn( modifier = Modifier @@ -185,7 +194,7 @@ private fun FestivalOverview( HomePosterList( posterUrls = posterUrls, - modifier = Modifier.padding(vertical = 12.dp), + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody3), ) } @@ -199,7 +208,7 @@ private fun FestivalOverview( festival.startDate, festival.endDate, ), - modifier = Modifier.padding(top = 16.dp), + modifier = Modifier.padding(top = festabookSpacing.paddingBody4), ) } @@ -210,7 +219,29 @@ private fun FestivalOverview( color = FestabookColor.gray200, modifier = Modifier - .padding(top = 16.dp), + .padding(top = festabookSpacing.paddingBody4), + ) + } + // 페스타팅 포스터 + // TODO if문 묶기 + item { + FestatingPoster( + onClick = { onFestatingClick(festivalUiState.organization.id) }, + modifier = + Modifier + .fillMaxSize() + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingBody5, + ), + ) + } + + // 구분선 + item { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, ) } @@ -250,15 +281,37 @@ private fun FestivalOverview( } } +@Composable +private fun formatFestivalPeriod( + start: LocalDate, + end: LocalDate, +): String { + val startYear = stringResource(Res.string.format_festival_period_year, start.year) + val endYear = + if (start.year == end.year) { + "" + } else { + stringResource( + Res.string.format_festival_period_year, + end.year, + ) + } + + val startDate = + stringResource(Res.string.format_festival_period_date, start.month.number, start.day) + val endDate = stringResource(Res.string.format_festival_period_date, end.month.number, end.day) + return "$startYear$startDate ~ $endYear$endDate" +} + @Preview(showBackground = true) @Composable private fun FestivalOverviewPreview() { val sampleFestival = - Organization( + OrganizationUiModel( id = 1, organizationName = "가천대학교", festival = - Festival( + FestivalUiModel( festivalName = "2025 가천 Water Festival\n: AQUA WAVE", startDate = Clock.System.todayIn(TimeZone.currentSystemDefault()), endDate = @@ -267,8 +320,8 @@ private fun FestivalOverviewPreview() { .plus(DatePeriod(days = 1)), festivalImages = listOf( - Poster(1, "sample", 1), - Poster(2, "sample", 2), + FestivalPosterUiModel(1, "sample", 1), + FestivalPosterUiModel(2, "sample", 2), ), ), ) @@ -312,10 +365,11 @@ private fun FestivalOverviewPreview() { ), ) - FestivalOverview( + HomeContent( festivalUiState = FestivalUiState.Success(sampleFestival), lineupUiState = LineupUiState.Success(sampleLineups.getLineupItems()), onNavigateToExplore = {}, onNavigateToSchedule = {}, + onFestatingClick = {}, ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalPosterUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalPosterUiModel.kt new file mode 100644 index 00000000..741b3869 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalPosterUiModel.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.presentation.home.model + +import com.daedan.festabook.domain.model.Poster + +data class FestivalPosterUiModel( + val id: Long, + val imageUrl: String, + val sequence: Int, +) + +fun Poster.toUiModel(): FestivalPosterUiModel = + FestivalPosterUiModel( + id = id, + imageUrl = imageUrl, + sequence = sequence, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt new file mode 100644 index 00000000..9f5f9521 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.home.model + +import com.daedan.festabook.domain.model.Festival +import kotlinx.datetime.LocalDate + +data class FestivalUiModel( + val festivalName: String, + val festivalImages: List, + val startDate: LocalDate, + val endDate: LocalDate, +) + +fun Festival.toUiModel(): FestivalUiModel = + FestivalUiModel( + festivalName = festivalName, + festivalImages = festivalImages.map { it.toUiModel() }, + startDate = startDate, + endDate = endDate, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineUpItemOfDayUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/LineUpItemOfDayUiModel.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineUpItemOfDayUiModel.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/LineUpItemOfDayUiModel.kt index 7c45da4e..8d58c633 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineUpItemOfDayUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/LineUpItemOfDayUiModel.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.home +package com.daedan.festabook.presentation.home.model import com.daedan.festabook.domain.model.LineupItem import kotlinx.datetime.LocalDate diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupItemUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/LineupItemUiModel.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupItemUiModel.kt rename to composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/LineupItemUiModel.kt index a0cfe9d9..f7bdc51e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/LineupItemUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/LineupItemUiModel.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.home +package com.daedan.festabook.presentation.home.model import com.daedan.festabook.domain.model.LineupItem import kotlinx.datetime.LocalDateTime diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/OrganizationUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/OrganizationUiModel.kt new file mode 100644 index 00000000..236b5afc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/OrganizationUiModel.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.presentation.home.model + +import com.daedan.festabook.domain.model.Organization + +data class OrganizationUiModel( + val id: Long, + val organizationName: String, + val festival: FestivalUiModel, +) + +fun Organization.toUiModel(): OrganizationUiModel = + OrganizationUiModel( + id = id, + organizationName = organizationName, + festival = festival.toUiModel(), + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt index 52a58bb5..4105963d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -19,7 +19,7 @@ import com.daedan.festabook.presentation.setting.SettingViewModel fun NavGraphBuilder.homeNavGraph( innerPadding: PaddingValues, - viewModel: HomeViewModel, + homeViewModel: HomeViewModel, mainViewModel: MainViewModel, settingViewModel: SettingViewModel, notificationPermissionManager: NotificationPermissionManager, @@ -27,6 +27,7 @@ fun NavGraphBuilder.homeNavGraph( onShowErrorSnackbar: (Throwable) -> Unit, onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, + onNavigateToFestating: (festivalId: Long) -> Unit, onNavigateToMyWaiting: () -> Unit, ) { composable( @@ -42,9 +43,10 @@ fun NavGraphBuilder.homeNavGraph( } HomeScreen( modifier = Modifier.padding(innerPadding), - viewModel = viewModel, + homeViewModel = homeViewModel, onShowErrorSnackbar = onShowErrorSnackbar, onNavigateToExplore = onNavigateToExplore, + onNavigateToFestating = onNavigateToFestating, onNavigateToMyWaiting = onNavigateToMyWaiting, settingViewModel = settingViewModel, notificationPermissionManager = notificationPermissionManager, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt index 9dc83e96..2898d822 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.presentation.main import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController @@ -19,7 +19,7 @@ class FestabookNavigator( get() = navController .visibleEntries - .collectAsState() + .collectAsStateWithLifecycle() .value .lastOrNull { it.destination.route != null } ?.destination diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index f2f4fe2a..b8412147 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -27,6 +27,11 @@ sealed interface FestabookRoute { val pendingMyWaiting: Boolean = false, ) : FestabookRoute + @Serializable + data class Festating( + val festivalId: Long, + ) : FestabookRoute + @Serializable data class AddWaitingInfo( val placeId: Long? = null, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index cd91676d..850f2ce4 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -197,9 +197,7 @@ fun MainScreen( placeMapViewModel.onMenuItemReClicked() } - else -> { - Unit - } + else -> {} } }, ) @@ -286,9 +284,12 @@ private fun FestabookNavHost( ) { homeNavGraph( innerPadding = innerPadding, - viewModel = homeViewModel, + homeViewModel = homeViewModel, mainViewModel = mainViewModel, onNavigateToExplore = { festabookNavigator.navigate(FestabookRoute.Explore) }, + onNavigateToFestating = { festivalId -> + festabookNavigator.navigate(FestabookRoute.Festating(festivalId)) + }, onSubscriptionConfirm = { settingViewModel.notificationAllowClick() mainViewModel.declineAlert() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index c571ca87..3a8faec4 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -31,8 +31,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.BuildKonfig -import com.daedan.festabook.domain.model.Festival -import com.daedan.festabook.domain.model.Organization import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.PermissionState import com.daedan.festabook.presentation.common.ObserveAsEvents @@ -40,6 +38,8 @@ import com.daedan.festabook.presentation.common.component.FestabookSwitch import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.model.FestivalUiModel +import com.daedan.festabook.presentation.home.model.OrganizationUiModel import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel import com.daedan.festabook.presentation.setting.waitinginfo.model.WaitingInfoUiState @@ -460,11 +460,11 @@ private fun SettingScreenPreview() { SettingScreen( festivalUiState = FestivalUiState.Success( - Organization( + OrganizationUiModel( id = 1, organizationName = "성균관대학교 인문사회과학철학문학자연캠퍼스 인문사회과학철학문학자연캠퍼스", festival = - Festival( + FestivalUiModel( festivalName = "성균관대학교 축제축제축제축제축제축제축제축제축제축제축제축제", festivalImages = listOf(), startDate = LocalDate(2026, 1, 1), diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt index cff20e2d..83606490 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt @@ -1,13 +1,16 @@ -package com.daedan.festabook.home +package com.daedan.festabook.viewModel.home import com.daedan.festabook.domain.model.MyWaiting import com.daedan.festabook.domain.model.WaitingStatus import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.home.FAKE_LINEUP +import com.daedan.festabook.home.FAKE_ORGANIZATION import com.daedan.festabook.domain.repository.MyWaitingRepository import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.model.LineUpItemOfDayUiModel +import com.daedan.festabook.presentation.home.model.toUiModel import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.home.toUiModel import dev.mokkery.answering.returns @@ -77,7 +80,7 @@ class HomeViewModelTest { fun `축제 정보를 불러올 수 있다`() = runTest { // given - val expect = FestivalUiState.Success(FAKE_ORGANIZATION) + val expect = FestivalUiState.Success(FAKE_ORGANIZATION.toUiModel()) // when homeViewModel.loadFestival() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8b4099c..7bd6c043 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ androidx-espresso = "3.7.0" androidx-lifecycle = "2.9.6" androidx-testExt = "1.3.0" composeMultiplatform = "1.9.3" +composeWebviewMultiplatform = "2.0.3" datastorePreferences = "1.2.0" compottie = "2.1.0" navigationCompose = "2.9.2" @@ -44,6 +45,7 @@ firebaseMessagingKtx = "25.0.1" [libraries] androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +compose-webview-multiplatform = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "composeWebviewMultiplatform" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } @@ -97,5 +99,5 @@ metro = { id = "dev.zacsweers.metro", version.ref = "metro" } buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } -firebaseCrashlytcis = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics"} +firebaseCrashlytcis = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics" } google-gms-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } \ No newline at end of file From 4c750085b653088c0b87d9c6c790114fc538d77a Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:06:40 +0900 Subject: [PATCH 30/54] =?UTF-8?q?[Fix]:=20=EC=9B=A8=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=ED=9C=B4=EB=8C=80=ED=8F=B0=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EB=AF=B8=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 웨이팅 등록 시 휴대폰 번호 미등록 시 화면 이동 버그 수정 * **refactor: `WaitingRegisterViewModel` 내 휴대폰 등록 네비게이션 로직 수정** * 기존의 `navigateToPhoneRegistrationEvent` (SharedFlow)를 제거하고 `WaitingRegisterUiState.NeedsPhoneRegistration` 상태를 도입했습니다. * `loadPlaceSummary` 시 사용자 정보가 없으면 `NeedsPhoneRegistration` 상태로 전이하도록 변경했습니다. * `waitingInfoRepository.getWaitingInfo()` 호출 실패 시 에러 상태를 설정하고 이후 로직을 중단하도록 예외 처리를 보완했습니다. * **feat: `WaitingRegisterUiState` 내 신규 상태 추가** * 휴대폰 번호 등록이 필요한 상태를 명시하기 위해 `NeedsPhoneRegistration` 오브젝트를 추가했습니다. * **refactor: `WaitingRegisterScreen` 내 UI 상태 대응 및 로직 수정** * `LaunchedEffect`를 사용하여 `uiState`가 `NeedsPhoneRegistration`일 때 휴대폰 등록 화면으로 이동하도록 수정했습니다. * 로딩 화면 표시 조건에 `NeedsPhoneRegistration` 상태를 포함했습니다. * **style: `PlaceMapNavigation` 내 불필요한 애니메이션 코드 제거** * `FestabookRoute.PlaceDetail` 경로의 중복되거나 불필요한 `enterTransition` 및 `exitTransition` 설정을 삭제했습니다. * **test: `WaitingRegisterViewModelTest` 구조 개선 및 테스트 케이스 추가** * `createViewModel` 헬퍼 함수를 도입하여 뷰모델 생성 로직을 캡슐화했습니다. * "정보 조회 실패 시 에러 상태 확인 및 추가 API 호출 차단"에 대한 테스트 케이스를 추가했습니다. * 이벤트 발행 방식에서 UI 상태 기반 확인 방식으로 테스트 로직을 업데이트했습니다. --- .../placeMap/navigation/PlaceMapNavigation.kt | 6 -- .../WaitingRegisterViewModel.kt | 16 ++--- .../component/WaitingRegisterScreen.kt | 15 ++-- .../model/WaitingRegisterUiState.kt | 2 + .../WaitingRegisterViewModelTest.kt | 70 ++++++++++++------- 5 files changed, 64 insertions(+), 45 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index d12c99d7..0b556ac0 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -56,12 +56,6 @@ fun NavGraphBuilder.placeMapNavGraph( typeOf() to defaultNavType(), typeOf() to defaultNavType(), ), - enterTransition = { - slideInVertically(initialOffsetY = { it / INITIAL_OFFSET_CONSTANT }) + fadeIn() - }, - exitTransition = { - slideOutVertically(targetOffsetY = { it / INITIAL_OFFSET_CONSTANT }) + fadeOut() - }, ) { backStackEntry -> val route = backStackEntry.toRoute() val viewModel = diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt index 3cf67e1d..d242b828 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/WaitingRegisterViewModel.kt @@ -53,11 +53,6 @@ class WaitingRegisterViewModel( MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val registerFailureEvent: SharedFlow = _registerFailureEvent.asSharedFlow() - private val _navigateToPhoneRegistrationEvent = - MutableSharedFlow(replay = 0, extraBufferCapacity = 1) - val navigateToPhoneRegistrationEvent: SharedFlow = - _navigateToPhoneRegistrationEvent.asSharedFlow() - init { loadPlaceSummary() } @@ -65,14 +60,17 @@ class WaitingRegisterViewModel( private fun loadPlaceSummary() { viewModelScope.launch { _uiState.value = WaitingRegisterUiState.Loading - waitingInfoRepository - .getWaitingInfo() + val infoResult = waitingInfoRepository.getWaitingInfo() + infoResult .onSuccess { info -> if (info == null) { - _navigateToPhoneRegistrationEvent.tryEmit(Unit) + _uiState.value = WaitingRegisterUiState.NeedsPhoneRegistration return@launch } - }.onFailure { _uiState.value = WaitingRegisterUiState.Error(it) } + }.onFailure { + _uiState.value = WaitingRegisterUiState.Error(it) + return@launch + } placeDetailRepository .getPlaceDetail(placeId) .onSuccess { placeDetail -> diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt index 08cfc62a..3abebf7e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt @@ -99,6 +99,7 @@ fun WaitingRegisterRoute( var showConfirmBottomSheet by rememberSaveable { mutableStateOf(false) } var showPermissionDialog by remember { mutableStateOf(false) } val onOpenAppSettings = rememberOpenAppSettings() + val currentOnNavigateToPhoneRegistration by rememberUpdatedState(onNavigateToPhoneRegistration) val notificationPermissionManager = rememberNotificationPermissionManager( @@ -118,9 +119,7 @@ fun WaitingRegisterRoute( ObserveAsEvents(viewModel.registerFailureEvent) { throwable -> onShowErrorSnackbar(throwable) } - ObserveAsEvents(viewModel.navigateToPhoneRegistrationEvent) { - onNavigateToPhoneRegistration() - } + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { val permission = notificationPermissionManager.checkPermission() @@ -141,6 +140,12 @@ fun WaitingRegisterRoute( } } + LaunchedEffect(uiState) { + if (uiState is WaitingRegisterUiState.NeedsPhoneRegistration) { + currentOnNavigateToPhoneRegistration() + } + } + WaitingRegisterScreen( uiState = uiState, onBackToPreviousClick = onBackToPreviousClick, @@ -208,7 +213,9 @@ fun WaitingRegisterScreen( WaitingRegisterTopBar(onBackClick = onBackToPreviousClick) when (uiState) { - is WaitingRegisterUiState.Loading -> { + is WaitingRegisterUiState.Loading, + is WaitingRegisterUiState.NeedsPhoneRegistration, + -> { Box(modifier = Modifier.weight(1f)) { LoadingStateScreen() } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt index 48ef4366..f27b4856 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/model/WaitingRegisterUiState.kt @@ -3,6 +3,8 @@ package com.daedan.festabook.presentation.placeMap.waitingRegister.model sealed interface WaitingRegisterUiState { data object Loading : WaitingRegisterUiState + data object NeedsPhoneRegistration : WaitingRegisterUiState + data class Success( val waitingRegister: WaitingRegisterUiModel, ) : WaitingRegisterUiState diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt index aa5c4f11..b4efcc57 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/waitingRegister/WaitingRegisterViewModelTest.kt @@ -20,7 +20,6 @@ import dev.mokkery.verifySuspend import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -56,13 +55,6 @@ class WaitingRegisterViewModelTest { phoneNumber = "010-1234-5678", ), ) - viewModel = - WaitingRegisterViewModel( - placeDetailRepository = placeDetailRepository, - waitingInfoRepository = waitingInfoRepository, - waitingRegisterInfoRepository = waitingRegisterInfoRepository, - placeId = FAKE_PLACE_DETAIL.place.id, - ) } @AfterTest @@ -70,31 +62,53 @@ class WaitingRegisterViewModelTest { Dispatchers.resetMain() } + private fun createViewModel(): WaitingRegisterViewModel = + WaitingRegisterViewModel( + placeDetailRepository = placeDetailRepository, + waitingInfoRepository = waitingInfoRepository, + waitingRegisterInfoRepository = waitingRegisterInfoRepository, + placeId = FAKE_PLACE_DETAIL.place.id, + ) + @Test - fun `전화번호 미등록 상태에서 초기화 시 navigateToPhoneRegistrationEvent 를 발행한다`() = - runTest(UnconfinedTestDispatcher()) { + fun `전화번호 미등록 상태에서 초기화 시 NeedsPhoneRegistration 상태가 된다`() = + runTest { // given everySuspend { waitingInfoRepository.getWaitingInfo() } returns Result.success(null) - viewModel = - WaitingRegisterViewModel( - placeDetailRepository = placeDetailRepository, - waitingInfoRepository = waitingInfoRepository, - waitingRegisterInfoRepository = waitingRegisterInfoRepository, - placeId = FAKE_PLACE_DETAIL.place.id, - ) // when - val event = observeEvent(viewModel.navigateToPhoneRegistrationEvent) + viewModel = createViewModel() advanceUntilIdle() // then - assertEquals(Unit, event.await()) + assertEquals(WaitingRegisterUiState.NeedsPhoneRegistration, viewModel.uiState.value) + } + + @Test + fun `WaitingInfo 로드에 실패하면 Error 상태가 되고 PlaceDetail 을 조회하지 않는다`() = + runTest { + // given + val exception = Throwable("정보 조회 실패") + everySuspend { waitingInfoRepository.getWaitingInfo() } returns Result.failure(exception) + + // when + viewModel = createViewModel() + advanceUntilIdle() + + // then + val state = viewModel.uiState.value + assertIs(state) + assertEquals(exception, state.throwable) + verifySuspend(VerifyMode.not) { + placeDetailRepository.getPlaceDetail(any()) + } } @Test fun `초기화 시 PlaceDetail 로드에 성공하면 Success 상태가 된다`() = runTest { // when + viewModel = createViewModel() advanceUntilIdle() // then @@ -117,13 +131,7 @@ class WaitingRegisterViewModelTest { ) // when - viewModel = - WaitingRegisterViewModel( - placeDetailRepository = placeDetailRepository, - waitingInfoRepository = waitingInfoRepository, - waitingRegisterInfoRepository = waitingRegisterInfoRepository, - placeId = FAKE_PLACE_DETAIL.place.id, - ) + viewModel = createViewModel() advanceUntilIdle() // then @@ -136,6 +144,7 @@ class WaitingRegisterViewModelTest { fun `increasePartySize 호출 시 partySize 가 1 증가한다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() // when @@ -151,6 +160,7 @@ class WaitingRegisterViewModelTest { fun `partySize 가 MAX 일 때 increasePartySize 를 호출해도 변경되지 않는다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() repeat(WaitingRegisterUiModel.MAX_PARTY_SIZE - WaitingRegisterUiModel.MIN_PARTY_SIZE) { viewModel.increasePartySize() @@ -169,6 +179,7 @@ class WaitingRegisterViewModelTest { fun `decreasePartySize 호출 시 partySize 가 1 감소한다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() viewModel.increasePartySize() @@ -185,6 +196,7 @@ class WaitingRegisterViewModelTest { fun `partySize 가 MIN 일 때 decreasePartySize 를 호출해도 변경되지 않는다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() // when @@ -200,6 +212,7 @@ class WaitingRegisterViewModelTest { fun `toggleServiceAgreement 호출 시 isServiceAgreed 가 토글된다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() // when @@ -215,6 +228,7 @@ class WaitingRegisterViewModelTest { fun `약관 미동의 상태에서 submitWaitingRegister 를 호출해도 API 를 호출하지 않는다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() // when - isServiceAgreed 가 false 인 기본 상태 @@ -231,6 +245,7 @@ class WaitingRegisterViewModelTest { fun `약관 동의 후 submitWaitingRegister 성공 시 registerSuccessEvent 를 발행한다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns Result.success(FAKE_MY_WAITING) @@ -249,6 +264,7 @@ class WaitingRegisterViewModelTest { fun `submitWaitingRegister 성공 후 Success 상태가 유지되고 isSubmitting 이 false 가 된다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns Result.success(FAKE_MY_WAITING) @@ -268,6 +284,7 @@ class WaitingRegisterViewModelTest { fun `partySize 를 변경하면 registerWaiting 에 변경된 값이 전달된다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns Result.success(FAKE_MY_WAITING) @@ -292,6 +309,7 @@ class WaitingRegisterViewModelTest { fun `submitWaitingRegister 실패 시 registerFailureEvent 를 발행하고 Success 상태를 유지한다`() = runTest { // given + viewModel = createViewModel() advanceUntilIdle() val exception = Throwable("등록 실패") everySuspend { waitingRegisterInfoRepository.registerWaiting(any(), any()) } returns From f00086703e85c8fcb8a83ef8adc8bf44ea6edd62 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:34:15 +0900 Subject: [PATCH 31/54] =?UTF-8?q?[Fix]:=20=EC=9B=A8=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=93=B1=EB=A1=9D=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=85=B8=EC=B6=9C=20=EC=8B=9C=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20=EA=B0=80=EB=A6=BC=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 웨이팅 정보 등록 화면 키보드 노출 시 확인 버튼 가림 현상 해결 Co-authored-by: Claude Opus 4.7 --- .../component/AddWaitingInfoScreen.kt | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt index fd320f25..2fbf4ae5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.presentation.setting.waitinginfo.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,7 +13,9 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Phone import androidx.compose.material3.Button @@ -37,7 +40,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.input.ImeAction @@ -135,6 +140,7 @@ fun AddWaitingInfoScreen( ) { val windowInfo = LocalWindowInfo.current val density = LocalDensity.current + val focusManager = LocalFocusManager.current val screenWidthDp = remember { with(density) { @@ -174,7 +180,9 @@ fun AddWaitingInfoScreen( Modifier .fillMaxSize() .background(FestabookColor.white) - .padding(innerPadding) + .pointerInput(Unit) { + detectTapGestures(onTap = { focusManager.clearFocus() }) + }.padding(innerPadding) .imePadding(), ) { Spacer(modifier = Modifier.height(festabookSpacing.paddingBody5)) @@ -194,7 +202,9 @@ fun AddWaitingInfoScreen( Column( modifier = Modifier - .fillMaxSize() + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(horizontal = festabookSpacing.paddingScreenGutter), ) { AddPhoneNumberContent( @@ -215,16 +225,15 @@ fun AddWaitingInfoScreen( isTermsAgreed = isTermsAgreed, onTermsAgreedChange = onTermsAgreedChange, ) - - Spacer(modifier = Modifier.weight(1f)) - - ConfirmButton( - phoneNumber = phoneNumber, - isSaveEnabled = isSaveEnabled, - isSaving = isSaving, - onSaveClick = onSaveClick, - ) } + + ConfirmButton( + phoneNumber = phoneNumber, + isSaveEnabled = isSaveEnabled, + isSaving = isSaving, + onSaveClick = onSaveClick, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) } } } From a37d88f7835aa3a8e3a6ce1eb461228298c1ccfd Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:34:47 +0900 Subject: [PATCH 32/54] =?UTF-8?q?[Fix]:=20iOS=20FCM=20=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: Fastlane 슬랙 알림 로직 제거 및 iOS 배포 설정 보완 * **refactor: Fastlane 빌드 실패 시 Slack 알림 코드 제거** * `Fastfile`: Android 및 iOS 플랫폼의 `error` 블록 내 `notify_slack` 호출 로직을 삭제했습니다. * **feat: iOS 운영 환경 전용 Entitlements 추가 및 적용** * `iosAppRelease.entitlements`: 푸시 알림 환경(`aps-environment`)을 `production`으로 설정한 파일을 신규 생성했습니다. * `project.pbxproj`: Debug 및 Release 빌드 설정의 `CODE_SIGN_ENTITLEMENTS` 경로를 기존 디버그용에서 신규 생성한 `iosAppRelease.entitlements`로 변경했습니다. * **build: iOS 프로젝트 빌드 설정 업데이트** * `project.pbxproj`: 모든 빌드 구성에 `ENABLE_DEBUG_DYLIB = NO` 설정을 추가했습니다. * `project.pbxproj`: CocoaPods 프레임워크 임베드 스크립트(`[CP] Embed Pods Frameworks`)의 입력/출력 경로 설정을 보완했습니다. * `project.pbxproj`: `Configuration` 그룹의 불필요한 `exceptions` 항목을 제거했습니다. --- fastlane/Fastfile | 20 +------------------- iosApp/iosApp.xcodeproj/project.pbxproj | 12 +++++++++--- iosApp/iosApp/iosAppRelease.entitlements | 8 ++++++++ 3 files changed, 18 insertions(+), 22 deletions(-) create mode 100644 iosApp/iosApp/iosAppRelease.entitlements diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0c8c78ac..842e699e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -273,15 +273,6 @@ platform :android do apk = android_build(flavor: "release") android_upload(flavor: "release", apk_path: apk) end - - error do |lane, exception| - notify_slack( - platform: "Android", - build_type: lane.to_s.include?("release") ? "Release" : "Dev", - success: false, - error: exception.message, - ) - end end # --- iOS lanes --- @@ -337,13 +328,4 @@ platform :ios do ) ios_upload(configuration: "Release", bundle_id: bundle_id, ipa_path: ipa) end - - error do |lane, exception| - notify_slack( - platform: "iOS", - build_type: lane.to_s.include?("release") ? "Release" : "Dev", - success: false, - error: exception.message, - ) - end -end \ No newline at end of file +end diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 69039bb5..fa578257 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -39,8 +39,6 @@ }; 8E6C8D378651BF8144E66D20 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Configuration; sourceTree = ""; }; @@ -239,10 +237,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; @@ -347,13 +349,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CODE_SIGN_ENTITLEMENTS = iosApp/iosAppDebug.entitlements; + CODE_SIGN_ENTITLEMENTS = iosApp/iosAppRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L8HZU9MP64; + ENABLE_DEBUG_DYLIB = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -395,6 +398,7 @@ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L8HZU9MP64; + ENABLE_DEBUG_DYLIB = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -486,10 +490,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = iosApp/iosAppRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_DEBUG_DYLIB = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; diff --git a/iosApp/iosApp/iosAppRelease.entitlements b/iosApp/iosApp/iosAppRelease.entitlements new file mode 100644 index 00000000..28c29bf6 --- /dev/null +++ b/iosApp/iosApp/iosAppRelease.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + production + + From 2343df348d7efc62e9b926d1d9b86002eeaab478 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:35:05 +0900 Subject: [PATCH 33/54] =?UTF-8?q?[Fix]=20=EC=A2=85=20=ED=99=94=EC=A7=88?= =?UTF-8?q?=EC=9D=B4=20=EA=B9=A8=EC=A7=80=EB=8A=94=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 웨이팅 푸시 알림 안내 이미지를 벡터 드로어블로 교체 Co-authored-by: Claude Opus 4.7 --- .../drawable/festa-ting_poster.png | Bin 50663 -> 260335 bytes .../img_waiting_push_notification_alert.png | Bin 10360 -> 38502 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/drawable/festa-ting_poster.png b/composeApp/src/commonMain/composeResources/drawable/festa-ting_poster.png index d3bde4e47b149782755e367112d81ad707c3655d..9231a05df4468f6f9cef0af66ce55c3091b26512 100644 GIT binary patch literal 260335 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 Ef*|Rpq5uE@ literal 50663 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 diff --git a/composeApp/src/commonMain/composeResources/drawable/img_waiting_push_notification_alert.png b/composeApp/src/commonMain/composeResources/drawable/img_waiting_push_notification_alert.png index 601eea88e06e25052b68ab4415df17f2b21c0f1e..1584d494808807bd280354492e39dce18baea176 100644 GIT binary patch literal 38502 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 WN(0hs0tsCJ0000 Date: Tue, 28 Apr 2026 02:35:21 +0900 Subject: [PATCH 34/54] =?UTF-8?q?[Fix]=20=EB=82=98=EC=9D=98=20=EC=9B=A8?= =?UTF-8?q?=EC=9D=B4=ED=8C=85=20=ED=95=98=EB=8B=A8=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EB=B0=94=20=EB=B0=B0=EA=B2=BD=EC=83=89=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: '나의 웨이팅' 화면 레이아웃 및 취소 버튼 UI 개선 * **refactor: `MyWaitingScreen` 배경색 및 스크롤 영역 레이아웃 수정** * 메인 컨텐츠 영역인 `Column`에 `FestabookColor.white` 배경색을 명시적으로 추가했습니다. * 하단 취소 버튼 영역을 별도의 `Column`으로 감싸고 배경색을 추가하여, 스크롤 시 버튼 영역이 투명하게 보이지 않도록 개선했습니다. * 취소 버튼 영역에 화면 너비를 강제하는 `requiredWidth` 설정을 추가했습니다. * **style: `WaitingCancelButton` 패딩 및 디자인 디테일 조정** * 기존 버튼 컴포넌트 내부의 수직 패딩을 제거하고, 호출부에서 `festabookSpacing`을 사용한 수평/수직 패딩을 적용하도록 변경하여 레이아웃 일관성을 높였습니다. * style: 홈 화면 및 테스트 코드 내 불필요한 임포트 최적화 * **style: `HomeScreen`, `HomeViewModel`, `HomeViewModelTest` 임포트 순서 정리** * 패키지 가독성 향상을 위해 관련 도메인 레포지토리 및 UI 상태 모델의 임포트 순서를 일관되게 조정했습니다. * 중복되거나 잘못 정렬된 임포트 구문을 정리했습니다. * style: `MyWaitingScreen` 내 취소 버튼 패딩 값 수정 * **style: `MyWaitingScreen` 내 `WaitingCancelButton` 레이아웃 조정** * 기존 세로(`paddingBody2`)와 가로(`paddingScreenGutter`)를 별도로 설정하던 방식에서, 모든 방향에 `paddingScreenGutter`를 일괄 적용하도록 간소화했습니다. --- .../presentation/home/HomeViewModel.kt | 2 +- .../presentation/home/component/HomeScreen.kt | 2 +- .../waiting/component/MyWaitingScreen.kt | 27 ++++++++++++------- .../viewModel/home/HomeViewModelTest.kt | 4 +-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt index 6bfb7757..42f60221 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.presentation.home.model.toUiModel import com.daedan.festabook.domain.repository.MyWaitingRepository +import com.daedan.festabook.presentation.home.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 6fee9f7e..8627049c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -26,12 +26,12 @@ import com.daedan.festabook.presentation.festating.component.FestatingPoster import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.home.model.FestivalPosterUiModel import com.daedan.festabook.presentation.home.model.FestivalUiModel import com.daedan.festabook.presentation.home.model.LineUpItemGroupUiModel import com.daedan.festabook.presentation.home.model.LineupItemUiModel import com.daedan.festabook.presentation.home.model.OrganizationUiModel -import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt index f6bb3ae5..ec0411ad 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt @@ -215,6 +215,7 @@ private fun MyWaitingContent( modifier = Modifier .fillMaxSize() + .background(FestabookColor.white) .verticalScroll(rememberScrollState()), ) { Spacer(modifier = Modifier.height(16.dp)) @@ -279,12 +280,22 @@ private fun MyWaitingContent( Spacer(modifier = Modifier.height(100.dp)) } - WaitingCancelButton( - isEnabled = !uiState.myWaiting.isCanceling, - isCancelling = uiState.myWaiting.isCanceling, - onClick = { showCancelConfirmDialog = true }, - modifier = Modifier.align(Alignment.BottomCenter), - ) + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .requiredWidth(screenWidthDp) + .background(FestabookColor.white), + verticalArrangement = Arrangement.Center, + ) { + WaitingCancelButton( + modifier = + Modifier.padding(festabookSpacing.paddingScreenGutter), + isEnabled = !uiState.myWaiting.isCanceling, + isCancelling = uiState.myWaiting.isCanceling, + onClick = { showCancelConfirmDialog = true }, + ) + } } } @@ -377,9 +388,7 @@ private fun WaitingCancelButton( Box( modifier = modifier - .padding( - vertical = festabookSpacing.paddingBody4, - ).fillMaxWidth() + .fillMaxWidth() .height(52.dp) .clip(festabookShapes.radius2) .background(if (isEnabled) FestabookColor.black else FestabookColor.gray300) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt index 83606490..d76bcd2d 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt @@ -3,15 +3,15 @@ package com.daedan.festabook.viewModel.home import com.daedan.festabook.domain.model.MyWaiting import com.daedan.festabook.domain.model.WaitingStatus import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.domain.repository.MyWaitingRepository import com.daedan.festabook.home.FAKE_LINEUP import com.daedan.festabook.home.FAKE_ORGANIZATION -import com.daedan.festabook.domain.repository.MyWaitingRepository import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.home.model.LineUpItemOfDayUiModel import com.daedan.festabook.presentation.home.model.toUiModel -import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.home.toUiModel import dev.mokkery.answering.returns import dev.mokkery.everySuspend From 80cea67701c78017801e6b2ab2791147a20144c3 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:30:33 +0900 Subject: [PATCH 35/54] =?UTF-8?q?[Feat]:=20=EB=82=98=EC=9D=98=20=EC=9B=A8?= =?UTF-8?q?=EC=9D=B4=ED=8C=85=20=ED=99=94=EB=A9=B4=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=90=EC=B2=B4=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 나의 웨이팅 화면 레이아웃 구조 개선 및 아티스트 아이템 이미지 컴포넌트 교체 * **refactor: `MyWaitingScreen` 하단 버튼 레이아웃 및 UX 개선** * 기존 `Box` 내부 `align(Alignment.BottomCenter)` 방식에서 `Scaffold`의 `bottomBar` 영역을 사용하는 방식으로 구조를 변경했습니다. * 하단 버튼 영역에 그림자 효과(`shadow`)와 시스템 네비게이션 바 패딩(`navigationBarsPadding`)을 추가하여 시각적 완성도를 높였습니다. * `isCancelEnabled` 및 `isCancelling` 변수를 상단으로 추출하여 상태 관리 로직을 정리하고, 다이얼로그 표시 로직의 위치를 조정했습니다. * **refactor: `HomeArtistItem` 내 이미지 컴포넌트 교체** * 기존 `CoilImage`를 커스텀 컴포넌트인 `FestabookImage`로 교체했습니다. * 아티스트 이름을 `contentDescription`으로 전달하고, 이미지 클릭 시 팝업 기능(`enablePopUp = true`)을 활성화했습니다. * refactor: `MyWaitingScreen` UI 상태 처리 로직 및 하단 레이아웃 개선 * **refactor: `MyWaitingScreen` 내 상태 변화에 따른 다이얼로그 및 버튼 노출 제어** * `LaunchedEffect`를 추가하여 `uiState`가 `Success` 상태가 아닐 경우 취소 확인 다이얼로그(`showCancelConfirmDialog`)가 자동으로 닫히도록 개선했습니다. * `Scaffold`의 `bottomBar` 영역에 조건문을 추가하여, 데이터 로드 성공(`Success`) 시에만 하단 취소 버튼 영역이 렌더링되도록 수정했습니다. * **style: 컨텐츠 하단 여백 수치 조정** * 컨텐츠 최하단에 위치한 `Spacer`의 높이를 하드코딩된 값(`100.dp`)에서 디자인 시스템 정의 값인 `festabookSpacing.paddingTitleHorizontal`으로 변경하여 레이아웃 일관성을 높였습니다. --- .../home/component/HomeArtistItem.kt | 9 ++- .../waiting/component/MyWaitingScreen.kt | 78 ++++++++++++------- 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt index 0f14d9dd..0b712c94 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography import org.jetbrains.compose.ui.tooling.preview.Preview @@ -28,9 +28,10 @@ fun HomeArtistItem( Column( modifier = modifier.width(68.dp), ) { - CoilImage( - url = artistImageUrl, - contentDescription = null, + FestabookImage( + imageUrl = artistImageUrl, + contentDescription = artistName, + enablePopUp = true, modifier = Modifier .fillMaxWidth() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt index ec0411ad..6d54844c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt @@ -10,6 +10,7 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size @@ -26,6 +27,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,6 +36,7 @@ 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.draw.shadow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.dp @@ -114,11 +117,55 @@ fun MyWaitingScreen( val state = rememberNavigationEventState(NavigationEventInfo.None) NavigationBackHandler(state = state) { onBack() } + var showCancelConfirmDialog by remember { mutableStateOf(false) } + val isCancelEnabled = uiState is MyWaitingUiState.Success && !uiState.myWaiting.isCanceling + val isCancelling = uiState is MyWaitingUiState.Success && uiState.myWaiting.isCanceling + + LaunchedEffect(uiState) { + if (uiState !is MyWaitingUiState.Success) { + showCancelConfirmDialog = false + } + } + Scaffold( modifier = modifier, topBar = { MyWaitingTopBar(onBack = onBack) }, + bottomBar = { + if (uiState is MyWaitingUiState.Success) { + Column( + modifier = + Modifier + .shadow(elevation = 10.dp, clip = false) + .background(FestabookColor.white) + .navigationBarsPadding(), + verticalArrangement = Arrangement.Center, + ) { + WaitingCancelButton( + modifier = + Modifier.padding( + vertical = festabookSpacing.paddingBody2, + horizontal = festabookSpacing.paddingScreenGutter, + ), + isEnabled = isCancelEnabled, + isCancelling = isCancelling, + onClick = { showCancelConfirmDialog = true }, + ) + } + } + }, containerColor = FestabookColor.white, ) { innerPadding -> + if (showCancelConfirmDialog) { + WaitingCancelConfirmDialog( + title = stringResource(Res.string.my_waiting_cancel_confirm_title), + onDismissClick = { showCancelConfirmDialog = false }, + onCancelClick = { + showCancelConfirmDialog = false + onCancelWaiting() + }, + onDismissRequest = { showCancelConfirmDialog = false }, + ) + } when (uiState) { is MyWaitingUiState.Loading -> { LoadingStateScreen(modifier = Modifier.padding(innerPadding)) @@ -191,19 +238,6 @@ private fun MyWaitingContent( windowInfo.containerSize.width.toDp() } } - var showCancelConfirmDialog by remember { mutableStateOf(false) } - - if (showCancelConfirmDialog) { - WaitingCancelConfirmDialog( - title = stringResource(Res.string.my_waiting_cancel_confirm_title), - onDismissClick = { showCancelConfirmDialog = false }, - onCancelClick = { - showCancelConfirmDialog = false - onCancelWaiting() - }, - onDismissRequest = { showCancelConfirmDialog = false }, - ) - } Box( modifier = @@ -278,23 +312,7 @@ private fun MyWaitingContent( phoneNumber = uiState.myWaiting.phoneNumber, ) - Spacer(modifier = Modifier.height(100.dp)) - } - Column( - modifier = - Modifier - .align(Alignment.BottomCenter) - .requiredWidth(screenWidthDp) - .background(FestabookColor.white), - verticalArrangement = Arrangement.Center, - ) { - WaitingCancelButton( - modifier = - Modifier.padding(festabookSpacing.paddingScreenGutter), - isEnabled = !uiState.myWaiting.isCanceling, - isCancelling = uiState.myWaiting.isCanceling, - onClick = { showCancelConfirmDialog = true }, - ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingTitleHorizontal)) } } } From 5bd0ac95959b3e446478aaa4b84ea4bf84d9aa74 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:37:40 +0900 Subject: [PATCH 36/54] =?UTF-8?q?[Feat]=20=EC=9D=B8=EC=8A=A4=ED=83=80/?= =?UTF-8?q?=ED=99=88=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=ED=8F=B0=EC=84=9C=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 축제 상세 정보 응답 모델 내 후원사 정보 및 관련 링크 필드 추가 * **feat: `FestivalResponse` 내 신규 필드 추가** * 주최 측 식별을 위한 `organizationId` 필드를 추가했습니다. * 축제 후원사 목록 조회를 위한 `festivalSponsors` 필드를 추가했습니다. * 축제 외부 연결을 위한 `instagramLink` 및 `homepageLink` 필드를 추가했습니다. * **feat: `FestivalSponsorsResponse` 데이터 모델 생성** * 후원사 배너 이미지 URL, ID, 노출 순서(`sequence`)를 포함하는 응답 모델 객체를 신규 정의했습니다. * feat: 축제 상세 정보 내 인스타그램 및 홈페이지 링크 추가 * **feat: `Festival` 도메인 및 UI 모델에 외부 링크 필드 추가** * 기존 `Organization` 모델에 포함되어 있던 `instagramLink`와 `homepageLink` 필드를 `Festival` 도메인 모델, `FestivalUiModel`, `FestivalResponse`로 이동하여 데이터 구조를 변경했습니다. * 이에 따라 `HomeTestFixture`, `SettingScreen`, `HomeScreen` 등 관련 테스트 코드와 UI 컴포넌트의 데이터 매핑 로직을 수정했습니다. * **feat: `HomeFestivalInfo` 컴포넌트 내 SNS 아이콘 및 링크 연결 기능 구현** * 축제 정보 화면에서 인스타그램 및 홈페이지 링크가 있을 경우 아이콘을 표시하도록 구현했습니다. * `LocalUriHandler`를 사용하여 아이콘 클릭 시 해당 외부 URL로 연결되는 기능을 추가했습니다. * 아이콘 식별을 위한 `strings.xml` 내 리소스 및 접근성용 `contentDescription`을 추가했습니다. * feat: 홈 화면 내 후원사 배너 컴포넌트 추가 * **feat: `HomeSponsorBanner` 컴포넌트 신규 구현** * `HorizontalPager`를 사용하여 후원사 배너가 4초 간격으로 무한 자동 스크롤되는 기능을 구현했습니다. * `FestivalSponsorUiModel`의 `sequence`를 기준으로 데이터를 정렬하여 표시하도록 처리했습니다. * 배너 영역에 `cardBackground`와 `radius3` 라운드 처리를 적용하여 시각적 일관성을 맞췄습니다. * **feat: `HomeScreen` 내 후원사 배너 영역 통합** * 홈 화면 하단 영역에 `HomeSponsorBanner`를 배치하고, 구분선(`HorizontalDivider`)을 추가했습니다. * 배너 노출에 따른 레이아웃 조정을 위해 하단 `Spacer` 여백을 60.dp에서 80.dp로 확대했습니다. * **style: 관련 문자열 리소스 추가** * 배너 타이틀용 리소스(`home_sponsor_title`: "Sponsored")를 추가했습니다. * chore: ktlintformat 적용 * refactor: `HomeScreen` 내 웨이팅 바 노출 여부에 따른 하단 여백 동적 조정 * **refactor: `HomeContent` 내 하단 여백(`Spacer`) 로직 개선** * 웨이팅 바의 노출 상태(`showWaitingBar`)를 파라미터로 전달받아 하단 여백을 동적으로 결정하도록 수정했습니다. * 웨이팅 바가 활성화된 경우 하단 버튼과의 겹침 방지를 위해 `80.dp`의 여백을 유지하고, 비활성화된 경우 디자인 시스템 정의 값인 `festabookSpacing.paddingBody5`를 적용하여 불필요한 공백을 제거했습니다. * **refactor: `HomeScreen` 및 프리뷰 코드 수정** * `HomeScreen`에서 `waitingBarUiState`를 기반으로 `showWaitingBar` 상태를 판단하여 `HomeContent`에 전달하도록 로직을 추가했습니다. * `HomeContent` 프리뷰 코드에 변경된 파라미터 구조를 반영했습니다. * style: `HomeSponsorBanner` 내 이미지 스케일 옵션 수정 * **style: `FestabookImage`에 `ContentScale.Fit` 적용** * 후원사 배너 이미지가 잘리지 않고 전체 영역 내에 적절히 맞게 표시되도록 `contentScale` 설정을 추가했습니다. * style: `HomeSponsorBanner` 내 이미지 스케일 옵션 수정 * **style: `FestabookImage`에 `ContentScale.Fit` 적용** * 후원사 배너 이미지가 잘리지 않고 전체 영역 내에 적절히 맞게 표시되도록 `contentScale` 설정을 추가했습니다. * refactor: `HomeScreen` 내 메인 컨텐츠 영역의 패딩 적용 방식 수정 * **refactor: `HomeScreen` 레이아웃 패딩 최적화** * `Scaffold`에서 제공하는 `innerPadding` 전체를 적용하던 방식에서 상단 패딩(`calculateTopPadding`)만 적용하도록 변경했습니다. * 이를 통해 하단 탭바나 시스템 네비게이션 영역과의 레이아웃 간섭을 방지하고 의도된 스크롤 영역을 확보했습니다. --- .../composeResources/drawable/ic_homepage.xml | 24 +++++ .../drawable/ic_instagram.xml | 9 ++ .../composeResources/values/strings.xml | 4 + .../response/festival/FestivalResponse.kt | 11 +++ .../festival/FestivalSponsorsResponse.kt | 22 +++++ .../daedan/festabook/domain/model/Festival.kt | 3 + .../festabook/domain/model/FestivalSponsor.kt | 7 ++ .../home/component/HomeFestivalInfo.kt | 46 +++++++++ .../presentation/home/component/HomeScreen.kt | 34 ++++++- .../home/component/HomeSponsorBanner.kt | 99 +++++++++++++++++++ .../home/model/FestivalSponsorUiModel.kt | 16 +++ .../home/model/FestivalUiModel.kt | 6 ++ .../setting/component/SettingScreen.kt | 3 + .../viewModel/home/HomeTestFixture.kt | 3 + 14 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_homepage.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_instagram.xml create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalSponsorsResponse.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/FestivalSponsor.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeSponsorBanner.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalSponsorUiModel.kt diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_homepage.xml b/composeApp/src/commonMain/composeResources/drawable/ic_homepage.xml new file mode 100644 index 00000000..07fd3045 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_homepage.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_instagram.xml b/composeApp/src/commonMain/composeResources/drawable/ic_instagram.xml new file mode 100644 index 00000000..f566ab59 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_instagram.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 85b9bcbf..f82619c9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -25,10 +25,14 @@ 일정 화면으로 이동하는 버튼 일정 확인하기 축제 라인업 + Sponsored 탐색 화면으로 이동하는 버튼 페스타팅 이미지 %1$s년 %1$s월 %2$s일 + 인스타그램 아이콘 + 홈페이지 아이콘 + diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt index bf5dc22b..7ecbb29b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt @@ -11,6 +11,8 @@ import kotlinx.serialization.Serializable data class FestivalResponse( @SerialName("festivalId") val id: Long, + @SerialName("organizationId") + val organizationId: Long, @SerialName("organizationName") val organizationName: String, @SerialName("festivalImages") @@ -21,6 +23,12 @@ data class FestivalResponse( val startDate: String, @SerialName("endDate") val endDate: String, + @SerialName("festivalSponsors") + val festivalSponsors: List, + @SerialName("instagramLink") + val instagramLink: String?, + @SerialName("homepageLink") + val homepageLink: String?, ) { @Serializable data class FestivalImage( @@ -43,6 +51,9 @@ fun FestivalResponse.toDomain() = festivalName = festivalName, startDate = startDate.toLocalDate(), endDate = endDate.toLocalDate(), + sponsors = festivalSponsors.map { it.toDomain() }, + instagramLink = instagramLink, + homepageLink = homepageLink, ), ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalSponsorsResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalSponsorsResponse.kt new file mode 100644 index 00000000..1ab42b11 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalSponsorsResponse.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.data.model.response.festival + +import com.daedan.festabook.domain.model.FestivalSponsor +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FestivalSponsorsResponse( + @SerialName("bannerUrl") + val bannerUrl: String, + @SerialName("festivalSponsorId") + val festivalSponsorId: Int, + @SerialName("sequence") + val sequence: Int, +) + +fun FestivalSponsorsResponse.toDomain() = + FestivalSponsor( + id = festivalSponsorId, + bannerUrl = bannerUrl, + sequence = sequence, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt index ee77268a..d5363af6 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt @@ -7,4 +7,7 @@ data class Festival( val festivalImages: List, val startDate: LocalDate, val endDate: LocalDate, + val sponsors: List, + val instagramLink: String?, + val homepageLink: String?, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/FestivalSponsor.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/FestivalSponsor.kt new file mode 100644 index 00000000..ebef2dca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/FestivalSponsor.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.domain.model + +data class FestivalSponsor( + val id: Int, + val bannerUrl: String, + val sequence: Int, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt index 2249864f..95f25df5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -1,16 +1,29 @@ package com.daedan.festabook.presentation.home.component +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.content_description_homepage +import festabookkmp.composeapp.generated.resources.content_description_instagram +import festabookkmp.composeapp.generated.resources.ic_homepage +import festabookkmp.composeapp.generated.resources.ic_instagram +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -18,7 +31,11 @@ fun HomeFestivalInfo( festivalName: String, festivalDate: String, modifier: Modifier = Modifier, + instagramLink: String? = null, + homepageLink: String? = null, ) { + val uriHandler = LocalUriHandler.current + Column( modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp), ) { @@ -35,6 +52,35 @@ fun HomeFestivalInfo( style = FestabookTypography.bodyLarge, color = FestabookColor.gray500, ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + ) { + if (instagramLink != null) { + Icon( + modifier = + Modifier.clickable { + uriHandler.openUri(instagramLink) + }, + painter = painterResource(Res.drawable.ic_instagram), + contentDescription = stringResource(Res.string.content_description_instagram), + tint = FestabookColor.gray500, + ) + } + if (homepageLink != null) { + Icon( + modifier = + Modifier.clickable { + uriHandler.openUri(homepageLink) + }, + painter = painterResource(Res.drawable.ic_homepage), + contentDescription = stringResource(Res.string.content_description_homepage), + tint = FestabookColor.gray500, + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 8627049c..7cca433f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -118,6 +118,7 @@ fun HomeScreen( HomeContent( festivalUiState = state, lineupUiState = lineupUiState, + showWaitingBar = waitingBarUiState is WaitingBarUiState.Visible, onNavigateToExplore = onNavigateToExplore, onNavigateToSchedule = homeViewModel::navigateToScheduleClick, onFestatingClick = { festivalId -> onNavigateToFestating(festivalId) }, @@ -152,6 +153,7 @@ fun HomeScreen( private fun HomeContent( festivalUiState: FestivalUiState.Success, lineupUiState: LineupUiState, + showWaitingBar: Boolean, onNavigateToExplore: () -> Unit, onNavigateToSchedule: () -> Unit, onFestatingClick: (festivalId: Long) -> Unit, @@ -177,7 +179,7 @@ private fun HomeContent( Column( modifier = Modifier - .padding(innerPadding) + .padding(top = innerPadding.calculateTopPadding()) .fillMaxSize(), ) { LazyColumn( @@ -208,6 +210,8 @@ private fun HomeContent( festival.startDate, festival.endDate, ), + instagramLink = festival.instagramLink, + homepageLink = festival.homepageLink, modifier = Modifier.padding(top = festabookSpacing.paddingBody4), ) } @@ -272,9 +276,31 @@ private fun HomeContent( } } + // 후원사 배너 + val sponsors = festivalUiState.organization.festival.sponsors + if (sponsors.isNotEmpty()) { + item { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody4), + ) + } + item { + HomeSponsorBanner( + sponsors = sponsors, + ) + } + } + // 하단 여백 추가 item { - Spacer(modifier = Modifier.padding(bottom = 60.dp)) + Spacer( + modifier = + Modifier.padding( + bottom = if (showWaitingBar) 80.dp else festabookSpacing.paddingBody5, + ), + ) } } } @@ -323,6 +349,9 @@ private fun FestivalOverviewPreview() { FestivalPosterUiModel(1, "sample", 1), FestivalPosterUiModel(2, "sample", 2), ), + sponsors = emptyList(), + instagramLink = null, + homepageLink = null, ), ) @@ -368,6 +397,7 @@ private fun FestivalOverviewPreview() { HomeContent( festivalUiState = FestivalUiState.Success(sampleFestival), lineupUiState = LineupUiState.Success(sampleLineups.getLineupItems()), + showWaitingBar = false, onNavigateToExplore = {}, onNavigateToSchedule = {}, onFestatingClick = {}, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeSponsorBanner.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeSponsorBanner.kt new file mode 100644 index 00000000..50df00eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeSponsorBanner.kt @@ -0,0 +1,99 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.FestabookImage +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.home.model.FestivalSponsorUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import festabookkmp.composeapp.generated.resources.Res +import festabookkmp.composeapp.generated.resources.home_sponsor_title +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +private const val AUTO_SCROLL_DELAY = 4_000L + +@Composable +fun HomeSponsorBanner( + sponsors: List, + modifier: Modifier = Modifier, +) { + if (sponsors.isEmpty()) return + + val sortedSponsors = remember(sponsors) { sponsors.sortedBy { it.sequence } } + val initialPage = + remember(sortedSponsors.size) { + (Int.MAX_VALUE / 2) - ((Int.MAX_VALUE / 2) % sortedSponsors.size) + } + val pagerState = + rememberPagerState( + initialPage = initialPage, + pageCount = { Int.MAX_VALUE }, + ) + + LaunchedEffect(Unit) { + while (true) { + delay(AUTO_SCROLL_DELAY) + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + + Column( + modifier = modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) { + Text( + text = stringResource(Res.string.home_sponsor_title), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) + + HorizontalPager( + state = pagerState, + modifier = + Modifier + .fillMaxWidth() + .height(70.dp) + .cardBackground( + shape = festabookShapes.radius3, + ), + ) { page -> + val actualIndex = page % sortedSponsors.size + FestabookImage( + imageUrl = sortedSponsors[actualIndex].bannerUrl, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeSponsorBannerPreview() { + HomeSponsorBanner( + sponsors = + listOf( + FestivalSponsorUiModel(1, "sample", 1), + FestivalSponsorUiModel(2, "sample", 2), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalSponsorUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalSponsorUiModel.kt new file mode 100644 index 00000000..a3388a12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalSponsorUiModel.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.presentation.home.model + +import com.daedan.festabook.domain.model.FestivalSponsor + +data class FestivalSponsorUiModel( + val id: Int, + val bannerUrl: String, + val sequence: Int, +) + +fun FestivalSponsor.toUiModel(): FestivalSponsorUiModel = + FestivalSponsorUiModel( + id = id, + bannerUrl = bannerUrl, + sequence = sequence, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt index 9f5f9521..fdf66dbb 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt @@ -8,6 +8,9 @@ data class FestivalUiModel( val festivalImages: List, val startDate: LocalDate, val endDate: LocalDate, + val sponsors: List, + val instagramLink: String?, + val homepageLink: String?, ) fun Festival.toUiModel(): FestivalUiModel = @@ -16,4 +19,7 @@ fun Festival.toUiModel(): FestivalUiModel = festivalImages = festivalImages.map { it.toUiModel() }, startDate = startDate, endDate = endDate, + sponsors = sponsors.map { it.toUiModel() }, + instagramLink = instagramLink, + homepageLink = homepageLink, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 3a8faec4..1cf21b01 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -469,6 +469,9 @@ private fun SettingScreenPreview() { festivalImages = listOf(), startDate = LocalDate(2026, 1, 1), endDate = LocalDate(2026, 1, 2), + sponsors = emptyList(), + instagramLink = null, + homepageLink = null, ), ), ), diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt index 301953ac..7877e760 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt @@ -24,6 +24,9 @@ val FAKE_ORGANIZATION = ), startDate = LocalDate(2025, 1, 1), endDate = LocalDate(2025, 1, 3), + sponsors = emptyList(), + instagramLink = null, + homepageLink = null, ), ) From 411b001cb7b310d8e41a628118bcb834b9d47763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 30 Apr 2026 16:44:13 +0900 Subject: [PATCH 37/54] =?UTF-8?q?[Fix]=20=EC=97=B0=EC=86=8D=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 중복 클릭 방지를 위한 `throttleClick` 확장 함수 추가 및 적용 * **feat: `Modifier.throttleClick` 확장 함수 구현** * 지정된 시간(`windowMs`, 기본값 300ms) 내에 발생하는 연속 클릭을 방지하는 커스텀 `Modifier`를 추가했습니다. * `kotlinx.datetime.Clock`을 사용하여 클릭 간격 시간을 계산합니다. * **refactor: `HomeHeader` 내 클릭 로직 개선** * `HomeHeader`에서 사용하던 기존 `clickable`을 `throttleClick`으로 교체하여 중복 이벤트를 방지했습니다. * 클릭 이벤트 관련 콜백 명칭을 `onExpandClick`에서 `onTitleClick`으로 변경했습니다. * **chore: 기타 변경 사항** * `HomeScreen.kt` 내 불필요한 임포트 순서를 정리했습니다. * refactor: 클릭 중복 방지 로직 개선 및 UI 레이아웃 수정 * **refactor: `throttleClick` 구현 방식 및 사용처 변경** * 기존 `Modifier.throttleClick` 확장 함수를 삭제하고, `LifecycleOwner`의 상태를 기반으로 동작하는 `throttleClick` 유틸 함수로 대체했습니다. * `HomeHeader` 및 `FestabookNavigator.popBackStack`에서 새로운 `throttleClick`을 사용하여 중복 클릭을 방지하도록 수정했습니다. * `HomeHeader`의 클릭 이벤트를 `throttleClick`과 Compose 기본 `clickable` 조합으로 변경했습니다. * **ui: `ExploreScreen` 및 관련 컴포넌트 레이아웃 최적화** * `ExploreScreen` 상단 바 및 메인 컨텐츠에 `festabookSpacing.paddingBody5`를 적용하여 일관된 패딩을 유지하도록 수정했습니다. * `ExploreBackHeader`의 뒤로 가기 버튼을 `IconButton`에서 `Icon`과 `clickable` 조합으로 변경하고, 클릭 영역에 원형 클리핑(`festabookShapes.radiusFull`)을 적용했습니다. * refactor: throttleClick을 runIfAtLeastState로 변경 및 범용성 개선 * **refactor: `throttleClick`을 대체하는 `runIfAtLeastState` 구현** * 기존의 `throttleClick.kt` 파일을 삭제하고, 보다 명확한 명칭인 `runIfAtLeastState.kt`를 추가했습니다. * `runIfAtLeastState` 함수에 `Lifecycle.State` 파라미터를 추가하여 특정 생명주기 상태 이상일 때만 액션이 실행되도록 개선했습니다. (기본값: `RESUMED`) * **refactor: 기존 호출부 변경** * `HomeHeader.kt`: 제목 클릭 시 호출하던 `throttleClick`을 `runIfAtLeastState`로 변경했습니다. * `FestabookNavigator.kt`: 뒤로 가기(`popBackStack`) 로직 실행 시 `runIfAtLeastState`를 사용하도록 수정했습니다. * style: 코드 포맷팅 * style: 코드 포맷팅 * chore: GitHub Actions 워크플로우 내 환경 변수 추가 * **chore: `cmp-ci.yml` 수정** * `local.properties` 생성 단계에 `FESTA_TING_URL` 및 `FESTA_TING_URL_DEV` 환경 변수 설정을 추가했습니다. --- .github/workflows/cmp-ci.yml | 2 ++ .../presentation/common/RunIfAtLeastState.kt | 10 +++++++++ .../explore/component/ExploreBackHeader.kt | 22 ++++++++++--------- .../explore/component/ExploreScreen.kt | 10 ++++++--- .../presentation/home/component/HomeHeader.kt | 9 +++++--- .../presentation/home/component/HomeScreen.kt | 2 +- .../presentation/main/FestabookNavigator.kt | 5 ++++- .../viewModel/home/HomeViewModelTest.kt | 1 - iosApp/Configuration/Config.xcconfig | 4 ++-- 9 files changed, 44 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RunIfAtLeastState.kt diff --git a/.github/workflows/cmp-ci.yml b/.github/workflows/cmp-ci.yml index ef1df1d6..4c290b7a 100644 --- a/.github/workflows/cmp-ci.yml +++ b/.github/workflows/cmp-ci.yml @@ -51,6 +51,8 @@ jobs: echo "NAVER_MAP_STYLE_ID=${{ secrets.NAVER_MAP_STYLE_ID }}" >> local.properties echo "APP_BUNDLE_ID=${{ secrets.APP_BUNDLE_ID }}" >> local.properties echo "APP_BUNDLE_ID_DEV=${{ secrets.APP_BUNDLE_ID_DEV }}" >> local.properties + echo "FESTA_TING_URL=${{ secrets.FESTA_TING_URL }}" >> local.properties + echo "FESTA_TING_URL_DEV=${{ secrets.FESTA_TING_URL_DEV }}" >> local.properties - name: Load Google Service file env: diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RunIfAtLeastState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RunIfAtLeastState.kt new file mode 100644 index 00000000..d5fe8a23 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RunIfAtLeastState.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.presentation.common + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner + +fun runIfAtLeastState( + lifecycleOwner: LifecycleOwner, + state: Lifecycle.State = Lifecycle.State.RESUMED, + action: () -> Unit, +) = if (lifecycleOwner.lifecycle.currentState.isAtLeast(state)) action() else Unit diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt index 39de1c04..65e898ff 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt @@ -1,16 +1,18 @@ package com.daedan.festabook.presentation.explore.component +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable 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.unit.dp import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.explore_back import festabookkmp.composeapp.generated.resources.ic_arrow_back @@ -30,15 +32,15 @@ fun ExploreBackHeader( .height(56.dp), contentAlignment = Alignment.CenterStart, ) { - IconButton( - onClick = onBackClick, - ) { - Icon( - imageVector = vectorResource(Res.drawable.ic_arrow_back), - contentDescription = stringResource(Res.string.explore_back), - tint = Color.Unspecified, - ) - } + Icon( + imageVector = vectorResource(Res.drawable.ic_arrow_back), + contentDescription = stringResource(Res.string.explore_back), + tint = Color.Unspecified, + modifier = + Modifier + .clip(festabookShapes.radiusFull) + .clickable { onBackClick() }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt index 7d014a61..1564c94f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt @@ -27,6 +27,7 @@ import com.daedan.festabook.presentation.explore.ExploreViewModel import com.daedan.festabook.presentation.explore.SearchUiState import com.daedan.festabook.presentation.explore.model.SearchResultUiModel import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookSpacing import festabookkmp.composeapp.generated.resources.Res import festabookkmp.composeapp.generated.resources.explore_festabook_logo import festabookkmp.composeapp.generated.resources.logo_title @@ -96,16 +97,19 @@ fun ExploreSearchScreen( topBar = { ExploreBackHeader( onBackClick = onBackClick, - modifier = Modifier.statusBarsPadding(), + modifier = + Modifier + .statusBarsPadding() + .padding(horizontal = festabookSpacing.paddingBody5), ) }, ) { innerPadding -> Box( modifier = Modifier - .fillMaxSize() .padding(innerPadding) - .padding(20.dp), + .fillMaxSize() + .padding(festabookSpacing.paddingBody5), ) { ExploreSearchContent( query = query, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt index 24dc3c15..88fcd0f2 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.daedan.festabook.presentation.common.runIfAtLeastState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography import festabookkmp.composeapp.generated.resources.Res @@ -27,9 +29,10 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun HomeHeader( universityName: String, - onExpandClick: () -> Unit, + onTitleClick: () -> Unit, modifier: Modifier = Modifier, ) { + val lifecycleOwner = LocalLifecycleOwner.current Box( modifier = modifier @@ -37,7 +40,7 @@ fun HomeHeader( .padding(horizontal = 16.dp), ) { Row( - modifier = Modifier.clickable { onExpandClick() }, + modifier = Modifier.clickable { runIfAtLeastState(lifecycleOwner = lifecycleOwner) { onTitleClick() } }, verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -67,6 +70,6 @@ fun HomeHeader( private fun HomeHeaderPreview() { HomeHeader( universityName = "가천대학교", - onExpandClick = {}, + onTitleClick = {}, ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 7cca433f..31151ab2 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -167,7 +167,7 @@ private fun HomeContent( topBar = { HomeHeader( universityName = universityName, - onExpandClick = onNavigateToExplore, + onTitleClick = onNavigateToExplore, modifier = Modifier.padding( top = festabookSpacing.paddingTitleHorizontal, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt index 2898d822..666de1b9 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookNavigator.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.presentation.main import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavGraph.Companion.findStartDestination @@ -9,6 +10,7 @@ import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.daedan.festabook.presentation.common.runIfAtLeastState class FestabookNavigator( val navController: NavHostController, @@ -61,7 +63,8 @@ class FestabookNavigator( } fun popBackStack() { - navController.popBackStack() + val lifecycleOwner: LifecycleOwner = navController.currentBackStackEntry ?: return + runIfAtLeastState(lifecycleOwner = lifecycleOwner) { navController.popBackStack() } } fun popBackStack( diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt index d76bcd2d..5f49110a 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt @@ -12,7 +12,6 @@ import com.daedan.festabook.presentation.home.LineupUiState import com.daedan.festabook.presentation.home.WaitingBarUiState import com.daedan.festabook.presentation.home.model.LineUpItemOfDayUiModel import com.daedan.festabook.presentation.home.model.toUiModel -import com.daedan.festabook.presentation.home.toUiModel import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.mock diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 23e51b09..fcb91be9 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Festabookkmp PRODUCT_BUNDLE_IDENTIFIER=com.daedan.festabook.Festabookkmp$(TEAM_ID) -CURRENT_PROJECT_VERSION=20100 -MARKETING_VERSION=2.1.0 \ No newline at end of file +CURRENT_PROJECT_VERSION=20101 +MARKETING_VERSION=2.1.1 \ No newline at end of file From 936929948d26d2f4d9d6e28ba7d93af391e3350d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 30 Apr 2026 22:37:48 +0900 Subject: [PATCH 38/54] =?UTF-8?q?[Feat]=20=ED=8E=98=EC=8A=A4=ED=83=80?= =?UTF-8?q?=ED=8C=85=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Festival 및 FestivalResponse 내 ID 필드 구조 변경 * **refactor: Festival 도메인 모델 및 관련 데이터 클래스 구조 개선** * `FestivalResponse`의 `id` 필드명을 `festivalId`로 변경했습니다. * `Festival` 도메인 모델 및 `FestivalUiModel`에 `id` 필드를 새롭게 추가했습니다. * `FestivalResponse.toDomain()` 매핑 로직에서 `Organization` ID를 임시 하드코딩하고, `Festival` 객체에 서버로부터 받은 `festivalId`를 할당하도록 수정했습니다. * **refactor: UI 및 이벤트 핸들링 로직 수정** * `HomeScreen`의 `FestatingPoster` 클릭 이벤트 발생 시, `organization.id` 대신 `organization.festival.id`를 전달하도록 변경했습니다. * `SettingScreen` 및 `HomeScreen`의 Preview용 Mock 데이터를 업데이트된 모델 구조에 맞게 수정했습니다. * **test: 테스트 코드 및 픽스처 패키지 경로 정리** * `HomeViewModelTest` 내 불필요한 임포트를 정리하고, `toUiModel` 등 확장 함수의 임포트 경로를 수정했습니다. * `HomeTestFixture.kt`의 패키지 경로를 `com.daedan.festabook.home`에서 `com.daedan.festabook.viewModel.home`으로 변경했습니다. * `FestivalRemoteDataSourceTest`의 가상 응답 데이터를 변경된 필드명(`festivalId`)에 맞춰 업데이트했습니다. * feat: 페스타팅 포스터 표시 여부 제어 로직 구현 * **feat: 축제 정보 내 페스타팅 활성화 여부 필드 추가** * `FestivalResponse`, `Festival` 도메인 모델 및 `FestivalUiModel`에 페스타팅 포스터 노출 여부를 결정하는 `festatingVisible` (Boolean) 필드를 추가했습니다. * 서버 응답 데이터를 도메인 및 UI 모델로 변환하는 매핑 로직에 해당 필드를 반영했습니다. * **feat: `HomeScreen` 내 페스타팅 포스터 조건부 렌더링** * `HomeScreen`에서 `festatingVisible` 값에 따라 페스타팅 포스터 및 하단 구분선이 화면에 나타나도록 수정했습니다. * **style: `HomeScreen` 레이아웃 수정** * `Scaffold`의 `contentWindowInsets`를 0으로 설정하여 전체 화면 레이아웃을 조정했습니다. * **test: 프리뷰 및 테스트 데이터 업데이트** * `SettingScreen` 및 `HomeScreen`의 Preview용 더미 데이터에 `festatingVisible` 필드를 추가했습니다. * feat: Festating 기능 구현 및 관련 데이터 구조 개선 * **feat: Festating 도메인 모델 및 UI 모델 추가** * `Festating` 도메인 모델과 `FestatingUiModel`을 정의하고 변환 로직을 구현했습니다. * `FestabookRoute.Festating` 경로 정의에 `organizationId`, `festivalId`, `deviceId` 파라미터를 추가했습니다. * **feat: FestivalRepository 내 Festating 조회 로직 구현** * `FestivalRepository`에 `getFestating` 메서드를 추가했습니다. * `FestivalRepositoryImpl`에서 `DeviceLocalDataSource`를 사용하여 `deviceId`를 가져오고 Festating 객체를 생성하는 로직을 구현했습니다. * **refactor: Festating 화면 및 내비게이션 연동** * `FestatingScreen`에서 URL 파라미터를 통해 `organizationId`, `festivalId`, `deviceId`를 전달하도록 수정했습니다. * `MainScreen` 및 `HomeScreen`에서 Festating 이동 시 `FestatingUiModel` 객체를 전달하도록 변경했습니다. * `FestivalResponse`에서 하드코딩되어 있던 `organizationId`를 서버 응답 값으로 사용하도록 수정했습니다. * **refactor: 홈 화면 상태 관리 개선** * `FestivalUiState.Success`에 `festating` 정보를 포함하도록 구조를 변경했습니다. * `HomeViewModel`에서 축제 정보 조회 시 Festating 정보를 함께 로드하여 UI 상태에 반영하도록 수정했습니다. * **chore: 테스트 및 프리뷰 데이터 업데이트** * `SettingScreen` 및 `HomeScreen` 프리뷰용 샘플 데이터에 Festating 모델을 적용했습니다. * refactor: FestatingScreen URL 생성 로직 개선 * **refactor: URL 빌드 방식 고도화 및 상수화** * URL 쿼리 파라미터 키(`festivalId`, `deviceId`, `organizationId`)를 상수로 정의하여 매직 스트링을 제거했습니다. * 쿼리 파라미터 맵을 받아 문자열로 변환하는 `buildUrl` 헬퍼 함수를 추가했습니다. 해당 함수는 null 값을 필터링하고 조건에 따라 쿼리 스트링을 결합합니다. * `FestatingScreen`에서 하드코딩된 문자열 템플릿 대신 `buildUrl`을 사용하여 `webViewState`를 초기화하도록 수정했습니다. * chore: 버전 변경 2.1.1 [skip ci] * refactor: `FestatingScreen` 내 URL 생성 로직 개선 * **refactor: `buildUrl` 함수 구현 방식 변경** * 기존의 수동 문자열 결합 방식 대신 Ktor의 `URLBuilder`를 사용하여 URL을 생성하도록 수정했습니다. * 쿼리 파라미터 추가 시 null 체크 로직을 유지하면서 코드의 가독성과 안정성을 높였습니다. * chore: ignore iOS configuration file in git * **chore: `.gitignore` 내 iOS 설정 파일 추가** * `/iosApp/Configuration/Config.xcconfig` 경로를 무시하도록 추가했습니다. * feat: Festating 화면 데이터 로드 방식 및 내비게이션 리팩터링 * **feat: `FestatingViewModel` 추가 및 상태 관리 로직 구현** * `FestatingUiState`를 정의하고 `FestatingViewModel`에서 `FestivalRepository`를 통해 데이터를 직접 로드하도록 구현했습니다. * `FestatingScreen`에서 `FestatingViewModel`을 사용하여 UI 상태(Loading, Success, Error)에 따른 화면을 렌더링하도록 수정했습니다. * **refactor: `FestivalRepository` 데이터 로드 및 캐싱 로직 개선** * `FestivalRepository.getFestating()` 메서드에서 파라미터를 제거하고, 내부적으로 캐싱된 `Organization` 정보를 활용하거나 새로 조회하도록 변경했습니다. * `FestivalRepositoryImpl`에 `cachedOrganization` 변수를 추가하여 축제 정보 조회 시 데이터를 캐싱합니다. * **refactor: Festating 내비게이션 및 데이터 전달 방식 간소화** * `FestabookRoute.Festating`을 `data class`에서 `data object`로 변경하여 내비게이션 시 인자를 전달하지 않도록 수정했습니다. * `MainScreen`, `HomeScreen` 등 관련 컴포저블에서 `onNavigateToFestating` 호출 시 객체를 전달하던 로직을 제거했습니다. * **refactor: `HomeViewModel` 및 UI 상태 모델 정리** * `FestivalUiState.Success`에서 더 이상 사용하지 않는 `festating` 필드를 제거했습니다. * `HomeViewModel`의 축제 정보 로드 로직에서 `getFestating` 호출부를 삭제했습니다. * **chore: 기타 UI 및 코드 정리** * `SettingScreen` 내 불필요한 테스트용 더미 데이터 생성을 제거했습니다. * `FestatingScreen`에 WebView 탐색을 위한 `NavigationBackHandler` 및 오류/로딩 화면을 적용했습니다. * refactor: Repository 구현체에 싱글톤 스코프 적용 및 코드 포맷팅 * **refactor: 모든 Repository 구현체에 `@SingleIn(AppScope::class)` 어노테이션 추가** * 의존성 주입 시 싱글톤 인스턴스를 보장하기 위해 `FestivalNotificationRepositoryImpl`, `ScheduleRepositoryImpl`, `PlaceDetailRepositoryImpl`, `DeviceRepositoryImpl`, `FestivalRepositoryImpl` 등 모든 Repository 구현 클래스에 `@SingleIn` 설정을 추가했습니다. * **style: 코드 포맷팅 및 가독성 개선** * `MyWaitingRepositoryImpl.kt`: `getMyWaiting` 메서드 내 체이닝된 호출 로직의 줄바꿈을 조정했습니다. * `WaitingInfoRepositoryImpl.kt`: `registerWaiting` 메서드 내 `when` 절의 `is ApiResult.ClientError` 및 `else` 분기에 중괄호를 추가하여 가독성을 개선했습니다. --- .gitignore | 1 + .../response/festival/FestivalResponse.kt | 10 ++- .../data/repository/DeviceRepositoryImpl.kt | 2 + .../data/repository/ExploreRepositoryImpl.kt | 2 + .../data/repository/FAQRepositoryImpl.kt | 2 + .../FestivalNotificationRepositoryImpl.kt | 2 + .../data/repository/FestivalRepositoryImpl.kt | 27 +++++- .../data/repository/LostItemRepositoryImpl.kt | 2 + .../repository/MyWaitingRepositoryImpl.kt | 8 +- .../data/repository/NoticeRepositoryImpl.kt | 2 + .../repository/PlaceDetailRepositoryImpl.kt | 2 + .../repository/PlaceListRepositoryImpl.kt | 2 + .../data/repository/ScheduleRepositoryImpl.kt | 2 + .../repository/WaitingInfoRepositoryImpl.kt | 11 ++- .../WaitingRegisterInfoRepositoryImpl.kt | 2 + .../festabook/domain/model/Festating.kt | 7 ++ .../daedan/festabook/domain/model/Festival.kt | 2 + .../domain/repository/FestivalRepository.kt | 3 + .../festating/FestatingUiState.kt | 15 ++++ .../festating/FestatingViewModel.kt | 42 +++++++++ .../festating/component/FestatingScreen.kt | 86 ++++++++++++++----- .../navigation/FestatingNavigation.kt | 6 +- .../presentation/home/HomeViewModel.kt | 3 +- .../presentation/home/component/HomeScreen.kt | 49 ++++++----- .../home/model/FestatingUiModel.kt | 16 ++++ .../home/model/FestivalUiModel.kt | 4 + .../home/navigation/HomeNavigation.kt | 2 +- .../presentation/main/FestabookRoute.kt | 4 +- .../presentation/main/component/MainScreen.kt | 4 +- .../setting/component/SettingScreen.kt | 3 + .../festival/FestivalRemoteDataSourceTest.kt | 2 +- .../viewModel/home/HomeTestFixture.kt | 3 +- .../viewModel/home/HomeViewModelTest.kt | 1 + 33 files changed, 265 insertions(+), 64 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festating.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestatingUiModel.kt diff --git a/.gitignore b/.gitignore index 80356aa3..5d316ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ iosApp/iosApp/GoogleService-Info.plist /iosApp/Configuration/Debug.xcconfig /iosApp/Configuration/Release.xcconfig +/iosApp/Configuration/Config.xcconfig .worktrees/ # Fastlane diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt index 7ecbb29b..bf873067 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt @@ -9,10 +9,10 @@ import kotlinx.serialization.Serializable @Serializable data class FestivalResponse( - @SerialName("festivalId") - val id: Long, @SerialName("organizationId") val organizationId: Long, + @SerialName("festivalId") + val festivalId: Long, @SerialName("organizationName") val organizationName: String, @SerialName("festivalImages") @@ -23,6 +23,8 @@ data class FestivalResponse( val startDate: String, @SerialName("endDate") val endDate: String, + @SerialName("festatingVisible") + val festatingVisible: Boolean, @SerialName("festivalSponsors") val festivalSponsors: List, @SerialName("instagramLink") @@ -43,14 +45,16 @@ data class FestivalResponse( fun FestivalResponse.toDomain() = Organization( - id = id, + id = organizationId, organizationName = organizationName, festival = Festival( + id = festivalId, festivalImages = festivalImages.map { it.toDomain() }, festivalName = festivalName, startDate = startDate.toLocalDate(), endDate = endDate.toLocalDate(), + festatingVisible = festatingVisible, sponsors = festivalSponsors.map { it.toDomain() }, instagramLink = instagramLink, homepageLink = homepageLink, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt index f233372e..9b19305e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt @@ -10,6 +10,7 @@ import com.daedan.festabook.domain.repository.DeviceRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class DeviceRepositoryImpl( private val deviceRemoteDataSource: DeviceRemoteDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt index f8a44b9a..f32bf53e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt @@ -12,11 +12,13 @@ import com.daedan.festabook.domain.repository.ExploreRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class ExploreRepositoryImpl( private val festivalRemoteDataSource: FestivalRemoteDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt index 070aff6d..5dce3863 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt @@ -8,8 +8,10 @@ import com.daedan.festabook.domain.repository.FAQRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class FAQRepositoryImpl( private val faqDataSource: FAQDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt index 3409856b..4d3f2b20 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt @@ -10,6 +10,7 @@ import com.daedan.festabook.domain.repository.FestivalNotificationRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class FestivalNotificationRepositoryImpl( private val festivalNotificationRemoteDataSource: FestivalNotificationRemoteDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt index 6f3220de..18668a82 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt @@ -1,30 +1,40 @@ package com.daedan.festabook.data.repository +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.data.datasource.remote.festival.FestivalRemoteDataSource import com.daedan.festabook.data.datasource.remote.lineup.LineupDataSource import com.daedan.festabook.data.model.response.festival.toDomain import com.daedan.festabook.data.model.response.lineup.toDomain import com.daedan.festabook.data.util.toResult +import com.daedan.festabook.domain.model.Festating import com.daedan.festabook.domain.model.LineupItem import com.daedan.festabook.domain.model.Organization import com.daedan.festabook.domain.repository.FestivalRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.datetime.LocalDate @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class FestivalRepositoryImpl( private val festivalRemoteDataSource: FestivalRemoteDataSource, private val festivalLocalDataSource: FestivalLocalDataSource, private val lineupDataSource: LineupDataSource, + private val deviceLocalDataSource: DeviceLocalDataSource, ) : FestivalRepository { + private var cachedOrganization: Organization? = null + override suspend fun getFestivalInfo(): Result { val response = festivalRemoteDataSource.fetchFestival().toResult() - return response.mapCatching { it.toDomain() } + return response.mapCatching { response -> + response.toDomain().also { cachedOrganization = it } + } } override suspend fun getLineUpGroupByDate(): Result>> { @@ -37,4 +47,19 @@ class FestivalRepositoryImpl( } override fun getIsFirstVisit(): Flow = festivalLocalDataSource.getIsFirstVisit() + + override suspend fun getFestating(): Result = + runCatching { + val organization = cachedOrganization ?: getFestivalInfo().getOrThrow() + + val deviceId = + deviceLocalDataSource.getDeviceId().firstOrNull() + ?: throw IllegalStateException("deviceId 없음") + + Festating( + organizationId = organization.id, + festivalId = organization.festival.id, + deviceId = deviceId, + ) + } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt index 10a3356f..82affbda 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt @@ -9,10 +9,12 @@ import com.daedan.festabook.domain.repository.LostItemRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class LostItemRepositoryImpl( private val lostItemDataSource: LostItemDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt index dc62e0de..5fe58a8f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/MyWaitingRepositoryImpl.kt @@ -9,14 +9,20 @@ import com.daedan.festabook.domain.repository.MyWaitingRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class MyWaitingRepositoryImpl( private val waitingRemoteDataSource: WaitingRemoteDataSource, ) : MyWaitingRepository { override suspend fun getMyWaiting(): Result { - val existResult = waitingRemoteDataSource.fetchMyWaitingExist().toResult().getOrElse { return Result.failure(it) } + val existResult = + waitingRemoteDataSource + .fetchMyWaitingExist() + .toResult() + .getOrElse { return Result.failure(it) } if (!existResult.exists) { return Result.success(null) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt index c2ef585d..2df028e7 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt @@ -8,8 +8,10 @@ import com.daedan.festabook.domain.repository.NoticeRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class NoticeRepositoryImpl( private val noticeDataSource: NoticeDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt index eb0fda18..8649c0e8 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt @@ -8,8 +8,10 @@ import com.daedan.festabook.domain.repository.PlaceDetailRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class PlaceDetailRepositoryImpl( private val placeDataSource: PlaceDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt index ac2631f7..015a1037 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt @@ -12,8 +12,10 @@ import com.daedan.festabook.domain.repository.PlaceListRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class PlaceListRepositoryImpl( private val placeDataSource: PlaceDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt index eb8a2ca7..a4b66679 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt @@ -9,8 +9,10 @@ import com.daedan.festabook.domain.repository.ScheduleRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class ScheduleRepositoryImpl( private val scheduleDataSource: ScheduleDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt index 0a9f2a41..13d07770 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingInfoRepositoryImpl.kt @@ -10,9 +10,11 @@ import com.daedan.festabook.domain.repository.WaitingInfoRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.firstOrNull @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class WaitingInfoRepositoryImpl( private val deviceRemoteDataSource: DeviceRemoteDataSource, @@ -51,7 +53,8 @@ class WaitingInfoRepositoryImpl( waitingLocalDataSource.savePhoneNumber(phoneNumber) Result.success(Unit) } - is ApiResult.ClientError -> + + is ApiResult.ClientError -> { if (apiResult.code == HTTP_CONFLICT) { // 이미 등록된 번호 — 서버 상태와 로컬 캐시 동기화 waitingLocalDataSource.savePhoneNumber(phoneNumber) @@ -59,7 +62,11 @@ class WaitingInfoRepositoryImpl( } else { apiResult.toResult() } - else -> apiResult.toResult() + } + + else -> { + apiResult.toResult() + } } companion object { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt index 77bdcb73..d749ac1d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/WaitingRegisterInfoRepositoryImpl.kt @@ -10,8 +10,10 @@ import com.daedan.festabook.domain.repository.WaitingRegisterInfoRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) @Inject class WaitingRegisterInfoRepositoryImpl( private val waitingRemoteDataSource: WaitingRemoteDataSource, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festating.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festating.kt new file mode 100644 index 00000000..6a77a5e2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festating.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.domain.model + +data class Festating( + val organizationId: Long, + val festivalId: Long, + val deviceId: Long, +) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt index d5363af6..2313d02a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/model/Festival.kt @@ -3,10 +3,12 @@ package com.daedan.festabook.domain.model import kotlinx.datetime.LocalDate data class Festival( + val id: Long, val festivalName: String, val festivalImages: List, val startDate: LocalDate, val endDate: LocalDate, + val festatingVisible: Boolean, val sponsors: List, val instagramLink: String?, val homepageLink: String?, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalRepository.kt index 24bb6773..a5c1c789 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/FestivalRepository.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.domain.repository +import com.daedan.festabook.domain.model.Festating import com.daedan.festabook.domain.model.LineupItem import com.daedan.festabook.domain.model.Organization import kotlinx.coroutines.flow.Flow @@ -11,4 +12,6 @@ interface FestivalRepository { suspend fun getLineUpGroupByDate(): Result>> fun getIsFirstVisit(): Flow + + suspend fun getFestating(): Result } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingUiState.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingUiState.kt new file mode 100644 index 00000000..f8cafd96 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingUiState.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.festating + +import com.daedan.festabook.presentation.home.model.FestatingUiModel + +sealed interface FestatingUiState { + data object Loading : FestatingUiState + + data class Success( + val festating: FestatingUiModel, + ) : FestatingUiState + + data class Error( + val throwable: Throwable, + ) : FestatingUiState +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingViewModel.kt new file mode 100644 index 00000000..9c7a588b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/FestatingViewModel.kt @@ -0,0 +1,42 @@ +package com.daedan.festabook.presentation.festating + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.domain.repository.FestivalRepository +import com.daedan.festabook.presentation.home.model.toUiModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@ContributesIntoMap(AppScope::class) +@ViewModelKey(FestatingViewModel::class) +@Inject +class FestatingViewModel( + private val festivalRepository: FestivalRepository, +) : ViewModel() { + private val _festatingUiState = MutableStateFlow(FestatingUiState.Loading) + val festatingUiState: StateFlow = _festatingUiState.asStateFlow() + + init { + loadFestating() + } + + private fun loadFestating() { + viewModelScope.launch { + _festatingUiState.value = FestatingUiState.Loading + + val result = festivalRepository.getFestating() + result + .onSuccess { + _festatingUiState.value = FestatingUiState.Success(it.toUiModel()) + }.onFailure { + _festatingUiState.value = FestatingUiState.Error(it) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt index 072b7adc..c1118884 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt @@ -4,48 +4,92 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import com.daedan.festabook.BuildKonfig +import com.daedan.festabook.presentation.common.component.ErrorStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.festating.FestatingUiState +import com.daedan.festabook.presentation.festating.FestatingViewModel import com.multiplatform.webview.web.WebView import com.multiplatform.webview.web.WebViewState import com.multiplatform.webview.web.rememberWebViewNavigator import com.multiplatform.webview.web.rememberWebViewState +import dev.zacsweers.metrox.viewmodel.metroViewModel +import io.github.aakira.napier.Napier +import io.ktor.http.URLBuilder -private const val HEADER_FESTIVAL_ID = "festivalId" +private const val FESTIVAL_ID_QUERY_KEY = "festivalId" +private const val DEVICE_ID_QUERY_KEY = "deviceId" +private const val ORGANIZATION_ID_QUERY_KEY = "organizationId" @Composable fun FestatingScreen( - festivalId: Long, modifier: Modifier = Modifier, + festatingViewModel: FestatingViewModel = metroViewModel(), ) { - val webViewState = - rememberConfiguredWebViewState( - url = BuildKonfig.FESTA_TING_URL, - headers = mapOf(HEADER_FESTIVAL_ID to festivalId.toString()), - ) - val backState = rememberNavigationEventState(NavigationEventInfo.None) - val navigator = rememberWebViewNavigator() + val festatingUiState by festatingViewModel.festatingUiState.collectAsStateWithLifecycle() - NavigationBackHandler(state = backState, isBackEnabled = navigator.canGoBack) { - navigator.navigateBack() - } + when (val uiState = festatingUiState) { + is FestatingUiState.Error -> { + Napier.e(uiState.throwable.stackTraceToString()) + ErrorStateScreen(modifier = modifier) + } - Scaffold(modifier = modifier) { innerPadding -> - WebView( - state = webViewState, - navigator = navigator, - modifier = - Modifier - .padding(innerPadding) - .fillMaxSize(), - ) + FestatingUiState.Loading -> { + LoadingStateScreen(modifier = modifier) + } + + is FestatingUiState.Success -> { + val url = + buildUrl( + BuildKonfig.FESTA_TING_URL, + mapOf( + FESTIVAL_ID_QUERY_KEY to uiState.festating.festivalId, + DEVICE_ID_QUERY_KEY to uiState.festating.deviceId, + ORGANIZATION_ID_QUERY_KEY to uiState.festating.organizationId, + ), + ) + val webViewState = rememberConfiguredWebViewState(url = url) + val backState = rememberNavigationEventState(NavigationEventInfo.None) + val navigator = rememberWebViewNavigator() + + NavigationBackHandler(state = backState, isBackEnabled = navigator.canGoBack) { + navigator.navigateBack() + } + + Scaffold(modifier = modifier) { innerPadding -> + WebView( + state = webViewState, + navigator = navigator, + modifier = + Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } } } +private fun buildUrl( + base: String, + params: Map, +): String = + URLBuilder(base) + .apply { + params.forEach { (key, value) -> + if (value != null) { + parameters.append(key, value.toString()) + } + } + }.buildString() + @Composable private fun rememberConfiguredWebViewState( url: String, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt index 2df14f2a..5443d527 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/navigation/FestatingNavigation.kt @@ -2,13 +2,11 @@ package com.daedan.festabook.presentation.festating.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import androidx.navigation.toRoute import com.daedan.festabook.presentation.festating.component.FestatingScreen import com.daedan.festabook.presentation.main.FestabookRoute fun NavGraphBuilder.festatingNavGraph() { - composable { backStackEntry -> - val festivalId = backStackEntry.toRoute().festivalId - FestatingScreen(festivalId = festivalId) + composable { + FestatingScreen() } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt index 42f60221..0d6cd83b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -54,7 +54,8 @@ class HomeViewModel( val result = festivalRepository.getFestivalInfo() result .onSuccess { organization -> - _festivalUiState.value = FestivalUiState.Success(organization.toUiModel()) + _festivalUiState.value = + FestivalUiState.Success(organization = organization.toUiModel()) }.onFailure { _festivalUiState.value = FestivalUiState.Error(it) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 31151ab2..71b716d2 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.home.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -57,7 +58,7 @@ fun HomeScreen( settingViewModel: SettingViewModel, notificationPermissionManager: NotificationPermissionManager, onNavigateToExplore: () -> Unit, - onNavigateToFestating: (festivalId: Long) -> Unit, + onNavigateToFestating: () -> Unit, onNavigateToMyWaiting: () -> Unit, onShowSnackBar: (String) -> Unit, onShowErrorSnackbar: (Throwable) -> Unit, @@ -121,7 +122,7 @@ fun HomeScreen( showWaitingBar = waitingBarUiState is WaitingBarUiState.Visible, onNavigateToExplore = onNavigateToExplore, onNavigateToSchedule = homeViewModel::navigateToScheduleClick, - onFestatingClick = { festivalId -> onNavigateToFestating(festivalId) }, + onFestatingClick = onNavigateToFestating, ) when (val waitingBarUiState = waitingBarUiState) { is WaitingBarUiState.Visible -> { @@ -156,7 +157,7 @@ private fun HomeContent( showWaitingBar: Boolean, onNavigateToExplore: () -> Unit, onNavigateToSchedule: () -> Unit, - onFestatingClick: (festivalId: Long) -> Unit, + onFestatingClick: () -> Unit, modifier: Modifier = Modifier, ) { val universityName = festivalUiState.organization.organizationName @@ -164,6 +165,7 @@ private fun HomeContent( Scaffold( modifier = modifier.fillMaxSize(), containerColor = FestabookColor.white, + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { HomeHeader( universityName = universityName, @@ -227,26 +229,27 @@ private fun HomeContent( ) } // 페스타팅 포스터 - // TODO if문 묶기 - item { - FestatingPoster( - onClick = { onFestatingClick(festivalUiState.organization.id) }, - modifier = - Modifier - .fillMaxSize() - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingBody5, - ), - ) - } + if (festivalUiState.organization.festival.festatingVisible) { + item { + FestatingPoster( + onClick = onFestatingClick, + modifier = + Modifier + .fillMaxSize() + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingBody5, + ), + ) + } - // 구분선 - item { - HorizontalDivider( - thickness = 4.dp, - color = FestabookColor.gray200, - ) + // 구분선 + item { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, + ) + } } // 라인업 헤더 @@ -338,6 +341,7 @@ private fun FestivalOverviewPreview() { organizationName = "가천대학교", festival = FestivalUiModel( + id = 1L, festivalName = "2025 가천 Water Festival\n: AQUA WAVE", startDate = Clock.System.todayIn(TimeZone.currentSystemDefault()), endDate = @@ -349,6 +353,7 @@ private fun FestivalOverviewPreview() { FestivalPosterUiModel(1, "sample", 1), FestivalPosterUiModel(2, "sample", 2), ), + festatingVisible = true, sponsors = emptyList(), instagramLink = null, homepageLink = null, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestatingUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestatingUiModel.kt new file mode 100644 index 00000000..05d829b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestatingUiModel.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.presentation.home.model + +import com.daedan.festabook.domain.model.Festating + +data class FestatingUiModel( + val organizationId: Long, + val festivalId: Long, + val deviceId: Long, +) + +fun Festating.toUiModel(): FestatingUiModel = + FestatingUiModel( + organizationId = organizationId, + festivalId = festivalId, + deviceId = deviceId, + ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt index fdf66dbb..17b03a32 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/model/FestivalUiModel.kt @@ -4,10 +4,12 @@ import com.daedan.festabook.domain.model.Festival import kotlinx.datetime.LocalDate data class FestivalUiModel( + val id: Long, val festivalName: String, val festivalImages: List, val startDate: LocalDate, val endDate: LocalDate, + val festatingVisible: Boolean, val sponsors: List, val instagramLink: String?, val homepageLink: String?, @@ -15,10 +17,12 @@ data class FestivalUiModel( fun Festival.toUiModel(): FestivalUiModel = FestivalUiModel( + id = id, festivalName = festivalName, festivalImages = festivalImages.map { it.toUiModel() }, startDate = startDate, endDate = endDate, + festatingVisible = festatingVisible, sponsors = sponsors.map { it.toUiModel() }, instagramLink = instagramLink, homepageLink = homepageLink, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt index 4105963d..bfe0db57 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -27,7 +27,7 @@ fun NavGraphBuilder.homeNavGraph( onShowErrorSnackbar: (Throwable) -> Unit, onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, - onNavigateToFestating: (festivalId: Long) -> Unit, + onNavigateToFestating: () -> Unit, onNavigateToMyWaiting: () -> Unit, ) { composable( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt index b8412147..5a922f10 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -28,9 +28,7 @@ sealed interface FestabookRoute { ) : FestabookRoute @Serializable - data class Festating( - val festivalId: Long, - ) : FestabookRoute + data object Festating : FestabookRoute @Serializable data class AddWaitingInfo( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index 850f2ce4..c51d38c4 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -287,9 +287,7 @@ private fun FestabookNavHost( homeViewModel = homeViewModel, mainViewModel = mainViewModel, onNavigateToExplore = { festabookNavigator.navigate(FestabookRoute.Explore) }, - onNavigateToFestating = { festivalId -> - festabookNavigator.navigate(FestabookRoute.Festating(festivalId)) - }, + onNavigateToFestating = { festabookNavigator.navigate(FestabookRoute.Festating) }, onSubscriptionConfirm = { settingViewModel.notificationAllowClick() mainViewModel.declineAlert() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 1cf21b01..8e64ad58 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -38,6 +38,7 @@ import com.daedan.festabook.presentation.common.component.FestabookSwitch import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.model.FestatingUiModel import com.daedan.festabook.presentation.home.model.FestivalUiModel import com.daedan.festabook.presentation.home.model.OrganizationUiModel import com.daedan.festabook.presentation.setting.SettingViewModel @@ -465,10 +466,12 @@ private fun SettingScreenPreview() { organizationName = "성균관대학교 인문사회과학철학문학자연캠퍼스 인문사회과학철학문학자연캠퍼스", festival = FestivalUiModel( + id = 1, festivalName = "성균관대학교 축제축제축제축제축제축제축제축제축제축제축제축제", festivalImages = listOf(), startDate = LocalDate(2026, 1, 1), endDate = LocalDate(2026, 1, 2), + festatingVisible = true, sponsors = emptyList(), instagramLink = null, homepageLink = null, diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt index 596a33d3..45ca8eaf 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt @@ -17,7 +17,7 @@ import kotlin.test.assertEquals private val FAKE_FESTIVAL_RESPONSE = FestivalResponse( - id = 1L, + festivalId = 1L, organizationName = "대학교", festivalImages = listOf( diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt index 7877e760..2dd0550c 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.home +package com.daedan.festabook.viewModel.home import com.daedan.festabook.domain.model.Festival import com.daedan.festabook.domain.model.LineupItem @@ -13,6 +13,7 @@ val FAKE_ORGANIZATION = organizationName = "하버드 대학교", festival = Festival( + id = 1, festivalName = "하버드 대동제", festivalImages = listOf( diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt index 5f49110a..075b8683 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt @@ -4,6 +4,7 @@ import com.daedan.festabook.domain.model.MyWaiting import com.daedan.festabook.domain.model.WaitingStatus import com.daedan.festabook.domain.repository.FestivalRepository import com.daedan.festabook.domain.repository.MyWaitingRepository +import com.daedan.festabook.domain.repository.MyWaitingRepository import com.daedan.festabook.home.FAKE_LINEUP import com.daedan.festabook.home.FAKE_ORGANIZATION import com.daedan.festabook.presentation.home.FestivalUiState From 6499c5343f9b04e119815426ec3b56576249c7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 30 Apr 2026 22:50:53 +0900 Subject: [PATCH 39/54] =?UTF-8?q?[Feat]=20=EB=82=9C=EB=8F=85=ED=99=94=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: release 빌드 시 Proguard 및 리소스 축소 활성화 * **chore: release 빌드 최적화 설정 적용** * `composeApp/build.gradle.kts` 내 release 빌드 블록에서 주석 처리되어 있던 `isMinifyEnabled`, `isShrinkResources`, `proguardFiles` 설정을 활성화하여 코드 난독화 및 리소스 최적화가 이루어지도록 수정했습니다. * feat: CoroutineScope DI 바인딩 및 한정자 추가 * **feat: Coroutine 전용 DI 바인딩 및 Qualifier 추가** * `CoroutineBindings`를 추가하여 `IO`, `Main`, `Default` 디스패처 기반의 `CoroutineScope`를 `AppScope` 수준에서 제공하도록 설정했습니다. * `@IO`, `@Main`, `@Default` 한정자(Qualifier) 어노테이션을 정의하여 목적에 맞는 스코프 주입이 가능하도록 개선했습니다. * **refactor: CoroutineScope 주입 방식 고도화** * `DeviceRepositoryImpl`, `DefaultUserNotificationDelegate`, `DefaultFirebaseMessagingDelegate` 등 주요 클래스에서 `@IO` 한정자를 사용하여 특정 `CoroutineScope`를 주입받도록 수정했습니다. * `FestabookApp.kt` 내 `applicationScope`에 `@IO` 어노테이션을 적용했습니다. * `AndroidAppGraph` 및 `MyFirebaseMessagingService`에서 사용되던 기존 `ioCoroutineScope` 명칭을 한정자가 적용된 `coroutineScope`로 변경했습니다. * **chore: 기타 코드 정리** * `FestabookAppGraph`에 직접 정의되어 있던 `CoroutineScope` 제공 로직을 제거하고 `CoroutineBindings`로 이관했습니다. * `DefaultUserNotificationDelegate` 내 불필요한 공백 수정 및 코드 가독성을 개선했습니다. * [Build] iOS 빌드 설정 내 심볼 스트립(Strip) 옵션 수정 * **build: Release 및 Debug 환경별 바이너리 최적화 설정 변경** * `project.pbxproj`: Release 빌드 시 바이너리 크기 최적화 및 보안을 위해 `COPY_PHASE_STRIP`, `DEPLOYMENT_POSTPROCESSING`, `STRIP_INSTALLED_PRODUCT` 옵션을 `YES`로 활성화했습니다. * `project.pbxproj`: Debug 빌드 시 디버깅 편의를 위해 `STRIP_INSTALLED_PRODUCT`를 `NO`로 설정했습니다. --- composeApp/build.gradle.kts | 12 ++++----- composeApp/proguard-rules.pro | 0 .../com/daedan/festabook/FestabookApp.kt | 2 ++ .../daedan/festabook/di/AndroidAppGraph.kt | 4 ++- .../service/MyFirebaseMessagingService.kt | 2 +- .../data/repository/DeviceRepositoryImpl.kt | 3 ++- .../daedan/festabook/di/FestabookAppGraph.kt | 11 -------- .../di/coroutine/CoroutineBindings.kt | 26 +++++++++++++++++++ .../daedan/festabook/di/coroutine/Default.kt | 6 +++++ .../com/daedan/festabook/di/coroutine/IO.kt | 6 +++++ .../com/daedan/festabook/di/coroutine/Main.kt | 6 +++++ .../DefaultFirebaseMessagingDelegate.kt | 3 ++- .../DefaultUserNotificationDelegate.kt | 15 +++++++---- iosApp/iosApp.xcodeproj/project.pbxproj | 5 ++++ 14 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 composeApp/proguard-rules.pro create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/CoroutineBindings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Default.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/IO.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Main.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1c76a93e..7d5af2f7 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -231,12 +231,12 @@ android { } release { -// isMinifyEnabled = true -// isShrinkResources = true -// proguardFiles( -// getDefaultProguardFile("proguard-android-optimize.txt"), -// "proguard-rules.pro", -// ) + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) resValue("string", "app_name", "Festabook") signingConfig = signingConfigs["release"] } diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt index 0468bcc0..33a3daee 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/FestabookApp.kt @@ -3,6 +3,7 @@ package com.daedan.festabook import android.app.Application import androidx.appcompat.app.AppCompatDelegate import com.daedan.festabook.di.AndroidAppGraph +import com.daedan.festabook.di.coroutine.IO import com.daedan.festabook.domain.repository.DeviceRepository import com.daedan.festabook.presentation.error.FestabookGlobalExceptionHandler import com.daedan.festabook.service.NotificationHelper @@ -29,6 +30,7 @@ class FestabookApp : Application() { private lateinit var deviceRepository: DeviceRepository @Inject + @IO private lateinit var applicationScope: CoroutineScope override fun onCreate() { diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt index 7d6b3d52..070672e8 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import com.daedan.festabook.FestabookApp import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.di.coroutine.IO import com.daedan.festabook.presentation.splash.platform.AppVersionManager import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -19,7 +20,8 @@ interface AndroidAppGraph : FestabookAppGraph { val festivalLocalDataSource: FestivalLocalDataSource - val ioCoroutineScope: CoroutineScope + @IO + val coroutineScope: CoroutineScope @DependencyGraph.Factory fun interface Factory { diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt index 0a477aa3..5faf62fc 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt @@ -20,7 +20,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } private val ioCoroutineScope: CoroutineScope by lazy { - application.androidAppGraph.ioCoroutineScope + application.androidAppGraph.coroutineScope } override fun onNewToken(token: String) { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt index 9b19305e..8638e535 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.daedan.festabook.data.datasource.remote.device.DeviceRemoteDataSource import com.daedan.festabook.data.util.randomUUID import com.daedan.festabook.data.util.toResult import com.daedan.festabook.data.util.withTimeoutOrNullFallback +import com.daedan.festabook.di.coroutine.IO import com.daedan.festabook.domain.repository.DeviceRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -26,7 +27,7 @@ class DeviceRepositoryImpl( private val deviceRemoteDataSource: DeviceRemoteDataSource, private val deviceLocalDataSource: DeviceLocalDataSource, private val fcmDataSource: FcmDataSource, - coroutineScope: CoroutineScope, + @IO coroutineScope: CoroutineScope, ) : DeviceRepository { private val cachedFcmToken: StateFlow = fcmDataSource.getFcmToken().stateIn( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt index 3cb61916..eff253d0 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt @@ -1,20 +1,9 @@ package com.daedan.festabook.di import com.daedan.festabook.presentation.NotificationPermissionManager -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Provides -import dev.zacsweers.metro.SingleIn import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.SupervisorJob interface FestabookAppGraph { val metroViewModelFactory: MetroViewModelFactory val notificationPermissionManagerFactory: NotificationPermissionManager.Factory - - @Provides - @SingleIn(AppScope::class) - fun provideIOCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/CoroutineBindings.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/CoroutineBindings.kt new file mode 100644 index 00000000..04bd86a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/CoroutineBindings.kt @@ -0,0 +1,26 @@ +package com.daedan.festabook.di.coroutine + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob + +@BindingContainer +@ContributesTo(AppScope::class) +object CoroutineBindings { + @Provides + @IO + fun provideIOCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Provides + @Main + fun provideMainCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + @Provides + @Default + fun provideDefaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Default.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Default.kt new file mode 100644 index 00000000..235ae3c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Default.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.coroutine + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class Default diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/IO.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/IO.kt new file mode 100644 index 00000000..cb86daf4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/IO.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.coroutine + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class IO diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Main.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Main.kt new file mode 100644 index 00000000..e0b61454 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/coroutine/Main.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.coroutine + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class Main diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt index 0bf9527f..02642117 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultFirebaseMessagingDelegate.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.delegate import cocoapods.FirebaseMessaging.FIRMessaging import cocoapods.FirebaseMessaging.FIRMessagingDelegateProtocol +import com.daedan.festabook.di.coroutine.IO import com.daedan.festabook.domain.repository.DeviceRepository import dev.zacsweers.metro.Inject import io.github.aakira.napier.Napier @@ -14,7 +15,7 @@ import platform.darwin.NSObject @OptIn(ExperimentalForeignApi::class, FlowPreview::class) @Inject class DefaultFirebaseMessagingDelegate( - private val scope: CoroutineScope, + @param:IO private val scope: CoroutineScope, private val deviceRepository: DeviceRepository, ) : NSObject(), FIRMessagingDelegateProtocol { diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt index a68bf931..b873c3ab 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.delegate import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.di.coroutine.IO import com.daedan.festabook.presentation.platform.DeepLinkKeys import com.daedan.festabook.presentation.platform.FcmDeepLinkAction import com.daedan.festabook.presentation.platform.FcmMessageType @@ -23,7 +24,7 @@ import platform.darwin.NSObject @Inject class DefaultUserNotificationDelegate( private val festivalLocalDataSource: FestivalLocalDataSource, - private val ioCoroutineScope: CoroutineScope, + @param:IO private val coroutineScope: CoroutineScope, ) : NSObject(), UNUserNotificationCenterDelegateProtocol { override fun userNotificationCenter( @@ -52,7 +53,7 @@ class DefaultUserNotificationDelegate( return } - ioCoroutineScope.launch { + coroutineScope.launch { val currentFestivalId = festivalLocalDataSource.getFestivalId().firstOrNull() val festivalIdChanged = newFestivalId != DeepLinkKeys.INITIALIZED_ID && newFestivalId != currentFestivalId @@ -76,16 +77,20 @@ class DefaultUserNotificationDelegate( return when (type) { FcmMessageType.WAITING_CALL, FcmMessageType.WAITING_ALMOST_CALL, - -> FcmDeepLinkAction.OpenMyWaiting + -> { + FcmDeepLinkAction.OpenMyWaiting + } FcmMessageType.WAITING_PLACE_ACCESS_CANCEL -> { - val placeId = (this[DeepLinkKeys.KEY_PLACE_ID] as? String)?.toLongOrNull() ?: return null + val placeId = + (this[DeepLinkKeys.KEY_PLACE_ID] as? String)?.toLongOrNull() ?: return null FcmDeepLinkAction.OpenPlaceDetail(placeId) } FcmMessageType.ANNOUNCEMENT, null -> { val announcementId = - (this[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String)?.toLongOrNull() ?: return null + (this[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String)?.toLongOrNull() + ?: return null FcmDeepLinkAction.OpenAnnouncement(announcementId) } } diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index fa578257..0854f9d8 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -376,6 +376,7 @@ PRODUCT_BUNDLE_IDENTIFIER = debug.eoehdeksruf.festabook; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_distribution_debug_provisioning_profile; + STRIP_INSTALLED_PRODUCT = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -417,6 +418,7 @@ PRODUCT_BUNDLE_IDENTIFIER = debug.eoehdeksruf.festabook; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_distribution_debug_provisioning_profile; + STRIP_INSTALLED_PRODUCT = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -493,6 +495,8 @@ CODE_SIGN_ENTITLEMENTS = iosApp/iosAppRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = YES; + DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = "${TEAM_ID}"; ENABLE_DEBUG_DYLIB = NO; @@ -512,6 +516,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = eoehdeksruf.festabook; + STRIP_INSTALLED_PRODUCT = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; From 377fcc5f653cf629e475203744a07b77bd40bff9 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:27:12 +0900 Subject: [PATCH 40/54] =?UTF-8?q?[Fix]=20=EC=97=90=EB=9F=AC=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=99=A9=EC=97=90=EC=84=9C=20EmptyScreen=EC=9D=84=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EA=B3=B3=20=EB=B3=80=EA=B2=BD=20=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 에러 상태 및 빈 화면 레이아웃 공통 컴포넌트 적용 및 개선 * **refactor: `ErrorStateScreen` 도입을 통한 에러 화면 처리 통일** * `HomeScreen`, `PlaceListScreen`, `PlaceDetailScreen`에서 기존에 개별적으로 처리하거나 `EmptyStateScreen`을 오용하던 에러 상태 UI를 공통 컴포넌트인 `ErrorStateScreen`으로 교체했습니다. * `HomeScreen`의 경우, 인라인으로 작성된 텍스트 출력 로직을 제거하고 공통 에러 컴포넌트를 사용하도록 구조를 개선했습니다. * **refactor: `PlaceListScreen` 에러 처리 로직 수정** * 리스트 로드 실패 시(`ListLoadState.Error`) 기존에 빈 화면(`EmptyStateScreen`)을 보여주던 로직을 에러 화면(`ErrorStateScreen`)으로 변경하여 상태 의미에 맞게 수정했습니다. * **style: `LostItemScreen` 내 빈 화면 레이아웃 개선** * 분실물 목록이 없을 때 표시되는 `EmptyStateScreen`에 `verticalScroll` 속성을 추가하여, 작은 화면 기기에서도 레이아웃 유연성을 확보하고 사용자 경험을 개선했습니다. * refactor: 분실물의 경우에도 Result를 받아오도록 변경 * **refactor: `LostItemRepository`의 분실물 조회 반환 타입을 `Result`로 변경** * 비정상적인 데이터 수신이나 네트워크 에러 상황에 대응하기 위해 `getLost()`의 반환 타입을 `List`에서 `Result>`로 변경했습니다. * `LostItemRepositoryImpl`에서 `async`를 사용해 병렬로 데이터를 호출할 때, `getOrElse`를 활용한 조기 반환(Early Return) 로직을 적용하여 에러 처리를 강화했습니다. * `mutableListOf`를 `buildList`로 교체하여 불변성을 확보하고 가이드 항목과 분실물 항목을 결합하는 로직을 가독성 있게 수정했습니다. * **refactor: `NewsViewModel` 내 분실물 로드 로직 개선** * 리포지토리의 반환 타입 변경에 맞춰 `onSuccess`와 `onFailure` 블록을 사용하도록 수정했습니다. * 데이터 로드 실패 시 `LostUiState.Content.Error`를 상태에 반영하여 UI에서 에러 처리가 가능하도록 구현했습니다. * **test: 데이터 모델 변경에 따른 테스트 코드 수정** * `FestivalRemoteDataSourceTest`: `FestivalResponse` 필드 확장에 따라 `organizationId`, `festivalSponsors`, `instagramLink` 등의 누락된 파라미터를 테스트 픽스처에 추가했습니다. * `NewsViewModelTest`: `lostItemRepository.getLost()`가 `Result.success`를 반환하도록 Mocking 로직을 수정했습니다. * `HomeViewModelTest`: 불필요한 import 문을 정리했습니다. --- .../data/repository/LostItemRepositoryImpl.kt | 19 +++++++------- .../domain/repository/LostItemRepository.kt | 2 +- .../presentation/home/component/HomeScreen.kt | 10 ++------ .../presentation/news/NewsViewModel.kt | 25 +++++++++++-------- .../news/lost/component/LostItemScreen.kt | 7 +++++- .../placeMap/component/PlaceListScreen.kt | 3 ++- .../component/PlaceDetailScreen.kt | 4 +-- .../festival/FestivalRemoteDataSourceTest.kt | 5 ++++ .../viewModel/home/HomeTestFixture.kt | 1 + .../viewModel/home/HomeViewModelTest.kt | 3 --- .../viewModel/news/NewsViewModelTest.kt | 2 +- 11 files changed, 45 insertions(+), 36 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt index 82affbda..a8a34d6c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt @@ -35,19 +35,20 @@ class LostItemRepositoryImpl( it.toDomain() } - override suspend fun getLost(): List = + override suspend fun getLost(): Result> = coroutineScope { val guide = async { getLostGuideItem() } val pendingItems = async { getPendingLostItems() } - val guideResult = guide.await() - val pendingItemsResult = pendingItems.await() + val guideResult = guide.await().getOrElse { return@coroutineScope Result.failure(it) } + val pendingItemsResult = + pendingItems.await().getOrElse { return@coroutineScope Result.failure(it) } - val total = mutableListOf() - - total.add(0, guideResult.getOrNull()) - pendingItemsResult.getOrNull()?.let { total.addAll(it) } - - total + Result.success( + buildList { + add(guideResult) + addAll(pendingItemsResult) + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/LostItemRepository.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/LostItemRepository.kt index d3e94412..4f3f4ce8 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/LostItemRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/domain/repository/LostItemRepository.kt @@ -7,5 +7,5 @@ interface LostItemRepository { suspend fun getLostGuideItem(): Result - suspend fun getLost(): List + suspend fun getLost(): Result> } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 71b716d2..4e9c40c9 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.festating.component.FestatingPoster import com.daedan.festabook.presentation.home.FestivalUiState @@ -37,7 +37,6 @@ import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing import festabookkmp.composeapp.generated.resources.Res -import festabookkmp.composeapp.generated.resources.error_fail_to_load_info import festabookkmp.composeapp.generated.resources.format_festival_period_date import festabookkmp.composeapp.generated.resources.format_festival_period_year import festabookkmp.composeapp.generated.resources.setting_notice_enabled @@ -106,12 +105,7 @@ fun HomeScreen( } is FestivalUiState.Error -> { - Box(modifier = modifier.fillMaxSize()) { - Text( - text = stringResource(Res.string.error_fail_to_load_info), - modifier = Modifier.align(Alignment.Center), - ) - } + ErrorStateScreen() } is FestivalUiState.Success -> { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/NewsViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/NewsViewModel.kt index c9b1b766..58879377 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -128,17 +128,22 @@ class NewsViewModel( fun loadAllLostItems(state: LostUiState) { viewModelScope.launch { _lostUiState.value = state - val result = lostItemRepository.getLost() - - val lostUiModels = - result.map { lost -> - when (lost) { - is Lost.Guide -> lost.toLostGuideItemUiModel() - is Lost.Item -> lost.toLostItemUiModel() - null -> LostUiModel.Guide() - } + lostItemRepository + .getLost() + .onSuccess { result -> + val lostUiModels = + result.map { lost -> + when (lost) { + is Lost.Guide -> lost.toLostGuideItemUiModel() + is Lost.Item -> lost.toLostItemUiModel() + null -> LostUiModel.Guide() + } + } + _lostUiState.value = + LostUiState(content = LostUiState.Content.Success(lostUiModels)) + }.onFailure { + _lostUiState.value = LostUiState(content = LostUiState.Content.Error(it)) } - _lostUiState.value = LostUiState(content = LostUiState.Content.Success(lostUiModels)) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 422a9f83..bb803739 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -104,7 +104,12 @@ private fun LostItemContent( Box(modifier = modifier) { val isLostItemEmpty = lostItems.none { it is LostUiModel.Item } if (isLostItemEmpty) { - EmptyStateScreen() + EmptyStateScreen( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) } LazyVerticalGrid( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index 593deb08..3cd3c395 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState @@ -150,7 +151,7 @@ fun PlaceListScreen( } is ListLoadState.Error -> { - EmptyStateScreen( + ErrorStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt index 549fb3ce..34626e3e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt @@ -52,7 +52,7 @@ import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import com.daedan.festabook.presentation.common.ObserveAsEvents -import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.URLText @@ -244,7 +244,7 @@ fun PlaceDetailScreen( } is PlaceDetailUiState.Error -> { - EmptyStateScreen() + ErrorStateScreen() } } } diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt index 45ca8eaf..a223f534 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt @@ -18,6 +18,7 @@ import kotlin.test.assertEquals private val FAKE_FESTIVAL_RESPONSE = FestivalResponse( festivalId = 1L, + organizationId = 1L, organizationName = "대학교", festivalImages = listOf( @@ -35,6 +36,10 @@ private val FAKE_FESTIVAL_RESPONSE = festivalName = "봄 축제", startDate = "2026-04-01", endDate = "2026-04-10", + festivalSponsors = emptyList(), + instagramLink = null, + homepageLink = null, + festatingVisible = true ) @Suppress("UNCHECKED_CAST") diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt index 2dd0550c..56779910 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeTestFixture.kt @@ -28,6 +28,7 @@ val FAKE_ORGANIZATION = sponsors = emptyList(), instagramLink = null, homepageLink = null, + festatingVisible = true, ), ) diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt index 075b8683..d64c6c44 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/home/HomeViewModelTest.kt @@ -4,9 +4,6 @@ import com.daedan.festabook.domain.model.MyWaiting import com.daedan.festabook.domain.model.WaitingStatus import com.daedan.festabook.domain.repository.FestivalRepository import com.daedan.festabook.domain.repository.MyWaitingRepository -import com.daedan.festabook.domain.repository.MyWaitingRepository -import com.daedan.festabook.home.FAKE_LINEUP -import com.daedan.festabook.home.FAKE_ORGANIZATION import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.LineupUiState diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt index 4de4c8fa..2c18ba9f 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/news/NewsViewModelTest.kt @@ -46,7 +46,7 @@ class NewsViewModelTest { lostItemRepository = mock() everySuspend { noticeRepository.fetchNotices() } returns Result.success(FAKE_NOTICES) everySuspend { faqRepository.getAllFAQ() } returns Result.success(FAKE_FAQS) - everySuspend { lostItemRepository.getLost() } returns FAKE_LOST_ITEM + everySuspend { lostItemRepository.getLost() } returns Result.success(FAKE_LOST_ITEM) newsViewModel = NewsViewModel( From e39a6675454bcaf8f943c89ffd6c670c02946947 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:27:34 +0900 Subject: [PATCH 41/54] =?UTF-8?q?[Fix]=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EA=B0=80=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: `NaverMapContent` 가시성 제어 로직 구현 및 iOS 맵 뷰 최적화 * **feat: `NaverMapContent` 가시성 제어를 위한 `isVisible` 파라미터 추가** * `NaverMapContent`, `PlaceMapScreen`, `PlaceMapContent`에 `isVisible` 파라미터를 추가하여 지도 컴포넌트의 노출 여부를 외부에서 제어할 수 있도록 개선했습니다. * `MainScreen`에서 해당 상태를 전달받아 지도의 렌더링 상태를 관리합니다. * **refactor: iOS 환경에서 지도 뷰의 가시성 처리 최적화** * iOS의 `UIKitView` 내 `update` 블록을 활용하여 `isVisible` 상태에 따라 `mapView.hidden` 속성이 동적으로 변경되도록 수정했습니다. * 이를 통해 화면 전환이나 탭 변경 시 불필요한 리소스 소모를 방지하고 시각적 상태를 정확히 반영합니다. * **refactor: 플랫폼별 공통 인터페이스 및 기본값 설정** * `commonMain`의 `NaverMapContent` 인터페이스에 `isVisible` 기본값(`true`)을 설정하여 기존 호출부와의 호환성을 유지했습니다. * Android 및 iOS의 실제 구현체에서 해당 파라미터를 수용하도록 시그니처를 업데이트했습니다. --- .../presentation/placeMap/component/NaverMapContent.kt | 1 + .../festabook/presentation/main/component/MainScreen.kt | 1 + .../presentation/placeMap/component/NaverMapContent.kt | 1 + .../presentation/placeMap/component/PlaceMapScreen.kt | 4 ++++ .../presentation/placeMap/component/NaverMapContent.kt | 2 ++ 5 files changed, 9 insertions(+) diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index f6758057..13ca91d2 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -30,6 +30,7 @@ actual fun NaverMapContent( mapDelegate: MapDelegate, onMapDrag: () -> Unit, onMapReady: (NaverMap) -> Unit, + isVisible: Boolean, content: @Composable (NaverMap?) -> Unit, ) { val context = LocalContext.current diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index c51d38c4..0b8e9646 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -223,6 +223,7 @@ fun MainScreen( } } }, + isVisible = isVisible, placeMapViewModel = placeMapViewModel, locationSource = locationSource, // logger = appGraph.defaultFirebaseLogger, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index 88e6b317..9dc55b84 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -11,5 +11,6 @@ expect fun NaverMapContent( mapDelegate: MapDelegate = MapDelegate(), onMapDrag: () -> Unit = {}, onMapReady: (NaverMap) -> Unit = {}, + isVisible: Boolean = true, content: @Composable (NaverMap?) -> Unit, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index 400103de..2dce80b0 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -57,6 +57,7 @@ fun PlaceMapRoute( locationSource: LocationSource, // logger: DefaultFirebaseLogger, modifier: Modifier = Modifier, + isVisible: Boolean = true, ) { val imageLoader = LocalCoilImageLoader.current val context = LocalPlatformContext.current @@ -112,6 +113,7 @@ fun PlaceMapRoute( onEvent = { placeMapViewModel.onPlaceMapEvent(it) }, bottomSheetState = bottomSheetState, mapDelegate = mapDelegate, + isVisible = isVisible, ) } @@ -122,6 +124,7 @@ fun PlaceMapScreen( bottomSheetState: PlaceListBottomSheetState, mapDelegate: MapDelegate, modifier: Modifier = Modifier, + isVisible: Boolean = true, ) { val isPlaceListVisible = uiState.selectedPlace is LoadState.Empty @@ -130,6 +133,7 @@ fun PlaceMapScreen( mapDelegate = mapDelegate, onMapReady = { onEvent(MapControlEvent.OnMapReady) }, onMapDrag = { onEvent(MapControlEvent.OnMapDrag) }, + isVisible = isVisible, ) { naverMap -> Column( modifier = Modifier.wrapContentSize(), diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index db48e4f0..59ca1581 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -20,6 +20,7 @@ actual fun NaverMapContent( mapDelegate: MapDelegate, onMapDrag: () -> Unit, onMapReady: (NaverMap) -> Unit, + isVisible: Boolean, content: @Composable (NaverMap?) -> Unit, ) { val mapView = remember { NMFNaverMapView() } @@ -34,6 +35,7 @@ actual fun NaverMapContent( Box(modifier = modifier) { UIKitView( factory = { mapView }, + update = { view -> view.hidden = !isVisible }, modifier = Modifier.dragInterceptor(onMapDrag), ) content(mapDelegate.value) From 0a659a270c5da3ca07e4861fd36fbbe0e2611a54 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 1 May 2026 01:37:41 +0900 Subject: [PATCH 42/54] =?UTF-8?q?[Fix][iOS]=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=81=EC=84=B8=20>=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=EB=93=B1=EB=A1=9D=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9E=94=EC=83=81=EC=9D=B4=20=EC=83=9D=EA=B8=B0=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: `WaitingRegister` 화면 진입 애니메이션 제거 및 로딩 UI 구조 최적화 * **refactor: `WaitingRegister` 경로의 전환 애니메이션 제거** * `PlaceMapNavigation` 내 `WaitingRegister` 화면 이동 시 설정되었던 커스텀 `enterTransition` 및 `exitTransition`(슬라이드 및 페이드 효과)을 제거하여 기본 애니메이션을 사용하도록 수정했습니다. * **refactor: 로딩 상태의 불필요한 컴포넌트 및 레이아웃 제거** * `WaitingRegisterScreen`에서 `Loading` 및 `NeedsPhoneRegistration` 상태일 때 표시하던 `LoadingStateScreen`과 이를 감싸던 `Box` 레이아웃을 제거했습니다. * 해당 상태에서 별도의 UI 노출 없이 처리하도록 변경하여 화면 깜빡임이나 불필요한 렌더링을 방지했습니다. * **style: 코드 포맷팅 수정** * `isSubmitting` 변수 선언부의 가독성을 위해 줄바꿈 및 들여쓰기를 조정했습니다. --- .../placeMap/navigation/PlaceMapNavigation.kt | 13 +------------ .../component/WaitingRegisterScreen.kt | 8 +++----- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index 0b556ac0..7034b0c7 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -2,10 +2,6 @@ package com.daedan.festabook.presentation.placeMap.navigation import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.ui.Modifier @@ -82,14 +78,7 @@ fun NavGraphBuilder.placeMapNavGraph( ) } - composable( - enterTransition = { - slideInVertically(initialOffsetY = { it / 10 }) + fadeIn() - }, - exitTransition = { - slideOutVertically(targetOffsetY = { it / 10 }) + fadeOut() - }, - ) { backStackEntry -> + composable { backStackEntry -> val route = backStackEntry.toRoute() val viewModel = assistedMetroViewModel( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt index 3abebf7e..30a5040e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt @@ -47,7 +47,6 @@ import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.PermissionState import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.ErrorStateScreen -import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.cardBackground import com.daedan.festabook.presentation.placeMap.component.PlaceDetailPreviewContent import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel @@ -194,7 +193,8 @@ fun WaitingRegisterScreen( ) { val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) val state = rememberNavigationEventState(NavigationEventInfo.None) - val isSubmitting = (uiState as? WaitingRegisterUiState.Success)?.waitingRegister?.isSubmitting ?: false + val isSubmitting = + (uiState as? WaitingRegisterUiState.Success)?.waitingRegister?.isSubmitting ?: false NavigationBackHandler( state = state, @@ -216,9 +216,7 @@ fun WaitingRegisterScreen( is WaitingRegisterUiState.Loading, is WaitingRegisterUiState.NeedsPhoneRegistration, -> { - Box(modifier = Modifier.weight(1f)) { - LoadingStateScreen() - } + Unit } is WaitingRegisterUiState.Error -> { From d3236e3f84fef39cea3a0c6b249e65304785920d Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 1 May 2026 01:37:50 +0900 Subject: [PATCH 43/54] =?UTF-8?q?[FIx]=20=EC=A7=80=EB=8F=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=9A=94=EC=B2=AD=20=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=EC=8B=9C,=20=EB=A7=88=EC=BB=A4=EA=B0=80=20?= =?UTF-8?q?=EB=82=98=ED=83=80=EB=82=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 장소 지도 마커 로드 타임아웃 처리 강화 및 대기 시간 조정 * **refactor: `PlaceMapUiState.await` 기본 타임아웃 시간 연장** * 네트워크 지연 등 다양한 통신 환경을 고려하여 `await` 함수의 기본 타임아웃 값을 `3.seconds`에서 `5.seconds`로 변경했습니다. * **feat: 마커 데이터 로드 타임아웃 시 에러 상태 업데이트 로직 추가** * `PlaceMapViewModel`에서 마커 정보(`placeGeographies`)를 불러올 때 타임아웃이 발생할 경우, `_uiState`를 `LoadState.Error`로 업데이트하는 처리 로직을 추가했습니다. * 타임아웃 발생 시 "서버에서 마커 정보를 가져오는 데 실패했습니다."라는 에러 메시지를 상태에 반영하여 예외 상황에 대한 UI 대응이 가능하도록 개선했습니다. --- .../presentation/placeMap/PlaceMapViewModel.kt | 14 +++++++++++++- .../presentation/placeMap/intent/state/StateExt.kt | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index d25c4da6..041e78cb 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -126,7 +126,19 @@ class PlaceMapViewModel( } val placeGeographies = - uiState.await>> { it.placeGeographies } + uiState.await>>( + onTimeout = { + _uiState.update { + it.copy( + placeGeographies = + LoadState.Error( + IllegalStateException("서버에서 마커 정보를 가져오는 데 실패했습니다."), + ), + ) + } + }, + ) { it.placeGeographies } + _mapControlSideEffect.send( MapControlSideEffect.SetMarkerByTimeTag( placeGeographies = placeGeographies.value, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt index 74cdbf20..c67a5289 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt @@ -13,7 +13,7 @@ import kotlin.time.Duration.Companion.seconds @OptIn(FlowPreview::class) suspend inline fun StateFlow.await( - timeout: Duration = 3.seconds, + timeout: Duration = 5.seconds, onTimeout: (Throwable) -> Unit = {}, crossinline selector: (PlaceMapUiState) -> Any?, ): R = From 2bad839a9528d3596757a92ddb7a190109b95327 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 1 May 2026 01:38:02 +0900 Subject: [PATCH 44/54] =?UTF-8?q?[Fix]=20=EC=A7=80=EB=8F=84=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=9E=AC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EB=82=B4=EB=A0=A4=EA=B0=80=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: `PlaceMapSideEffectHandler` 내 메뉴 아이템 재클릭 시 동작 로직 개선 * **refactor: `MenuItemReClicked` 사이드 이펙트 처리 로직 고도화** * 메뉴 아이템 재클릭 시, 하단 시트(`bottomSheetState`)가 완전히 펼쳐진 상태(`EXPANDED`)라면 절반 펼침 상태(`HALF_EXPANDED`)로 변경하도록 수정했습니다. * 장소 미리보기가 활성화된 경우(`isPreviewVisible`)에만 장소 선택 해제(`UnSelectPlace`) 이벤트가 발생하도록 로직을 조정했습니다. * 불필요한 주석 처리된 로거 관련 코드를 제거했습니다. --- .../intent/handler/PlaceMapSideEffectHandler.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt index 0adf3101..0e3a9ee3 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt @@ -12,7 +12,6 @@ class PlaceMapSideEffectHandler( private val mapManagerDelegate: MapManagerDelegate, private val bottomSheetState: PlaceListBottomSheetState, private val viewModel: PlaceMapViewModel, -// private val logger: DefaultFirebaseLogger, // 안드로이드 종속적인 액션은 외부에서 주입 // TODO Compose로 전환 시, 콜백이 아닌 Compose State 주입 private val onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, @@ -29,13 +28,12 @@ class PlaceMapSideEffectHandler( is PlaceMapSideEffect.MenuItemReClicked -> { mapManager?.moveToPosition() - if (!event.isPreviewVisible) return - viewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) -// logger.log( -// PlaceMapButtonReClick( -// baseLogData = logger.getBaseLogData(), -// ), -// ) + if (bottomSheetState.settledValue == PlaceListBottomSheetValue.EXPANDED) { + bottomSheetState.update(PlaceListBottomSheetValue.HALF_EXPANDED) + } + if (event.isPreviewVisible) { + viewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + } } is PlaceMapSideEffect.StartPlaceDetail -> { From e289833e330d6b961d9cf270e1b69444cd568442 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 1 May 2026 17:37:41 +0900 Subject: [PATCH 45/54] =?UTF-8?q?[Fix]=20=ED=8E=98=EC=8A=A4=ED=83=80?= =?UTF-8?q?=ED=8C=85=20=EC=A0=91=EC=86=8D=20=ED=9B=84=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=EC=A0=91=EC=86=8D=20=EC=8B=9C,=20=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=EA=B0=80=20=EB=9C=A8=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: iOS Firebase Crashlytics 설정 개선 및 네비게이션 구조 최적화 * **build: iOS Firebase Crashlytics dSYM 자동 업로드 및 빌드 단계 조정** * Firebase Crashlytics의 정확한 디버깅을 위해 dSYM 파일을 자동으로 찾아 업로드하는 `Upload Firebase Crashlytics dSYMs` 빌드 단계를 추가했습니다. * 빌드 단계 순서를 조정하여 Crashlytics 초기화 및 심볼 업로드가 적절한 시점에 수행되도록 수정했습니다. * Debug 빌드의 앱 표시 명칭(`CFBundleDisplayName`)을 "festabook(Debug)"에서 "festabook"으로 통일했습니다. * **refactor: `festatingNavGraph` 위치 변경 및 네비게이션 로직 수정** * 최상위 수준의 `FestabookScreen`에서 관리하던 `festatingNavGraph`를 `MainScreen` 내부의 네비게이션 영역으로 이동하여 계층 구조를 최적화했습니다. * `MainScreen` 내 `onNavigateToFestating` 콜백이 상위 `festabookNavigator` 대신 현재 컨텍스트의 `navigator`를 사용하도록 변경했습니다. * refactor: `PlaceListScreen` 내 현재 위치 버튼 노출 조건 수정 및 매개변수 추가 * **refactor: `CurrentLocationButton` 표시 로직 개선** * `PlaceListScreen`에 지도 표시 여부를 제어하는 `isPlaceMapVisible` 파라미터를 추가했습니다. * `CurrentLocationButton`의 노출 조건을 기존 장소 목록 가시성(`isPlaceListVisible`) 단독 체크에서 지도 가시성(`isPlaceMapVisible`)까지 모두 만족하는 경우로 강화하여, 지도가 보이지 않는 상황에서 버튼이 노출되는 현상을 방지했습니다. * **refactor: 상위 컴포넌트 및 프리뷰 반영** * `PlaceMapScreen`에서 `PlaceListScreen`을 호출할 때 지도의 가시성 상태(`isVisible`)를 전달하도록 수정했습니다. * `PlaceListScreen` 프리뷰 코드에 신규 파라미터를 추가했습니다. --- .../festabook/presentation/FestabookScreen.kt | 2 -- .../presentation/main/component/MainScreen.kt | 5 +++- .../placeMap/component/PlaceListScreen.kt | 4 ++- .../placeMap/component/PlaceMapScreen.kt | 1 + iosApp/iosApp.xcodeproj/project.pbxproj | 28 +++++++++++++++++-- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt index d44d302d..9662c448 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/FestabookScreen.kt @@ -6,7 +6,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import com.daedan.festabook.di.FestabookAppGraph import com.daedan.festabook.presentation.explore.navigation.exploreNavGraph -import com.daedan.festabook.presentation.festating.navigation.festatingNavGraph import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.navigation.mainNavGraph import com.daedan.festabook.presentation.main.rememberFestabookNavigator @@ -64,6 +63,5 @@ fun FestabookScreen( locationSource = locationSource, festabookNavigator = festabookNavigator, ) - festatingNavGraph() } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index 0b8e9646..4d2ffb79 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -27,6 +27,7 @@ import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.FestabookSnackbar import com.daedan.festabook.presentation.common.component.SnackbarManager import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager +import com.daedan.festabook.presentation.festating.navigation.festatingNavGraph import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.navigation.homeNavGraph import com.daedan.festabook.presentation.main.FestabookMainTab @@ -288,7 +289,7 @@ private fun FestabookNavHost( homeViewModel = homeViewModel, mainViewModel = mainViewModel, onNavigateToExplore = { festabookNavigator.navigate(FestabookRoute.Explore) }, - onNavigateToFestating = { festabookNavigator.navigate(FestabookRoute.Festating) }, + onNavigateToFestating = { navigator.navigate(FestabookRoute.Festating) }, onSubscriptionConfirm = { settingViewModel.notificationAllowClick() mainViewModel.declineAlert() @@ -366,5 +367,7 @@ private fun FestabookNavHost( onShowSnackbar = snackbarManager::show, onShowErrorSnackbar = snackbarManager::showError, ) + + festatingNavGraph() } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index 3cd3c395..e51e7bec 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -61,6 +61,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview fun PlaceListScreen( placesUiState: ListLoadState>, isPlaceListVisible: Boolean, + isPlaceMapVisible: Boolean, modifier: Modifier = Modifier, map: NaverMap? = null, isExceededMaxLength: Boolean = false, @@ -103,7 +104,7 @@ fun PlaceListScreen( Box { CurrentLocationButton( map = map, - visible = isPlaceListVisible, + visible = isPlaceListVisible && isPlaceMapVisible, ) if (isExceededMaxLength) { Row( @@ -318,6 +319,7 @@ private fun PlaceListScreenPreview() { }, ), isPlaceListVisible = true, + isPlaceMapVisible = true, modifier = Modifier.padding( horizontal = festabookSpacing.paddingScreenGutter, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index 2dce80b0..fc1d8efe 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -169,6 +169,7 @@ fun PlaceMapScreen( ) PlaceListScreen( + isPlaceMapVisible = isVisible, isPlaceListVisible = isPlaceListVisible, placesUiState = uiState.places, map = naverMap, diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 0854f9d8..8152144f 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -106,8 +106,9 @@ 3ED20EC5BFA21FC0E7D100C7 /* Frameworks */, A504BDED7EC474099DAD9F64 /* Resources */, 67E4A4402F6D7E2800C3FBD6 /* Copy Google Service plist */, - 67E4A4352F6D707D00C3FBD6 /* Inititialize Firebase Crashlytics */, 884269F5D9640B20E82AC683 /* [CP] Embed Pods Frameworks */, + 67E4A4352F6D707D00C3FBD6 /* Inititialize Firebase Crashlytics */, + 9F3B2C1A8E7D6F5E4D3C2B1A /* Upload Firebase Crashlytics dSYMs */, ); buildRules = ( ); @@ -211,6 +212,29 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; }; + 9F3B2C1A8E7D6F5E4D3C2B1A /* Upload Firebase Crashlytics dSYMs */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", + ); + name = "Upload Firebase Crashlytics dSYMs"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "find \"${DWARF_DSYM_FOLDER_PATH}\" -name \"*.dSYM\" | xargs -I {} \"${PODS_ROOT}/FirebaseCrashlytics/upload-symbols\" -gsp \"${PROJECT_DIR}/iosApp/GoogleService-Info.plist\" -p ios {}\n"; + }; 67E4A4402F6D7E2800C3FBD6 /* Copy Google Service plist */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -503,7 +527,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; + INFOPLIST_KEY_CFBundleDisplayName = festabook; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; From 6af3000d19e8ab90bff7ed494dfd4464de3ca2ee Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 1 May 2026 20:09:58 +0900 Subject: [PATCH 46/54] [Release] Release 2.1.1 --- fastlane/Fastfile | 6 +- gradle.properties | 4 +- iosApp/Configuration/Config.xcconfig | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 74 ++++++++++++++----------- iosApp/iosApp/Info.plist | 38 ++++++------- 5 files changed, 66 insertions(+), 58 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 842e699e..5f293c96 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -134,11 +134,11 @@ private_lane :android_build do |options| File.join(PROJECT_ROOT, "composeApp/build/outputs/apk/debug/composeApp-debug.apk") when "release" gradle( - task: "composeApp:assembleRelease", + task: "composeApp:bundleRelease", project_dir: PROJECT_ROOT, properties: { "buildkonfig.flavor" => "release" }, ) - File.join(PROJECT_ROOT, "composeApp/build/outputs/apk/release/composeApp-release.apk") + File.join(PROJECT_ROOT, "composeApp/build/outputs/bundle/release/composeApp-release.aab") else UI.user_error!("알 수 없는 Android flavor: #{flavor}") end @@ -153,7 +153,7 @@ private_lane :android_upload do |options| service_credentials_file: local_property("FIREBASE_SERVICE_ACCOUNT_PATH"), groups: optional_local_property("FIREBASE_TESTER_GROUPS", "festabook"), release_notes: changelog_from_git_commits(commits_count: 10, merge_commit_filtering: "exclude_merges"), - android_artifact_type: "APK", + android_artifact_type: flavor == "release" ? "AAB" : "APK", android_artifact_path: apk_path, ) notify_slack( diff --git a/gradle.properties b/gradle.properties index 35595697..039ca151 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ kotlin.native.binary.stripDebugSymbols=false kotlin.native.cacheKind=static kotlin.native.disableCompilerDaemon=false -buildkonfig.flavor=dev +buildkonfig.flavor=release APP_VERSION_NAME=2.1.1 -APP_VERSION_CODE=20101 +APP_VERSION_CODE=2010102 diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index fcb91be9..553d328c 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Festabookkmp PRODUCT_BUNDLE_IDENTIFIER=com.daedan.festabook.Festabookkmp$(TEAM_ID) -CURRENT_PROJECT_VERSION=20101 +CURRENT_PROJECT_VERSION=2010102 MARKETING_VERSION=2.1.1 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 8152144f..ade4d3ab 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -39,6 +39,8 @@ }; 8E6C8D378651BF8144E66D20 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Configuration; sourceTree = ""; }; @@ -212,7 +214,7 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; }; - 9F3B2C1A8E7D6F5E4D3C2B1A /* Upload Firebase Crashlytics dSYMs */ = { + 67E4A4402F6D7E2800C3FBD6 /* Copy Google Service plist */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -220,59 +222,55 @@ inputFileListPaths = ( ); inputPaths = ( - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", - "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", - "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", ); - name = "Upload Firebase Crashlytics dSYMs"; + name = "Copy Google Service plist"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "find \"${DWARF_DSYM_FOLDER_PATH}\" -name \"*.dSYM\" | xargs -I {} \"${PODS_ROOT}/FirebaseCrashlytics/upload-symbols\" -gsp \"${PROJECT_DIR}/iosApp/GoogleService-Info.plist\" -p ios {}\n"; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nDEBUG_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-DEBUG.plist\"\nRELEASE_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-RELEASE.plist\"\n\n# 2. 앱이 실제로 참조하게 될 목적지 경로\nDESTINATION_PATH=\"${SRCROOT}/iosApp/GoogleService-Info.plist\"\n\n# 3. 빌드 구성(Configuration)에 따라 복사 실행\nif [ \"${CONFIGURATION}\" == \"Debug\" ] || [ \"${CONFIGURATION}\" == \"Staging\" ]; then\n echo \"복사 중: Debug용 Firebase 설정...\"\n cp \"${DEBUG_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nelse\n echo \"복사 중: Release용 Firebase 설정...\"\n cp \"${RELEASE_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nfi\n"; }; - 67E4A4402F6D7E2800C3FBD6 /* Copy Google Service plist */ = { + 884269F5D9640B20E82AC683 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); - name = "Copy Google Service plist"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nDEBUG_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-DEBUG.plist\"\nRELEASE_CONFIG_PATH=\"${SRCROOT}/iosApp/GoogleService-Info-RELEASE.plist\"\n\n# 2. 앱이 실제로 참조하게 될 목적지 경로\nDESTINATION_PATH=\"${SRCROOT}/iosApp/GoogleService-Info.plist\"\n\n# 3. 빌드 구성(Configuration)에 따라 복사 실행\nif [ \"${CONFIGURATION}\" == \"Debug\" ] || [ \"${CONFIGURATION}\" == \"Staging\" ]; then\n echo \"복사 중: Debug용 Firebase 설정...\"\n cp \"${DEBUG_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nelse\n echo \"복사 중: Release용 Firebase 설정...\"\n cp \"${RELEASE_CONFIG_PATH}\" \"${DESTINATION_PATH}\"\nfi\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; - 884269F5D9640B20E82AC683 /* [CP] Embed Pods Frameworks */ = { + 9F3B2C1A8E7D6F5E4D3C2B1A /* Upload Firebase Crashlytics dSYMs */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", ); - name = "[CP] Embed Pods Frameworks"; + name = "Upload Firebase Crashlytics dSYMs"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "find \"${DWARF_DSYM_FOLDER_PATH}\" -name \"*.dSYM\" | xargs -I {} \"${PODS_ROOT}/FirebaseCrashlytics/upload-symbols\" -gsp \"${PROJECT_DIR}/iosApp/GoogleService-Info.plist\" -p ios {}\n"; }; C08EE85BEF61373E7C695D8C /* Compile Kotlin Framework */ = { isa = PBXShellScriptBuildPhase; @@ -389,8 +387,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIStatusBarHidden = NO; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; KOTLIN_FRAMEWORK_BUILD_TYPE = release; LD_RUNPATH_SEARCH_PATHS = ( @@ -401,9 +399,11 @@ PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_distribution_debug_provisioning_profile; STRIP_INSTALLED_PRODUCT = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Staging; }; @@ -432,8 +432,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIStatusBarHidden = NO; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -443,9 +443,11 @@ PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_distribution_debug_provisioning_profile; STRIP_INSTALLED_PRODUCT = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -518,32 +520,38 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = iosApp/iosAppRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = YES; DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L8HZU9MP64; ENABLE_DEBUG_DYLIB = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = festabook; + INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIStatusBarHidden = NO; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = eoehdeksruf.festabook; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = festabook_distribution_provisioning_profile; STRIP_INSTALLED_PRODUCT = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 61396ab5..b9834b1e 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,22 +1,22 @@ - + + - - CADisableMinimumFrameDurationOnPhone - - FirebaseAppDelegateProxyEnabled - - LSApplicationQueriesSchemes - - itms-apps - - UIBackgroundModes - - remote-notification - - CFBundleShortVersionString + + CADisableMinimumFrameDurationOnPhone + + FirebaseAppDelegateProxyEnabled + + LSApplicationQueriesSchemes + + itms-apps + + UIBackgroundModes + + remote-notification + +CFBundleShortVersionString 2.1.1 - CFBundleVersion - 20101 - +CFBundleVersion + 2010102 + From 9ef0a2720ad4b89c43e9f5dbcea50186ce04a701 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sat, 2 May 2026 13:59:13 +0900 Subject: [PATCH 47/54] [Release] Release 2.1.1(2010103) --- gradle.properties | 2 +- iosApp/Configuration/Config.xcconfig | 2 +- .../Assets.xcassets/AppIcon.appiconset/100.png | Bin 1434 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/102.png | Bin 1401 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/1024.png | Bin 25795 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/108.png | Bin 1556 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/114.png | Bin 1596 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/120.png | Bin 1750 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/128.png | Bin 1828 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/144.png | Bin 2033 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/152.png | Bin 2249 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/16.png | Bin 309 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/167.png | Bin 2392 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/172.png | Bin 2501 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/180.png | Bin 2676 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/196.png | Bin 2834 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/20.png | Bin 368 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/216.png | Bin 3016 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/234.png | Bin 3490 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/256.png | Bin 3756 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/258.png | Bin 3697 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/29.png | Bin 507 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/32.png | Bin 543 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/40.png | Bin 634 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/48.png | Bin 773 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/50.png | Bin 787 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/512.png | Bin 9793 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/55.png | Bin 873 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/57.png | Bin 894 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/58.png | Bin 849 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/60.png | Bin 879 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/64.png | Bin 986 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/66.png | Bin 985 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/72.png | Bin 1042 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/76.png | Bin 1080 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/80.png | Bin 1194 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/87.png | Bin 1187 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/88.png | Bin 1302 -> 0 bytes .../Assets.xcassets/AppIcon.appiconset/92.png | Bin 1330 -> 0 bytes .../AppIcon.appiconset/Contents.json | 2 +- .../AppIcon.appiconset/festabook_icon.png | Bin 0 -> 41934 bytes iosApp/iosApp/Info.plist | 2 +- 42 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/102.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/1024.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/108.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/114.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/144.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/152.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/216.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/234.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/258.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/57.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/60.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/64.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/66.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/72.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/80.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/88.png delete mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/92.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/festabook_icon.png diff --git a/gradle.properties b/gradle.properties index 039ca151..79f5d77c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,4 +18,4 @@ kotlin.native.disableCompilerDaemon=false buildkonfig.flavor=release APP_VERSION_NAME=2.1.1 -APP_VERSION_CODE=2010102 +APP_VERSION_CODE=2010103 diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 553d328c..93b82f3f 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Festabookkmp PRODUCT_BUNDLE_IDENTIFIER=com.daedan.festabook.Festabookkmp$(TEAM_ID) -CURRENT_PROJECT_VERSION=2010102 +CURRENT_PROJECT_VERSION=2010103 MARKETING_VERSION=2.1.1 \ No newline at end of file diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png deleted file mode 100644 index 4cabfb55e643b1bebec0ff811a057a4380875796..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1434 zcmV;L1!ek)P)h|fib#>z@GbZtEZBC#h8;_m`4Fs8yJ3d}66&T>rLHT1 z)SwDM0%@GF=W!QD8u@ZxZhCQAb7tX1+C2Qrng4ms39;6Cxauv62oM&+LRbh3VIeF; zfUpo2!a`UG3t=GwgoUsW7Q#YU2n!J)EQGb{<=?aqWUV#ESZk|oC-Ud@yTj62E2Xs7 zfT;=vsKFH;r<77k8Do^vCfSjd_YIX&MNt@ID5bvdGsgbu{(+-0#ux)wJ~i3nrN^Ss$?279xGu(Z~fmzQA}Mo|PC(DS?x zA3hu$92^}Tef;?G`}gl-+OjOe zFl^yAG{(&5bH(vBSf|r%#`zX)1(JN>#=Cudc9qRZ8`Gy&wpxQAOJ#_@XFW zd~|+(zQ4bpBne|oDRl+yAVh23?RFiyB*~~q7dU{%x<&|6*{IisDu~;aj*4EbCchHhV+FI*GJ&ATI=a_ znx-i*diLzu+1Z&8;^gGy;^G2qttRwGj~>Nw3{gT^mTPNkFJHcdXm&cCW?2Sr(VhtY z?c28}Cnrgggke~p6d2dLcki^;082`_xw+Yxw66wMnx@@uH;!YM57%FvPRCVpilWG} ztl<)~-+akIYrUMrxIiD$9j-5n2GVF(Lh;X+sl%WHG!;QGl+6#QI;&sAw!4ogaz z=Q$w+>JZ))OVWTCf>J4E%VBM8ZRL3`gt)l4fDTRl=ia}6ADUf|8}|GCs&O@7XiX>5 zN-3ojW9-S3C$C?>?smI%0&$>oaBwgj4xtnS)hplkA)22|CM`*%tK9^5^ka$I6C8>7 z_t(GOut1*NSt2OZEmx&;{a2S-Iz~$<;0=YvIiJtxi^XC#n{8}tbUK}DTIhH@)>?DU zfjj412+@FXH32IKf=;IsMG@@2YZl9`wNVtop%Qp0##l=%3(DkhP>*x|zuc1V`%x4{ zQG^jZraK4=Vd1&~uvU0}{r16X!EaCDHbhtm3t=HF^oHmSaT_8mgysFVW4~?`v!-)s z;C$`Ph4tgdk1t=oz_f22vPDree9rF%!Fv4o@#f}c9LIH9)6nmNv)n7Rujb|rt+kWM zLpSUHl7jgbpQYW07*qoM6N<$f|@hE?f?J) diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/102.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/102.png deleted file mode 100644 index 69a30a289896ea9420a3fb23d0f88dc6c491b260..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1401 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 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/108.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/108.png deleted file mode 100644 index c97b253bdcdbef168c10e36836a2af668916cf1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1556 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 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index 31d722879bfd995604d61fb37a417f32d497680e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1750 zcmZ`)e>@Wi8=qP%#Avc}ix$d|pVKg6J64kzdDjrq?k&GUC?=YQD{rI1MP)X8=10tr zV_YPbZo5o=?Xp9ddY!Hem$qHDajxsX_w#-~pXYf#-{<-Bd7kgLfDmwWm$r#E007v9 zKjss>RWtq$(6+7Isacf|0BADsKA4bg-4}v;1~jxW zJbPB6Ur#bnnc3Phpd_#%$l@6dXsgiBGy8T6FHzQh6Q=D&L8{ zv7*F)RS8e~;|A`z78N>1LpLa+O$mFxvJ%I+%qjK?g8YK~5bT<d$zSh>!#I9R=(Gy$Q)YIMBc>wzLgGx1&NrfSs7RF*AzIPTW z$qhk%bA43m;xMBwklD=PSmLpWe4*LxYmW#$PJDu&+aib9IFrcqpnMo-A3r}_p`oZR zwH~zm;RE4ra1qXY*o~mMzwt8|0oPMaeS>PoS{?aRTS+28qZWsdEeOj-m|k92RV2hE z`BEstlC0~W#m2;Q-neRtwP5++Q!SC%f38NiPbP4~z`ijYD&sMUQYml?6s5(+J`Vr@ z9bTT`@nWplu8Wd&5M106MO~Hf<_E6tga;4e8J*mN?d|Q8x7+HCBKC~pi-8zPw;d4L zl{nK)-Q2iiesmn^dF-?g=pK{VW#b(*VG#?i!!teUTU{Q>Y;;-qM89^sw{iK65~m+A2=*ccJj9M&Uz&6t z{xqVRz3S{Jei5pZxUsl+}HwPaVO}Gt0611z2V{EIy6ZR3qDy;T_4#py)}<2 zfc*FXo(C<&VVTpJCO!?*T1J}*vzop*&zm8Vy8n@Y6@R%d&!2!+DF za!GZQ7NXUTtI+**IZH@!fL5T>6DgEX5ZpOM{>$!)cOj&Ht-}va{7f+Q%uIX3FPxkV z<;eJazFdB}r~`|5nQ$1cue!)}ns_fG5{bgwkRlxCDI~~0oCO8&DTHO46O8s zrax-Fvb7y!`^aD3%L?S4AUGP0HnuxW3JqeIS~&v5>bd(uK`9LZ&&k* z{@A{5m;BH{@6~Y)u8%UVQ`u+8s=AjIA8&&%=A8ipuet0M`TZdli}S(J(5VJlb|10v z+QItK|9oesBe{_u9R=Z!il`4xst#y< z-+Ob<$>dk>z5hoW3t2%RlYf+Q)Cwp)voaI%u9FaucEGlATB`&8Hapf8CeQR#BJZnn zmt~m)^I!~GX)CTglD+<6L0E_*kI(ib%{^hbUwYV)4uNC|nU3cipdk4D7q!mP>B{W6 zTh*ZH%M7Mx^y_`5ljwP8{06YIk~d(Vo4eP_EXOHVnZ!t`>U6b&a#C zP(uo}1-_OGs6szisH@k%I=;?IEE$6=wj^u@@;JX`HdPz;TH?||Aw$2POs3L VPYNqfKyCRI0Ph>%(~Lbs`wMoYMN9wy diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png deleted file mode 100644 index 1485eeac4107abd93fe78693153dfc12253a6d68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1828 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 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png deleted file mode 100644 index 693210f3e6ddb325f6801a4af06eb9100dde1297..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 309 zcmV-50m}Y~P)qm|gOSWm(2#x+gT_dO!Iu4@>^MbiJ0{>=MA{Pzn#$PbitRv+a!00000NkvXX Hu0mjfyGDn` diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png deleted file mode 100644 index 520248a7aa2405fffba7e666245b0afbe5082af7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2392 zcmai0dpHwp8{b>JNpdK!Nsc9_PbfknBd5v8rpY-WkwXs4Oj8kFm5MdTIh1A==1g0x z53!<=Q;S(z4r2>>8QIXgzVG_}dEe{%zCWJ(xv%GXp69-=-+e#7-;;UH)p4(+k|Y2C z*b8>DgNkOt&mplxWQFm}mjJ+SHL#tH2XcI&*zMl%1dtXNoujpd2_qDz9X}N0ZewF3 zFXp+}T{Cy^J-%|uR;&Y9Hs|m@?X379mLx_iR`(DG!W{0^L~28#a)kQQapa1rP32@T zEuK1=wn{_~6%JT&N|F}I+Ahvvpd=UNR=|kHxeSoX4ur)2c6R;04gb2t3DeBj?szWT z*Z#5L!2Te8{vp27$euIa&boM)oIBgkfVyjUawG4?^(W_T7kv37Yf3AoJ*z;%z&Ufi z2psW;eb#P)cby6Kd^fJeSDm|0b#kDw{gB}%{XP>&T4YkTby>ab&7o5!5_1+6 zVM8~$seqA$tXA1WcMtXCM>6rywx4;6@0{Uxipx!J#VoyVwE zm$>r9Ft4wKb3!Q#UNsutTu9e)cNt5_(bVhoT!I1W@mGK0w>@%npB7T5%(2c#LWpstv+pV|{wE+Y{970y7re{@h#Dy~tr%D6|rPY>(d>|HY!VPI&eW{Iq0MX8;Pea4D1 zG!%siK|9TlOqo|yb#x7M*6-1%$fm`(v{|DCtKXNWfQJ6@7R5$Vx-S|deFARb;cLO~ z-R0e#bns33Rq(0>X6w>7c9*s*&K$uVA;)ne}`JfYhJvSt9J;Z@( zb@yr5^g~rEO6*LpKp+?z+6SQ&AWsN}jHFa(UKcK$Gq{a&)KK7cw;;R;5Uu?T#QA*4 zn^Mcf*ewAc)!u*d;ZvQ5wWOcGpTGiioc(iw78-}|h%zMmN-{h>J)aCbg;_0cK?Q1MDi*|4i_JXUv!8(SSDBbGxX zH!jqf*}}iyE2;;dZ0ae8K+C6~76(slT=T9Qv`w^BttF8tq@n;>LNb7Zvux|Et_*4~ zL9-9C0>)WTT{+2;Q^oz`wXNnC92f)5-mmn2t@eEbiFsBKaB{#V4umVRB*-;=HDKmFea&J7_U;F0P3GI|Mc-= zS%=+s6&jJj6VRTIK9@~dLx8M|r)?z$R0340Uuylcg?j(F zj=&R}k3|o(ifx2p8q>kPv1=PToST9=}8up3ZUi7|#&L0mvi+!JpG8GmP z&0sST1@fnP>8BN?3<9Wa4Gp$z<|HsA71fh5ELEe zId)e@@P}cw7s%yHjbkqK6XxRCS(TvP^({R9NH@|*5qBC*VJ<*XuFhUH@J7FC;~p(c z=919o*UT8@S7_nd^jnPHxai?!gm{gHW1YVQ^2nN9Kp-&8@q8((5n)8)acASHQbJYLOioo*dKfe?$9hC2;-2eOqM{ClHqHb*i82UKGg6ciY*8bb@#TFp z4XEPL=`X@$#q^%is}_lI4>ez9TwmS8W@ojz z*=A!gN1=QDT6P*p?67b<>8A+R_F=PEve+^yK_3sYG&RDIwkPehsDw zzv4L3<|%Qkz(rcc!tQ8MsPUjtk6O%?U^!Zu56{q9$101cXE^J z3uQ*w?7u7Q-r!&x7RW*2Z<_nF?#JIp_v_f@K7LRdlB4N{paJ+dJ#1v}I~-=V7|+{? zln7k)fJ^P_$U^_FQ_s5HSM9=JecUkFWPV0#9Y|9OS+PDri*>; zcmcA+Qx7YNt{5#WQmG4~JqXN*97y~=ww^7{_mh}_T3-Gf+q&srJ#m>~bjee&{SI<5 zz{!U&Yd5u=uVkhTKE885K>>z`5@p-FXR|2b*4lWFcFKx>rjzdZt7KcpC3`=z+%3D| z2-G>^Vs_l;X)n1xt9i3N2t6X^jj6a#hHRGzmD?6#|Ayx;PU?SI|Lc)Qft$O^%V(2! Tq=527KPCX!-qns|dokrtJBG4% diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png deleted file mode 100644 index 9239b459ae910344c833642f94039999dcb07dcc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2501 zcmb_e`#;l-7yp=*P0~boP(~M*Tylv`E;F~xwJo9$wumg*Ql_vbx}ZGaN##}&5~6I4 z$E^!JCYSOwCL;0SnOiQ?HuS8%-#_6wuh%)}bS%vBjyIFva7t-Ny&a^489%!-@aatQm+=e+(wA|Pn1g>} zXt*l<1bWQrY3(MMHcVNT0k@cnn%QY#?|$M|1}nF*tCKx_%z9NGv>b3u$R9#Ae2!MI z;0|;y-T}{bSrb>{Z8oVzqfr|WNID<|g&{G36aK-K^= zKVAy9K#pCj;qvuL;(r)_m?u#h3e6G4mr`uxsd64>ZhATRrQg@jf9NkiZnAcHPfLtO zd(2Xrk_mmpcz-hku0=pD%Gsq0&o=9g`xlLHN#EH0d53@xN(Kj5D9J79RK{_i# z$ccItnL^hqC;Ri!P zzMzz`*_`pvninfFlaT$%*owa+>y92$a@B*Uh1N`po94u4J?6&Ub#nCfOcJ`-{QB%9 zfDxjIEJ+{p!=X(~LaYFh(S|5at!;wndHpYAGMQ|)a8118S(>#A9=PAe`$&HJ88l zm(2|~iUl+GvTvCvqxy4DPQ}_3E|xZHc9KNY%1tk zXqz9(z&mAMz2Xha+%l4Pq{7DoNZ`ceWn^Ty+}vSkXh5T*`9{1Cf7Y zdt65I2u)KcPu0|{tgJ1XwgG;AMHr&LZ^O{(x}zbgVfbq;imT41xw~9%?RvDOC^KqM z@GQK0hjrZ3y1H%9*3M2jr?YzFNt3#SdT*oK@J$k65?A}D&UR}0;>IgZy>{M-fN6a} z2@;NjQlzb2!kIx

2X%>ge>e!8S_L@P&^6`i3IZzS7rCLHI!%f*{FBZGdoNB@pUk zKK)6f(r9eoiZ!-yWf4;V`c(LsUG;|izB7CzJyjinb$55qWyWZLUHJz%91fLA?KAAM zEvzM%9A*c|zv{V6ug%nmz2PDS`KNPIDLwKM*j;ExTX^VO5z{4eH|={<^g?*5bR1)J z+lD?B(}b3ZiHXJwD=+t1BgLH8iU;+Qk@E?5%T)_Gs#Y{(*)mIzR^YE-U}W@>zZ_bF znuPe_{40ImO%xg7M&YR{o60Opo4?f03Iu}p@81^<(FH#_P%c6g1oy{Y!s8q;zQ_Ij z%enklQ#^eMLvl~a(t{7eFOdi9Eiq`}JFGZHGMkOg&pP^tkFL_m z{d-*kcm7e$m@l>a7-btOx|ZVv-;d14QE>0&g1dMIk(?Yity#6;eAZ91PmS5_gt^(>FGZc*F?O! zQ_nAx{e6pjua2Q$49R}8!_`*K^ngQV76%TT!UhlP=8oRi*|SG%F~|%KSiFcg~S*D5~crt z^aNlCd7w3V&M>*IM~80q^lAH<&j%p*;?RwwW`e=pAJ12qAXOtzmt{v<+?(jqRwLyL zcf2~y8azLn!>^ZcN;?0lG%RuJ_8Z#|2)?}8WQz8v3=9akC>D@Q@PGZ&=G`8{n-NqV zV;Ke3J1#B|qLZnKqc2nJr_aeiBeF^Zs+ztA-Bh}XmgBR5zA-R@)RWj<_gfxs=3H@ak<7i^oxRJ?%vFi#>k8;( zdO_r^UGR7JZjQxKD3n}8&*pvko*?^g99&9OoEc&={K0rxC`lM8A74;&hf703}u);(dj{p04cw z(T#!J7?W=u6_F0hjLpI`6^v>PthQVM}mi_SK(t0l?6qkNm2B zp7_f?j*}{>r2yoG6bA6m4HDd@P01xJOC?2;@>Ydm* zCg)eBHE&%#5Jl-qLHOrLH&eX1v71sLFIz_)M(V3TT4T`7Do%*Alv<#Lse)GZZtdM* zN6(pc1ygG$ZOlAN3DWpu%DYVu0(py#{qG)`qHjITkKSy{B);{Xl)PtvtCOc=6*idh EFQ9^YegFUf diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index cf7453aeca7e38d8cd92acc1fd25f9abab89e117..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2676 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 H&X4~M)f(SO diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png deleted file mode 100644 index e5e6e4609b8c16550037bb7c1632299ef93eb163..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2834 zcmb`J`#%%x8^>4A99nb8B$*1Kl*eJnDastzX0#;DsWPYfZfs6ZPSs*~h|L@tLn>wt zdC&-r%4x`Xnv4{WQ$q0|hxA>~zwrIuuh)HFulu@xxelM}e!Y{N(e_dxB@h4rkU}`v zU_`d{*OuHXI-Ob%p9KIw?-4duE`N+J<~0UEkkEt;SmQz?0uGlw0GFUjItEx}IPB~* zw0VrMPoO?3B1xxPMWuU??5CGf51FAp()P3hQ0So|c@BiO%Ui{j3$Ui4Y z$_{_q4;gOnNHVTDmBd0Qg*%!$borCJoLOV4mbG*|Xq;fY!g0dE9ES`YyQ{%1e`$B` zoLJct+34%HT$wr$GLfXdwY6%y1Q?0tu3st zuP-eT8pn$z%v0%ov!^q=Mn*>Xd_L{Ced_oB>aDXX_>>CnJ%_%;IjyG4fpQkFs>=y=+dG@9 z_Gw4C(VHY6pB6oE->Qe82CH(&S)&?_2$|?Z(p=g*hAVYy4%7o^|u`l5Y9SP1`K> ziNeR9lQLUE;DVs?kb;8il`9<`9a5MZEkL&SqA$02Y|0-pLNtOR#jDfZ)U~380?QCF~LZY9Kt*WVZ#pBgzH^hU7TA2 zli7IqbxF+ukL=0R0SZ=?cE4_%AI>mI)Dsn~g{Wg{-vO`iRXB{rG zT>^loNp*@et$9-&$-Tqss&T$ogC?3hJv_9tyIZs})o4i#q|&a?!;z@e8pER{U&0$p zQLXe7PB}e(1?IlLaZjZqjtsj_f#k2fQ1Mq5^==Hf6><4`Enn%Y@i z?pO1kP&YMSj$zvjj`8^}PX-=|i3K)SKDN$z0uA1bj;+jkWCtQYaxIf;xCV2q@v1B^ zk60QkLY{ESf3BSzvb#FlS5Z+Rk{O%LPDIIP*$u1SMRAA5f{u;Nhhi%fj6Qt4f#G^J z6Q|mHa*em$pfoQ}H!wp^U-ql;2P%3(hI}pfc58LH-4o)cH}URugn=F=?>RB7g}h5Q zP#rqy``KXFJJv_Ie%@Zvap-SmUteE0UTn7;=grH=_`M$7@{uXHX~gl{`Zm>Z9tlyk zDZ(%qjQ69X>8sgX74kL=<2O1%UYynEPOR zqSaL(cTp!_sSW`S$v--%D!TB9=a+fFG8IU}J8X<1rulJ1rQai;EPkFCr3Td4xBhhn zc-gaq@!T^HZoWU%?8sV461-4vPO(40Fo5kpCeuM&$L5iF8FN76GiSm#S9}Fw)8VyE z%gwh}{en-o22NxqAJVmZcZE%3yOtd}7waKV(_Kr}id~gu4bVi&lU!0t4+&8I(HHMT<4Q|?Ips>F30PgyKOwK zJ#K4amz)7R+0(h$3?b1{r%Y1m-P4Lbv{CdD>3v8gQWRqW-+~&Lwi)M9h!^(is(rEt z#R;`KR}dehk=S=5r?clH?BfG#lM}bBoY1!zAj-W>l-(AkP2WNpcD?;D?t)h}5{>I} zNa36IXq5VqQSi=c(xE@l!LLfywb!ONhug#utd77P;(1oPr}Kn9@Svgcp0 zxr2*QO%qI;NWiLU&5i^BZ?r1I->&F zD<>i$=oJ9(>2Q|W;m((+DcgtJ-zQ#kPvQnE#97=wB#0=(Bdt>ngsdUuZZbk%v2^(J zR7YC8?EPd&aUdFvwu4^`zhGQU96u6h+dlH;i#AA;7pnKbnCWwwHWPaK)%G!^ET;4x ziZ~`%+zzf5cZMZ{(}#5$sLPS&#PUY?vkpXF1{pG^=)=K22ssM8Cp%f^I)IzFkHw-3 zY0~%`DFgF5quZ^|-x5PTlEx0S)h^x#UA%Jxia736AFOPjW}RF8a3;muABp)7m9+|< z2wZvi*hN8&PJvQ*&=AZM+Dm%_$~p}(JMN#p`VIy&yjzh1X#a(P)O~rio{ZSQmh>McX`sUnBn)_bx?c diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index dd7b98c983d595c67b6fb80aae5f65305b2081fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 368 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 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/234.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/234.png deleted file mode 100644 index 5b0f947cbf2c6e30a3e0515193078c8f58ed895f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3490 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 TzuOnC_TsTHvAvBna!>sqJcv8A diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png deleted file mode 100644 index 6c3c266c810b90288dc12b5d9c42dd713b91ea35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3756 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 UY_jS(zmfn`q{VgWRhOuL04{XN{{R30 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index e56082e8ff571e1a25d68f8560c091512b4885f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 507 zcmV6ooT)ZvHTsh(<_}&O#7ud=V>O!`BdfA;A~0v9=HdMT9gq{sbnIxmye@BJ3o% zyRgOHDulTocg~zS!-&ZGg#zn$>Yu9%>n~MG=?}&jW6Zpm)$v1FmH_a~A|f*y>p(f@ z_xpXj-98?VFbtC}_yWMOyJDpCPa|62KULazl z(fI5qBHOmx?Y7AYb+`av+x9b;bMAQ_=lo4B0OWZ-91iF6`R#V&oFn4(dL56)>-Bmt z7ziN%;GKC9(RJNUr}H_lQYr|7BuNm_ECrZFV2l+-@%&RtNkpFK8HTNuXD=qzH=;6C zYUQLJoH6#2KHth6rBt5hj^pT)e39i%E)fMm(ChV@&8B5pQ53~-Tqjy9loCReWhtf9 x+t>aD0M~WDmA~3d&z92YpLs9--}yhs{QxGM2dCh011|so002ovPDHLkV1mqG>{b8( diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png deleted file mode 100644 index a38c69a7d41dfa031dd64fe61c9c8233c1613e6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 543 zcmV+)0^t3LP)61b7f<3r^aca&1wn!v4ch&&_01#3fAC#o25aOdpDPUS5vJBO#>SZVv_n$8jj7nx^GFRz-%0h`8VHCzDC5)iO=fG)>F0`u+al zaF9|;DRZ8)lEqmp7TfK%)9Ku^3ByoIS*z9V{dl}Yh{za=<9N5**|wdgX{A!(ocDUY z(P-3cHW4wuL~VQGWOZEK*7VfaiJB>C`Zc%ohMe zQ4~edX0sU%hxcobvxqTf+xG2t`xCfcuQwVE#@G`E4G~YLlVKRYvj*^eKS>fq{3-w< zT9!4R&)4g9Hp86r*=*MBb}h>iLgdd{!6e6V%oy{1-*sI?B!mz`E|<${wHlAdVHoPV zuIu{aEUH$k0B}B^-vk8^1VNgnjIn(0rFuXqrIfypix5rIKAt~WuFDAJKeKS)tvz|+ zD5W^(8H+gQ1!~+LyE_lWWfTov+zpk|f2e=2I(EO8Jha hPb<;S$Lqfb_yq(zGQR#c#3=v(002ovPDHLkV1nk@`Un63 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index eac36c2654902a45a1e2195f9121b9addb93e7cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 634 zcmV-=0)_pFP)@2JVu@gVSZ}5x6Z?Rgz9}on=Qn1iMg=Eys-1~|Fk==c~ ziO=1AJiJwykes<^&YXLIh@77&=KN5-fM3A>A25ITORY5`I*tPXj4{Tt^#BhTNSdZZ zB!swMR7w#M=NtgO1o(VDd!FYw&T_fjY&Kf!D2i&eTCG+SLZoRbgs{S5gB7i zlC)Z_>2zu|c)45(g@TQ$ndK1*A&$r6{q=okYWw{@j$`{FVM}!`m-Bu9cRvMyFbvJ` z_-<8aopP_t_3vh``-Ej4EM@$+z*5RMj?M5l_t?aeF9aqc#@Ki~j^o&MT{Hd=(f9qV z5i?uZyI!xxm<;NTd9AgSGM~@C%bm|mnYXNSe|-VKb=^!e@xfgEy2mfz7x1U`3+61G UQT*}v3;+NC07*qoM6N<$g8!j52><{9 diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png deleted file mode 100644 index 3bc27c6ec224d89169337d929590cee657d6b449..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 773 zcmV+g1N!`lP)3?eofjUWhKUtfE@9wNqZyu7^J zYPAYJr4(Zf02c1DF-^DIJv}|Wy}eD6#99jgQp$F_y}rJFa&oe^wuXqMfT}3uy&n#T zQp&Tlv%|wfLP#9Pj4?up_ug7-j2RAxH#aw(PG>Y4MNza^AVg%0^?JRXogG9JLRf3Z z`xFr^gt)xCY`5Fi+As_knD*ZLEXydR=jZ4Bem{yLW6b2KthFyMFG{IqvuTVen{MS| z$T??>5ke3`9v>eIzE1{H%H7@Fm6a7KrPg|}b;uYK1OXxB;o;%=`56GlKX z`NcW^KDP-WVHj2`MHRDbE;jY8qXzn8_cBT;V~kQdIlLB=_3r>>S(YRTrPNwGE{RE! zAmU8JLWTTH(^N`%e}Dh<^u#%zR0t8bwzgJRSMxk?Hk&^LRCMwocMx#%cjmg*ofGCQp1rw=%3)et3Pz_WA{l7i{5`>U(vxOF diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png deleted file mode 100644 index 7475484ce5f383cc9b61f0cd1cf5f6c5bf87221e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 787 zcmV+u1MK{XP)K#j1sHFDzQqe5^E&EexAa6Z;Wxy^?t+{ z6G9kcgb`KrV~kQdA+-RY)9EOs5JFd1SLf&Fx3{-x zno21b78V+f#>&dd>guYLGELL`*l^j4#mmc!wKk69-QC@}xw&e!8b#6c^mG(O)oOKi zb~XrtqoX4LXti3yCNHQ~mSv1FYwh0N-s$P-{QP_vhVS)&F{YHpaU29e{&PZ|m}Qv| z;`sRZ?CflDanU)Km(hCw0O!13uOoyAA(QAtV@#H1*4k#X$vOYngT|Q0$H#WN9fn~s zwO9e25JI(D4IvcAF{QM(modiH+L@V|gM$O5)cX3m_kKcHN~xQhn~RH!`}=#wSRYoF zWsOE-ZEY=0(>RX9F!bJ!y|%pfgpg*lxwEr#cXua*=*9BhE2S7_993g~r zKK7F5oO8~twfU$Yu$35Ngb-570$8k=X#v2u%_yb(YCRl_K@8BT?^t>BDY5>qdm?#@ zzAuGG;b^eh?RJtR0MP65Ns{E3$A5;kv9Y1Gu2d@TSd`M|=jY|+<=of`XD)K9_TGOx z=Kki)h}Fv97h0{>fGv+wTG--?zd_{t=V0_<-yKHwG*a4WiB)2iSS8k5`~lim^?RUV RpY#9#002ovPDHLkV1f#3g_!^V diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png deleted file mode 100644 index 5bbed66aa6b0af7a2490b191f5aed18af0d3c104..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9793 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 Z#L(pXBwO#ndjO6b*X~^XtYQB2e*mq?G=cyC diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png deleted file mode 100644 index 2b67d472e5065bd0b361731fd76606b8f9f412ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 873 zcmV-v1D5=WP)fNs=g~Fvf%sjPZ0R(=-)A7(UPQ?(gqC z&(m6S&KDLI78e(5wHg3GDV3(F@ov$SeZ)?ub9#DueSPhDUY2D50F1G1+jDbs^Yin2 zdwZ=`D@{{MY0k~YOR%0zVF}O-Zq=fR;wkY%$J>G zQ6@>k80&Vshlht!%6h#ngiuNio}rYwuG?rd%ws;v{7R&?&aw<+d~tE{@bKU`PL^du zoj?4v+1c6tV{yJzN}0+PLbTg$O6iab4b#WR$J^VR@B5tdqV>{nlQN z$cqS}IF6T>myeH+F~-ei697O6QA!n5<0jUOv74KlPN&oDb}6N!Q=XKv(P+eR97WOH z-QC*STJA{@=J--6_5S`oGc$8^baZlZvb40MwI2NsgfNcdBuRYVKR7rzKR@?nMu)UB&kHcDvo?oDY@=A>{e_`Rwd$cXxNt_l&WEln6pd z2+eZSZ1#c@1D>3Lop$5W>4UpYHFI~yAt zmSqi6VvK_zSY2J!T932G%HM4m%d#xXveEU`Xg?oC(U_BNeoE6BB}vj>1^(?GXN*~v zH66e6vu7HcA5l8%K#ZbVnU)4Ll@Y14IY6V7ABs7nU#?bF*3jtAhC5|VMVAEwIIc@ zonzm@w-w@}LP=>aS2*=r>V}uZ4 z7^Z2uzrTNadYY!`aHFlY0Dv)8N^NXxTwh-sW2RQGQcBLbwRUG`=j`k(Ns=%OhrQ)m z%JaOJmzUYuS&T6NFvh09YOU2;2SIRha&mETv9z?Ll=?Jtd7jVD&%1pn+*_seRVn2j ze13jjtJMJDljgwRLdZL* z9=6xf(UA~hVPRo+cNgR0ieB)SQp!BfqbSooc{N@-!gGET4E>-C#c6L`}FkG>-DDWU$?inTdmgR<)xJJoz6RbU0+|{ z+}xB>CP`BASp_vqDV68B^MP9HR;$(T_bH`sSEH16I-R|}y@P{;;YA??A;g^v6Ur;4 zRBQeC_*k#k`~Cii8pyH?06LuxLWpzD7<2z{a;$`qAPAfv7=2(8LOjo-ltxiRDRmzw zU9W@?&iOaJ2xH9m{lac=3_9@rT7UayTg@^m){6DNoM+)de7yM2v6?}=6((a00F2Q( zj)#>o#u)qP2RP^Dt>AcOZ)uuRN*QAh4-dwek2`LRnVXx7qcs^8f$<07*qoM6N<$f_JmIaR2}S diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index 155b4ec404ae8eba2240ec6ee774f6a10aae50a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 849 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 IM6N<$f(f3)z5oCK diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/66.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/66.png deleted file mode 100644 index de80a97a9ab5be3a54fde5ed4f3a17f4909c5402..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 985 zcmV;~119{5P)jwY}vw(%gIJ>2N@@K4291BhG=|2|7#c%kXbg>^F*L^L zhQIp(Qp(RwGynhq@Yi5uS;jdRLS$Jcr33&d81T~|ga{!BA%q1)1SovokW$8RoMjmR zfKocZM?D%sNC+8miwedNLd0>55V9=GFpMaQZf|cN9vw2CyKR@qzo@ttD zNs=T|O66aQpvtn0F-8b6O|#u@pPrtcpPzR+9mZIeW%-A9U3YG7Zed|zdwaXpY9&d6 z5Gqk_Fvc*(rfFVWT+5>G-fFct=NRL%V5Dg(grJn( z-QDf%>@dbW&*Pj+DTNT9JrP0}<7&0Kyu6&3ud?Vyk|Ziq4-XH0->+0EX_|hScv4DL zzN*!#`Z;>Bf<;v)&beh-&(F`7mzS>Va?blXt2%30*7WprQM-Xh2T;|CbDm|{&CQMP z`;OzNoc_{YUtgt^X_~sOtNvm97)s+PrIb<#q3`=?n)Z7w008HFZ*Q;JY=&XDy1Hr@ zhHcwIh!KkwE<~+u+xC6G+wFEboqmB1A&jDEZEbCTf1ePNr;SRL@#lsrL}zDb>+9>s z$H%Vg{!*H9U_wZ{-QL*PXfzrtD=SAwNBQ!y6kH($V{AZXtDsWl3uCOJv2?8=001HM z+hom~!eN}pGj9ypp2?r4$0j}f4HNXk>J zeY0HehQ|1hu4(cnyjKOqoRm?R_k|c2)=ZBfUt+VQ|v{I>*Yd2KT=K(W-K|u;33K=sB#!y!l-ELQ% z_CK#hAgeECBckl@Mav zHe<}T?LuozYaPciZr5>~Y33`XPzsE(f@(}_9Yqo69Fcmx-tFz}y~B3ag4tfQDXoQMUmq;obx0}PEJlPE-qeQU$xe@ZU1x> zDW#Q4rBbP!otMRRi@Ih47RNEAlrc6K4EFc;yWMWH*=#nOgpeOC3O@mW5aRnj zUPsg-f@!U#l$A<_5OR2U*zI;(t(Fi%DfOu_5K^nv78Vx9UyBHqrYSm%hlhvD%ge>Z z#WYQ`Oq>v{wc|KwV*rqs#&QnOQcCpKS65e3%53qY2nZpb=Xsu&uf}MONKq8Uag3hu z>FJ4cuC>mfQcAtOz4^Ycl&aNgsIg)lMJc6}(s3NrKomtm5HQ9*izS2*DW&VWJ3Bj^ z^Lo9`IkzmUR4OT@a*5F_ucQe#ihDvwpCNln>Bq;%>FMe2?(Tn=Do{$5BuN~{jYi}7 z`MJ~SFvjlh@6nNDguBhnO~-KvA*0dg;NW0uYby*x&Uvu{GzusPg6r#R&-2RV@1ON=<1eW1p#% z$~|SJ`5r}6LH{{mxtGAjavtam&>u{kj6n|z=iKFzv%42U^!t4wM73I-xYzghccm0# ztk>&h=gPkmj8X~!Yin!g=jT$&$=ryHTRT?=G&k8HH)1^ z)?zXb17^Ssm;p0j2F&_znB5GXY0*6sFaYTH`#}(7mn~AtKdChU0IutHI-Mwr zGIC-7SXx?|U9%+5XV6Ni;c(b!H2&AW27^JVRLZLin~BD-lQSF+@fCoHBEz=NxT{m> z_dM_^+4RO^+qUbvxvOjDFR++6X21-X0W)9*%zzm%17^Ss*w>AJ0DmhVx?5%OivR!s M07*qoM6N<$f+jNc`2YX_ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png deleted file mode 100644 index 9b208cc13ebdd7018e7e7382dccafb54d6b2b8f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1080 zcmV-81jqY{P)J4LVN+F2#KBwAt6LVMF$0-fM_5@ ziXpMRYkT&J(M6}bBjn++pL3qusUpX+_ve|N+4=7hrIhi1H){L^rXe+?hSZQ6QbTG; z4XGhDq=wXx8ZvR?|Jj?OC;|Wg_^C}Ngb+gLcO!%({%Vr8GN6=_QpR!o6A%7{%mDBs zGh>W%j`s`zWE4e02*wy=ESJlDdOv(OrBp7LONukaa1FyyN@-aZ=iD%iPN#Eud3kkp zRj=1w*98FEwo9eb^z`)H+}!;9{Os&(p-@1ucDvnmcy=MthxvS-F%|^D-rnBf;o-x> zLl}k_u^6ssj4{V?hKGkMmCDY}4t^lpwlkf0lu|(uSeC^YdwF@;-rhbxKQ9)G#bS{V z@@wM3D^E{P*VosC5S*e3W}s9cg%E@g&bgGbTCHAOTue<(g<**4=eLH*W@cu_FpM~k zF-2v!PAa9sFyx#QLTa_z$;rv&#4bR5StO}yg!J{F7~gi^|K zxxBx>zp$|2c^-Pww(T$s0igE>`ZM7gMUhg95ORNi9|XbO-5q1BheIf(zVENBtV~Qy zG#U-g`SbI0xm@n%p3E)l0O0NItxza1#x^%M&(6+@#bQ4XM}IQLJkMKPTs%EJRZ6+8 zi;v1~o$U4#{XhZ$E>k(@Nl|64pG>2-(#n8_)R2Fl#?#d=`YCr^btk>-nhK=rx{XFd zDV5LXdsh)cNT<^gLQqOOoldLO8W_lGwYt8(esgnkbaXT{H1v@_5o~R3ZFzYarJeEd z@d1Fuj%j0K!!V5Fgdy+90$Fb*mDBJX@*5MhDf$<~&c%CPO=zsf-QfgTio`)F>$gdT> y0h^P0S*Ia2q=wXx8d5`QNDZkWHKc~@W#0fWWE+sZ@c^y>0000-u!<{}aHBG0wTJ>lkChFecd@DWwoX2!Rlyl+Ke&`@S!wv@DAd zqG=lEyi%!DDwTS@-syCrD58|6)9KaK)qFmm&*!t*tmk=}rV&Df5dD7NFpMc0IbPtm zw>K%JZQBT;APA0+kI&A|YPFj0`@?fPbYorDUDsV+Ue4um`}_Nwo11V7<|b%y&N=6< z>mr06A0KyjcW-WPJkPT%%XQt)QCdnFMbXR4%kAxLsZ`Q54bJ>@&#)d}Kn{YyG);t1 zyWK98O4Vw0Wo0D{Lm@;MhF?uI3}bO|F`Lc8Kbd;Cjw||yVJM}97<+Vdba!``$z&K~ zLWt25qbMSTEG#Sl@YGW@jxRD3LNLaSM&tbaJe^K6#>VFigoF^bZ6}k-c>;#h2;O*o zecfucCdoz^V@O?zL?V$$#N^WY)C-I-3_0gP5LlL_Xp z#`^!?2WE_6jBVStEGvqlD2kq+pE1Ux%NvaGU@+L&*x1_I;+!XwNl4m`C=?2D z)cJ809+c9hrKLn70SS5R9vOy_$zV~kDHoU?xTed{~<=i%f}vPVkdQNRjVQ>x`u zwX%N|DbD#M5eO0z1g)v-SU&;G7;840j4{V?48s`Bj1c0Sx7%$1ZnauL5X=oQ7-xNb z{ovrhw(X0Hi+a6on&wx`p#H3_trd$!XpaVi!Jj{WV$!RptXlSZJxb~OvY}KeU0zCc%-Ng5O5Cdk^!Wd&A1T+j_F3$N_LxD?!`U4s*Aq2#L5ADRv z0h^{d7!2C&wh$th%S|r9L68$dJUu-jgg`K`EX#46nFc0=c%DZo?f3iN5NM>7j^ntl z3vmwaC}tWMOsebpw_Op^($qb9b_8OdwY8liD%es#ar0K;X+{;zP`Su zQmJpu?Q}Yx=jpmWPZkEv6$IJ|ua|~0Z5&O1?7;BEu-EI2y$#nsy;*V`C%%`ZF}}6@ z@KW?Y2_~^x7^xpjD_{kzfEBO;R=^5a0V`kytbi4;0@k#D0F$9%ob7-AssI2007*qo IM6N<$f&rg1Gynhq diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index c9e28954303609c7fb9d15c414a65cf5cf555f95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1187 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 op&%55f>3ZE6oi6M5K0K+H_BYDFR-N&o-= diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json index cff1680b..3df2cdd6 100644 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "1024.png", + "filename" : "festabook_icon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/festabook_icon.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/festabook_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4f34f999aeff7f729a6cbf5ae1a5cc5f376dc292 GIT binary patch literal 41934 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 2.1.1 CFBundleVersion - 2010102 + 2010103 From d5c11508b589cd983706d1bdec5c19cb1b39f1d9 Mon Sep 17 00:00:00 2001 From: changui <122252160+changuii@users.noreply.github.com> Date: Sat, 2 May 2026 14:24:09 +0900 Subject: [PATCH 48/54] =?UTF-8?q?[Chore]=20cmp-ci.yml=20runner=EB=A5=BC=20?= =?UTF-8?q?ubuntu-latest=EB=A1=9C=20=EC=A0=84=ED=99=98=20(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: cmp-ci.yml runner를 ubuntu-latest로 전환 self-hosted runner에서 평균 21.3분 걸리던 PR 검증을 hosted runner로 전환하여 약 7분대로 단축. PR 피드백 지연 해소. closes #184 Co-Authored-By: Claude Code * style: 코드 포맷팅 개선 및 미사용 임포트 제거 * **style: `FestivalRemoteDataSourceTest` 내 트레일링 콤마 추가** * 코드 일관성 및 향후 변경 시의 편의성을 위해 매개변수 목록 끝에 콤마(`,`)를 추가했습니다. * **refactor: `SettingScreen` 내 불필요한 임포트 제거** * 더 이상 사용되지 않는 `FestatingUiModel` 임포트 구문을 제거하여 코드를 정리했습니다. * test: `PlaceMapViewModelTest` 내 장소 데이터 로드 실패 테스트 케이스 수정 * **test: `placeGeographies` 로드 실패 검증 로직 수정** * 기존에 `placeListRepository.getPlaceGeographies()`가 실패를 반환하도록 설정되었던 부분을 성공(`FAKE_PLACE_GEOGRAPHIES`)을 반환하도록 변경했습니다. * `uiState.placeGeographies`의 상태를 검증하던 로직을 `uiState.places`가 `ListLoadState.Error(exception)` 상태인지 확인하도록 수정하여 변경된 UI 상태 구조를 반영했습니다. * **test: `getPlaceGeographies` 실패 시나리오 모킹 코드 정리** * `placeListRepository.getPlaceGeographies()`가 실패를 반환하는 테스트 케이스의 코드 포맷팅을 조정했습니다. --------- Co-authored-by: Claude Code Co-authored-by: oungsi2000 --- .github/workflows/cmp-ci.yml | 2 +- .../setting/component/SettingScreen.kt | 1 - .../festival/FestivalRemoteDataSourceTest.kt | 2 +- .../viewModel/placeMap/PlaceMapViewModelTest.kt | 17 +++++++++++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cmp-ci.yml b/.github/workflows/cmp-ci.yml index 4c290b7a..c40bacd9 100644 --- a/.github/workflows/cmp-ci.yml +++ b/.github/workflows/cmp-ci.yml @@ -10,7 +10,7 @@ on: jobs: Run-PR-Test: - runs-on: self-hosted + runs-on: ubuntu-latest permissions: contents: read diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 8e64ad58..1f04dc8a 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -38,7 +38,6 @@ import com.daedan.festabook.presentation.common.component.FestabookSwitch import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.home.FestivalUiState import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.home.model.FestatingUiModel import com.daedan.festabook.presentation.home.model.FestivalUiModel import com.daedan.festabook.presentation.home.model.OrganizationUiModel import com.daedan.festabook.presentation.setting.SettingViewModel diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt index a223f534..71a44138 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/data/datasource/remote/festival/FestivalRemoteDataSourceTest.kt @@ -39,7 +39,7 @@ private val FAKE_FESTIVAL_RESPONSE = festivalSponsors = emptyList(), instagramLink = null, homepageLink = null, - festatingVisible = true + festatingVisible = true, ) @Suppress("UNCHECKED_CAST") diff --git a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt index 294da693..10177421 100644 --- a/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/daedan/festabook/viewModel/placeMap/PlaceMapViewModelTest.kt @@ -188,7 +188,10 @@ class PlaceMapViewModelTest { Result.success( FAKE_ORGANIZATION_GEOGRAPHY, ) - everySuspend { placeListRepository.getPlaceGeographies() } returns Result.failure(exception) + everySuspend { placeListRepository.getPlaceGeographies() } returns + Result.success( + FAKE_PLACE_GEOGRAPHIES, + ) // when placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) @@ -196,8 +199,11 @@ class PlaceMapViewModelTest { // then val uiState = placeMapViewModel.uiState.value - assertEquals(LoadState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()), uiState.initialMapSetting) - assertEquals(LoadState.Error(exception), uiState.placeGeographies) + assertEquals( + LoadState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()), + uiState.initialMapSetting, + ) + assertEquals(ListLoadState.Error(exception), uiState.places) } @Test @@ -245,7 +251,10 @@ class PlaceMapViewModelTest { runTest { // given val throwable = Throwable() - everySuspend { placeListRepository.getPlaceGeographies() } returns Result.failure(throwable) + everySuspend { placeListRepository.getPlaceGeographies() } returns + Result.failure( + throwable, + ) // when placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) From 3d6c3cf73c40e23142247dc1608d89dfcd744070 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 4 May 2026 21:55:17 +0900 Subject: [PATCH 49/54] =?UTF-8?q?[Fix]:=20=EC=9B=B9=EB=B7=B0=20=EB=82=B4?= =?UTF-8?q?=20=ED=86=A0=EC=8A=A4(Toss)=20=EA=B2=B0=EC=A0=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=8A=A4=ED=82=B4=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 웹뷰 내 토스(Toss) 결제 연동을 위한 외부 스킴 처리 로직 추가 * **feat: Android 외부 앱 실행 및 패키지 쿼리 설정** * `AndroidManifest.xml`에 토스 패키지(`viva.republica.toss`)에 대한 ``를 추가했습니다. * `AndroidWebViewClient`를 구현하여 `supertoss` 및 `intent` 스킴 감지 시 외부 앱을 실행하거나 마켓으로 이동하는 로직을 추가했습니다. * **feat: iOS 외부 앱 실행 및 내비게이션 델리게이트 구현** * `Info.plist`에 `supertoss` 스킴을 `LSApplicationQueriesSchemes`에 추가했습니다. * `DefaultWKNavigationDelegate`를 구현하여 `supertoss` 스킴 발생 시 토스 앱 또는 앱스토어로 분기 처리하도록 설정했습니다. * **refactor: 플랫폼별 `FestatingWebView` 공통 인터페이스 추출** * `commonMain`에 `FestatingWebView` expect 함수를 정의하고, 각 플랫폼(`androidMain`, `iosMain`)에서 실제 웹뷰 구현 및 커스텀 델리게이트/클라이언트를 적용하도록 구조를 개선했습니다. * `FestatingScreen`에서 직접 `WebView`를 호출하던 방식을 신규 구현한 `FestatingWebView`를 사용하도록 변경했습니다. * **chore: 빌드 구성 변경** * `gradle.properties` 내 `buildkonfig.flavor`를 `release`에서 `dev`로 변경했습니다. --- .../src/androidMain/AndroidManifest.xml | 6 +- .../component/platfom/AndroidWebViewClient.kt | 34 +++++++++++ .../platfom/FestatingWebView.android.kt | 32 ++++++++++ .../festating/component/FestatingScreen.kt | 13 ++-- .../component/platfom/FestatingWebView.kt | 17 ++++++ .../delegate/DefaultWKNavigationDelegate.kt | 60 +++++++++++++++++++ .../component/platfom/FestatingWebView.ios.kt | 46 ++++++++++++++ .../platform/RememberOpenAppSettings.ios.kt | 3 +- gradle.properties | 2 +- iosApp/iosApp/Info.plist | 41 +++++++------ 10 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/AndroidWebViewClient.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultWKNavigationDelegate.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.ios.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 42b4591e..0f3c29b0 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -3,7 +3,11 @@ - + + + + + - WebView( - state = webViewState, + FestatingWebView( + webViewState = webViewState, navigator = navigator, - modifier = - Modifier - .padding(innerPadding) - .fillMaxSize(), + innerPadding = innerPadding, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.kt new file mode 100644 index 00000000..2d451404 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.kt @@ -0,0 +1,17 @@ +package com.daedan.festabook.presentation.festating.component.platfom + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.multiplatform.webview.web.WebViewNavigator +import com.multiplatform.webview.web.WebViewState + +@Composable +expect fun FestatingWebView( + webViewState: WebViewState, + navigator: WebViewNavigator, + innerPadding: PaddingValues, + modifier: Modifier = Modifier, +) + +const val TOSS_SCHEME = "supertoss" diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultWKNavigationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultWKNavigationDelegate.kt new file mode 100644 index 00000000..c4e1f319 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultWKNavigationDelegate.kt @@ -0,0 +1,60 @@ +package com.daedan.festabook.delegate + +import com.daedan.festabook.presentation.festating.component.platfom.TOSS_SCHEME +import io.github.aakira.napier.Napier +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.WebKit.WKNavigationAction +import platform.WebKit.WKNavigationActionPolicy +import platform.WebKit.WKNavigationDelegateProtocol +import platform.WebKit.WKWebView +import platform.darwin.NSObject + +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) +class DefaultWKNavigationDelegate : + NSObject(), + WKNavigationDelegateProtocol { + override fun webView( + webView: WKWebView, + decidePolicyForNavigationAction: WKNavigationAction, + decisionHandler: (WKNavigationActionPolicy) -> Unit, + ) { + val url = decidePolicyForNavigationAction.request.URL + val scheme = url?.scheme + + Napier.d("scheme:$scheme") + scheme.takeIf { it == TOSS_SCHEME }?.let { + url?.let { nsUrl -> + Napier.d("nsUrl:$nsUrl") + openUrl(nsUrl) { + // Kotlin Native에서 objc의 하위 타입은 필드 사용 불가 + ( + NSURL.URLWithString("itms-apps://itunes.apple.com/app/id839333328")?.let { + openUrl(it) + } + ) + } + } + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel) + return + } + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow) + } + + private fun openUrl( + url: NSURL, + onFailure: () -> Unit = {}, + ) { + UIApplication.sharedApplication.openURL( + url = url, + options = emptyMap(), + completionHandler = { success -> + if (!success) { + onFailure() + } + }, + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.ios.kt new file mode 100644 index 00000000..a454320a --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/festating/component/platfom/FestatingWebView.ios.kt @@ -0,0 +1,46 @@ +package com.daedan.festabook.presentation.festating.component.platfom + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.daedan.festabook.delegate.DefaultWKNavigationDelegate +import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.WebViewNavigator +import com.multiplatform.webview.web.WebViewState +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import platform.WebKit.WKNavigationDelegateProtocol +import platform.WebKit.WKWebView + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun FestatingWebView( + webViewState: WebViewState, + navigator: WebViewNavigator, + innerPadding: PaddingValues, + modifier: Modifier, +) { + val wrappedDelegateWkNavigationDelegate = + remember { + DefaultWKNavigationDelegate() + } + WebView( + state = webViewState, + navigator = navigator, + modifier = + Modifier + .padding(innerPadding) + .fillMaxSize(), + factory = { param -> + object : WKWebView(frame = CGRectZero.readValue(), configuration = param.config) { + override fun setNavigationDelegate(navigationDelegate: WKNavigationDelegateProtocol?) { + super.setNavigationDelegate(wrappedDelegateWkNavigationDelegate) + } + } + }, + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt index f125f2fa..9186f009 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/presentation/setting/component/platform/RememberOpenAppSettings.ios.kt @@ -4,13 +4,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import platform.Foundation.NSURL import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString @Composable actual fun rememberOpenAppSettings(): () -> Unit = remember { { val url = - NSURL.URLWithString("app-settings:") ?: return@remember + NSURL.URLWithString(UIApplicationOpenSettingsURLString) ?: return@remember UIApplication.sharedApplication.openURL(url, mapOf(), null) } } diff --git a/gradle.properties b/gradle.properties index 79f5d77c..98fd0892 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ kotlin.native.binary.stripDebugSymbols=false kotlin.native.cacheKind=static kotlin.native.disableCompilerDaemon=false -buildkonfig.flavor=release +buildkonfig.flavor=dev APP_VERSION_NAME=2.1.1 APP_VERSION_CODE=2010103 diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index d3ec0a25..3bd36f72 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,22 +1,27 @@ - - + - - CADisableMinimumFrameDurationOnPhone - - FirebaseAppDelegateProxyEnabled - - LSApplicationQueriesSchemes - - itms-apps - - UIBackgroundModes - - remote-notification - -CFBundleShortVersionString + + CADisableMinimumFrameDurationOnPhone + + FirebaseAppDelegateProxyEnabled + + LSApplicationQueriesSchemes + + itms-apps + + UIBackgroundModes + + remote-notification + + CFBundleShortVersionString 2.1.1 -CFBundleVersion + CFBundleVersion 2010103 - + LSApplicationQueriesSchemes + + supertoss + + + From d6d3db456535d10719c7663c76c974a950d5d0fd Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 4 May 2026 21:55:41 +0900 Subject: [PATCH 50/54] =?UTF-8?q?[Fix][iOS]=20=EC=9B=A8=EC=9D=B4=ED=8C=85?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C,=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=20=EC=83=81=ED=83=9C=EC=9D=BC=20=EB=95=8C,?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B0=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: `WaitingRegisterScreen` 내 권한 거부 시 앱 설정 이동 로직 추가 * **feat: 권한 거부(onPermissionDeny) 핸들러 구현** * `WaitingRegisterScreen`에서 권한 요청이 거부되었을 때, 아무런 동작을 수행하지 않던 기존 코드에 `onOpenAppSettings()` 호출을 추가했습니다. * 이를 통해 사용자가 권한을 거부한 경우 앱 설정 화면으로 이동하여 직접 권한을 설정할 수 있도록 유도합니다. --- .../waitingRegister/component/WaitingRegisterScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt index 30a5040e..2470ac46 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt @@ -108,7 +108,9 @@ fun WaitingRegisterRoute( settingViewModel.saveNotificationId() viewModel.submitWaitingRegister() }, - onPermissionDeny = {}, + onPermissionDeny = { + onOpenAppSettings() + }, ) ObserveAsEvents(viewModel.registerSuccessEvent) { From 163949df5d51fff734fb10befcfbc2c22e6cace8 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Mon, 4 May 2026 21:55:55 +0900 Subject: [PATCH 51/54] =?UTF-8?q?[Fix]=20=EB=A9=94=EC=9D=B8=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=ED=83=AD=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8B=9C=20=EA=B9=9C=EB=B9=A1=EC=9E=84=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: `MainScreen` 네비게이션 애니메이션 제거 및 빌드 플레이버 변경 * **refactor: `MainScreen` 내 `NavHost` 전환 애니메이션 비활성화** * `NavHost`의 `enterTransition`, `exitTransition`, `popEnterTransition`, `popExitTransition` 속성을 모두 `None`으로 설정하여 화면 전환 시 애니메이션이 발생하지 않도록 수정했습니다. * **chore: 빌드 컨피그 플레이버를 `dev`로 변경** * `gradle.properties`에서 `buildkonfig.flavor` 설정을 기존 `release`에서 `dev`로 변경했습니다. --- .../festabook/presentation/main/component/MainScreen.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index 4d2ffb79..f215ae17 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -1,5 +1,7 @@ package com.daedan.festabook.presentation.main.component +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -283,6 +285,10 @@ private fun FestabookNavHost( modifier = modifier, startDestination = navigator.startRoute, navController = navigator.navController, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, ) { homeNavGraph( innerPadding = innerPadding, From 09492d2fc75823aebb8f5c7d5fa9493f6bb97c9b Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 5 May 2026 01:08:59 +0900 Subject: [PATCH 52/54] =?UTF-8?q?[Fix]=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=93=B1=EB=A1=9D=20UI=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **fix: 새로고침 아이콘 회전 애니메이션 로직 개선** * `PlaceWaitingContent` 및 `WaitingStatusCard`에서 `infiniteRepeatable`을 사용하여 무한 루프 애니메이션 구조를 안정화했습니다. * 새로고침 종료 시 잔여 회전 각도를 계산하여 부드럽게 멈추도록 애니메이션 종료 처리를 강화하고, 완료 후 상태를 초기화(`snapTo(0f)`)하도록 수정했습니다. * **fix: 대기 상태 문구 노출 임계값 조건 수정** * `WaitingStatusCard`에서 대기 순서에 따른 문구 노출 조건을 `order > WAITING_NEAR_THRESHOLD`와 `order <= WAITING_NEAR_THRESHOLD`로 조정하여 경계값 동작을 명확히 했습니다. * **refactor: `AddWaitingInfoScreen` 키보드 처리 및 입력 로직 개선** * `Scaffold`의 `contentWindowInsets`에서 `IME`를 제외하여 키보드 활성화 시 레이아웃 중복 계산을 방지했습니다. * `WaitingInfoViewModel`에서 전화번호 업데이트 시 숫자 필터링 후 길이를 체크하도록 순서를 변경하여 유효성 검사 로직을 개선했습니다. * **build: 앱 버전 코드 업데이트** * `APP_VERSION_CODE`를 `2010103`에서 `2010106`으로 올렸습니다. --- .../component/PlaceWaitingContent.kt | 29 ++++++++------- .../waitinginfo/WaitingInfoViewModel.kt | 5 ++- .../component/AddWaitingInfoScreen.kt | 5 +++ .../waiting/component/WaitingStatusCard.kt | 37 ++++++++++--------- gradle.properties | 2 +- 5 files changed, 44 insertions(+), 34 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt index 885e78b7..2a0e5596 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt @@ -2,6 +2,8 @@ package com.daedan.festabook.presentation.placeMap.placeDetail.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -214,26 +216,27 @@ private fun RefreshButton( LaunchedEffect(isRefreshing) { if (isRefreshing) { - while (true) { - rotation.animateTo( - targetValue = rotation.value + 360f, - animationSpec = tween(durationMillis = durationMillis, easing = LinearEasing), - ) - } + rotation.snapTo(rotation.value % 360f) + rotation.animateTo( + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = durationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) } else { val remainder = rotation.value % 360f if (remainder > 0f) { - val target = rotation.value + (360f - remainder) val remainingFraction = (360f - remainder) / 360f rotation.animateTo( - targetValue = target, - animationSpec = - tween( - durationMillis = (remainingFraction * durationMillis).toInt().coerceAtLeast(1), - easing = LinearEasing, - ), + targetValue = 360f, + animationSpec = tween( + durationMillis = (remainingFraction * durationMillis).toInt().coerceAtLeast(1), + easing = LinearEasing, + ), ) } + rotation.snapTo(0f) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt index 994181e6..784e0fc5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/WaitingInfoViewModel.kt @@ -59,8 +59,9 @@ class WaitingInfoViewModel( val errorEvent: SharedFlow = _errorEvent.asSharedFlow() fun updatePhoneNumber(input: String) { - if (input.count() > MAX_DIGIT_COUNT) return - _phoneNumber.value = input.filter { it.isDigit() } + val digits = input.filter { it.isDigit() } + if (digits.count() > MAX_DIGIT_COUNT) return + _phoneNumber.value = digits } fun setTermsAgreed(agreed: Boolean) { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt index 2fbf4ae5..9bfa26b6 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt @@ -6,9 +6,12 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth @@ -31,6 +34,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -174,6 +178,7 @@ fun AddWaitingInfoScreen( }, modifier = modifier, containerColor = FestabookColor.white, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.ime), ) { innerPadding -> Column( modifier = diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt index ad16b090..048fb48b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt @@ -2,6 +2,8 @@ package com.daedan.festabook.presentation.waiting.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -193,29 +195,28 @@ private fun RefreshButton( LaunchedEffect(isRefreshing) { if (isRefreshing) { - while (true) { - rotation.animateTo( - targetValue = rotation.value + 360f, - animationSpec = tween(durationMillis = durationMillis, easing = LinearEasing), - ) - } + rotation.snapTo(rotation.value % 360f) + rotation.animateTo( + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = durationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) } else { val remainder = rotation.value % 360f if (remainder > 0f) { - val target = rotation.value + (360f - remainder) val remainingFraction = (360f - remainder) / 360f rotation.animateTo( - targetValue = target, - animationSpec = - tween( - durationMillis = - (remainingFraction * durationMillis) - .toInt() - .coerceAtLeast(1), - easing = LinearEasing, - ), + targetValue = 360f, + animationSpec = tween( + durationMillis = (remainingFraction * durationMillis).toInt() + .coerceAtLeast(1), + easing = LinearEasing, + ), ) } + rotation.snapTo(0f) } } @@ -244,11 +245,11 @@ private fun statusHeadline( order: Int, ): String = when (status) { - WaitingStatus.WAITING if order >= WAITING_NEAR_THRESHOLD -> { + WaitingStatus.WAITING if order > WAITING_NEAR_THRESHOLD -> { stringResource(Res.string.my_waiting_headline_waiting_far) } - WaitingStatus.WAITING if order < WAITING_NEAR_THRESHOLD -> { + WaitingStatus.WAITING if order <= WAITING_NEAR_THRESHOLD -> { stringResource(Res.string.my_waiting_headline_waiting_near) } diff --git a/gradle.properties b/gradle.properties index 98fd0892..3909b18a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,4 +18,4 @@ kotlin.native.disableCompilerDaemon=false buildkonfig.flavor=dev APP_VERSION_NAME=2.1.1 -APP_VERSION_CODE=2010103 +APP_VERSION_CODE=2010106 From 4c6eff9d3c2b10e00a6fd22868a5f0cadc5a68c0 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Tue, 5 May 2026 01:11:40 +0900 Subject: [PATCH 53/54] =?UTF-8?q?[Feat]=20FirebaseAnalytics=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A7=91=20(#192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: `FirebaseAnalytics` 구조 개선 및 자동 파라미터 수집 기능 구현 * **feat: `FirebaseAnalytics` 클래스화 및 의존성 주입 적용** * 기존 `object` 선언을 `class`로 변경하고 Metro를 통한 의존성 주입(`@Inject`)을 적용했습니다. * `FestivalLocalDataSource` 및 `DeviceLocalDataSource`를 주입받아 축제 ID와 사용자 UUID를 자동으로 수집하도록 개선했습니다. * **feat: 기본 이벤트 파라미터 자동 설정 및 지연 전송 로직 구현** * 앱 버전, OS 버전, 디바이스 모델, 언어, 사용자 ID 등을 포함하는 기본 파라미터를 `setDefaultEventParameters`를 통해 설정합니다. * 초기화 작업이 완료되기 전 발생한 이벤트를 `Channel`에 보관했다가 초기화 후 순차적으로 전송하는 `PendingEvent` 처리 로직을 추가했습니다. * 모든 이벤트 로깅 시 현재 활성화된 `festival_id`가 자동으로 포함되도록 수정했습니다. * **refactor: 플랫폼별 분석 로직 고도화 및 불필요한 메서드 제거** * **Android**: `Firebase.analytics`를 활용하여 기본 파라미터 및 `festival_id` 매핑 로직을 구현했습니다. * **iOS**: `utsname`을 사용한 상세 디바이스 모델 추출 및 `NSDate` 기반 타임스탬프 계산 로직을 추가했습니다. * 사용되지 않는 `getAppInstanceId`, `getSessionId` 메서드를 제거했습니다. * **infra: 앱 그래프 내 분석 서비스 등록** * `FestabookAppGraph`에 `FirebaseAnalytics`를 추가하고, 비동기 작업을 위한 `IOCoroutineScope` 제공 로직을 구성했습니다. # Conflicts: # composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt * feat: 알림 클릭 분석 로직 구현 및 공통 로깅 유틸리티 추가 * **feat: 공통 시간 측정 유틸리티 `currentTimeMillis` 구현** * Kotlin Multiplatform의 `expect/actual`을 사용하여 플랫폼별 현재 시간(ms) 측정 로직을 통합했습니다. * **Android**: `System.currentTimeMillis()`를 사용합니다. * **iOS**: `NSDate`와 `timeIntervalSince1970`을 사용하여 구현하고, 기존 `IosFirebaseAnalytics` 내 중복 코드를 제거했습니다. * **feat: 알림 클릭 시 분석 이벤트(`notification_tapped`) 로깅 추가** * `FcmDeepLinkAction.OpenAnnouncement` 모델에 `notificationSentAt` 필드를 추가하여 알림 발송 시점을 기록합니다. * 알림 클릭 핸들러에서 알림 ID, 발송 시점, 그리고 클릭까지 걸린 시간(`elapsed_sec`)을 포함한 분석 이벤트를 전송하도록 구현했습니다. * `MainScreen` 진입 시 `app_foreground` 이벤트를 로깅하도록 추가했습니다. * **refactor: 분석 도구 및 딥링크 처리 로직 개선** * `rememberAnalytics()` Composable을 추가하여 `AppGraph`에서 `FirebaseAnalytics` 인스턴스를 쉽게 참조할 수 있도록 개선했습니다. * Android 및 iOS의 메시징 서비스/델리게이트에서 공지사항 알림 처리 시 현재 타임스탬프를 함께 전달하도록 수정했습니다. * `AndroidFirebaseAnalytics`에서 기본 파라미터 설정 시 새로 정의한 `currentTimeMillis()`를 사용하도록 통일했습니다. * feat: 분석용 로깅 유틸리티 `logClick` 및 `ScreenViewLogger` 구현 * **feat: 클릭 이벤트 로깅을 위한 `Modifier.logClick` 확장 함수 추가** * `Modifier.clickable`을 래핑하여 버튼 클릭 시 `button_id`와 `screen_name` 파라미터를 포함한 `button_click` 이벤트를 자동으로 전송합니다. * **feat: 화면 진입 및 이탈 로깅을 위한 `ScreenViewLogger` 컴포저블 구현** * `DisposableEffect`를 활용해 화면 진입(`screen_view`)과 이탈(`screen_exit`) 시점을 기록합니다. * 이탈 시 화면 체류 시간(`duration_sec`)을 계산하여 함께 전송하도록 로직을 구성했습니다. * **feat: 가시성 기반 로깅을 위한 `ScreenViewVisibilityLogger` 구현** * `isVisible` 상태 변화에 따라 화면 노출 여부를 판단하여 진입/이탈 이벤트를 전송합니다. * 체류 시간을 소수점 단위의 초(`Double`)로 정밀하게 계산하도록 구현했습니다. * feat: 공지사항 알림 발생 시각(`notificationSentAt`) 전달 로직 추가 * **feat: `DeepLinkKeys` 내 알림 전송 시각 키 정의** * 딥링크 데이터 관리를 위한 `KEY_NOTIFICATION_SENT_AT` 상수를 추가했습니다. * **feat: `NotificationHelper` 알림 생성 시 현재 시각 포함** * 공지사항 알림(`ANNOUNCEMENT`) 생성 시, 인텐트에 현재 시각(`currentTimeMillis()`)을 `KEY_NOTIFICATION_SENT_AT`에 담아 전달하도록 수정했습니다. * **refactor: `RememberDeepLinkHandler` 내 알림 시각 추출 및 처리 로직 반영** * 안드로이드 인텐트에서 `notificationSentAt` 값을 추출하여 `FcmDeepLinkAction.OpenAnnouncement`로 전달하도록 수정했습니다. * 처리가 완료된 `KEY_NOTIFICATION_SENT_AT` 데이터를 인텐트에서 제거하는 로직을 추가했습니다. * feat: 전역 화면 방문 로깅 및 클릭 이벤트 로깅 기능 강화 * **feat: 주요 화면에 `ScreenViewLogger` 및 `ScreenViewVisibilityLogger` 추가** * `HomeScreen`, `PlaceMapScreen`, `NewsScreen`, `ScheduleScreen`, `SettingScreen` 등 거의 모든 주요 화면(Screen) 컴포넌트에 진입 시 로그를 남기는 `ScreenViewLogger`를 추가했습니다. * `PlaceMapScreen`에는 가시성 상태에 따라 로깅 여부를 결정하는 `ScreenViewVisibilityLogger`를 적용했습니다. * **feat: `logClick` 함수 내 추가 파라미터(`extraParam`) 지원** * 버튼 클릭 로그를 기록할 때 기존 `button_id`와 `screen` 외에도 사용자 정의 매개변수를 추가로 전달할 수 있도록 `extraParam` 파라미터를 추가했습니다. * `buildMap`을 사용하여 로그 이벤트에 포함될 데이터 구조를 확장했습니다. * **style: `FestaingScreen` 내 오타 수정** * `ScreenViewLogger`에 전달되는 문자열 내 오타를 수정했습니다. (`FestaingScreen`) * refactor: 로깅 로직 최적화 및 코드 정리 * **refactor: `ScreenViewLogger` 내 상태 관리 및 코드 정리** * `enteredTime`을 저장할 때 일반 `mutableStateOf` 대신 `mutableLongStateOf`를 사용하도록 변경하여 오토박싱을 방지했습니다. * 파일 끝의 불필요한 공백을 제거했습니다. * **refactor: `IosFirebaseAnalytics` 의존성 주입 수정 및 미사용 임포트 제거** * `CoroutineScope` 주입 시 `@IO` 한정자(qualifier)를 추가하여 적절한 스코프가 주입되도록 명시했습니다. * `NSDate`, `date`, `timeIntervalSince1970` 등 iOS 플랫폼의 미사용 임포트를 정리했습니다. * **style: 코드 포맷팅 및 미사용 임포트 제거** * `ClickableLogger.kt`: `logEvent` 호출 시 가독성을 위해 파라미터 줄바꿈 및 트레일링 콤마를 추가했습니다. * `AndroidAppGraph.kt`, `RememberDeepLinkHandler.android.kt`: 불필요한 임포트 구문을 제거했습니다. * `FestabookAppGraph.kt`: 불필요한 빈 줄을 제거했습니다. * `FestatingScreen.kt`: 임포트 순서를 조정했습니다. * fix: ios에서 elapsed time이 0으로 표시되는 버그 해결 * feat: 앱 전반의 주요 사용자 클릭 액션에 대한 로깅 로직 추가 * **feat: `FAQScreen`, `NoticeScreen` 내 아이템 클릭 로깅** * FAQ 및 공지사항 항목 클릭 시 각각 `faq_id`, `notice_id`를 포함하여 로깅하도록 수정했습니다. * 공지사항 및 유실물 화면의 새로고침 액션에 로깅을 추가했습니다. * **feat: `HomeScreen` 및 메인 내비게이션 클릭 로깅** * 홈 화면에서 탐색, 페스타팅, 일정, 웨이팅 바 클릭 시 로깅을 수행합니다. * 하단 탭 바에서 각 탭 선택 시 탭 이름을 포함하여 로깅하도록 개선했습니다. * **feat: 장소 관련 화면 클릭 로깅** * 장소 지도(`PlaceMapScreen`)의 카테고리 선택 및 리스트 아이템 클릭 시 로깅을 추가했습니다. * 장소 상세 및 미리보기 화면에서 상세 이동, 웨이팅 등록 등의 액션 로깅을 수행합니다. * **feat: 웨이팅 및 설정 화면 액션 로깅** * `MyWaitingScreen`의 새로고침, 취소, 상세 이동 액션에 로깅을 적용했습니다. * 웨이팅 등록 화면의 인원 조절, 약관 동의, 등록 버튼 클릭 시 로깅을 수행합니다. * 설정 화면의 공지사항/정책 링크 클릭, 알림 설정 토글, 내 정보 저장 액션 등에 로깅을 추가했습니다. * **feat: 검색 및 뒤로가기 액션 로깅** * 탐색 화면(`ExploreScreen`) 및 각 주요 화면의 뒤로가기 버튼 클릭 시 로깅을 수행하도록 수정했습니다. --- .../daedan/festabook/di/AndroidAppGraph.kt | 5 -- .../logging/AndroidFirebaseAnalytics.kt | 64 ++++++++++++++ .../logging/FirebaseAnalytics.android.kt | 30 ------- .../festabook/logging/LoggingUtil.android.kt | 3 + .../RememberDeepLinkHandler.android.kt | 5 +- .../service/MyFirebaseMessagingService.kt | 3 +- .../festabook/service/NotificationHelper.kt | 2 + .../daedan/festabook/di/FestabookAppGraph.kt | 8 ++ .../festabook/logging/ClickableLogger.kt | 26 ++++++ .../festabook/logging/FirebaseAnalytics.kt | 55 +++++++++++- .../daedan/festabook/logging/LoggingUtil.kt | 3 + .../festabook/logging/ScreenViewLogger.kt | 72 ++++++++++++++++ .../presentation/common/RememberAnalytics.kt | 8 ++ .../explore/component/ExploreScreen.kt | 6 +- .../component/ExploreSearchResultList.kt | 17 +++- .../festating/component/FestatingScreen.kt | 2 + .../presentation/home/component/HomeScreen.kt | 37 ++++++++- .../component/FestabookBottomNavigationBar.kt | 21 ++++- .../presentation/main/component/MainScreen.kt | 15 ++++ .../presentation/news/component/NewsScreen.kt | 2 + .../news/faq/component/FAQScreen.kt | 13 ++- .../news/lost/component/LostItemScreen.kt | 24 +++++- .../news/notice/component/NoticeScreen.kt | 16 +++- .../placeMap/component/PlaceCategoryScreen.kt | 12 ++- .../component/PlaceDetailPreviewScreen.kt | 20 ++++- .../PlaceDetailPreviewSecondaryScreen.kt | 3 + .../placeMap/component/PlaceListScreen.kt | 10 ++- .../placeMap/component/PlaceMapScreen.kt | 3 + .../component/PlaceDetailScreen.kt | 24 +++++- .../component/WaitingRegisterScreen.kt | 21 ++++- .../platform/RememberDeepLinkHandler.kt | 3 + .../schedule/component/ScheduleScreen.kt | 2 + .../setting/component/SettingScreen.kt | 44 ++++++++-- .../component/AddWaitingInfoScreen.kt | 28 ++++++- .../waiting/component/MyWaitingScreen.kt | 30 +++++-- .../DefaultUserNotificationDelegate.kt | 9 +- .../logging/FirebaseAnalytics.ios.kt | 35 -------- .../festabook/logging/IosFirebaseAnalytics.kt | 83 +++++++++++++++++++ .../festabook/logging/LoggingUtil.ios.kt | 7 ++ 39 files changed, 651 insertions(+), 120 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/AndroidFirebaseAnalytics.kt delete mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/LoggingUtil.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ClickableLogger.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/LoggingUtil.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ScreenViewLogger.kt create mode 100644 composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RememberAnalytics.kt delete mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/IosFirebaseAnalytics.kt create mode 100644 composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/LoggingUtil.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt index 070672e8..fe43cab9 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/di/AndroidAppGraph.kt @@ -4,7 +4,6 @@ import android.app.Application import android.content.Context import com.daedan.festabook.FestabookApp import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource -import com.daedan.festabook.di.coroutine.IO import com.daedan.festabook.presentation.splash.platform.AppVersionManager import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -12,7 +11,6 @@ import com.google.firebase.messaging.FirebaseMessaging import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.Provides -import kotlinx.coroutines.CoroutineScope @DependencyGraph(AppScope::class) interface AndroidAppGraph : FestabookAppGraph { @@ -20,9 +18,6 @@ interface AndroidAppGraph : FestabookAppGraph { val festivalLocalDataSource: FestivalLocalDataSource - @IO - val coroutineScope: CoroutineScope - @DependencyGraph.Factory fun interface Factory { fun create( diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/AndroidFirebaseAnalytics.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/AndroidFirebaseAnalytics.kt new file mode 100644 index 00000000..aedf79fd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/AndroidFirebaseAnalytics.kt @@ -0,0 +1,64 @@ +package com.daedan.festabook.logging + +import android.os.Build +import android.os.Bundle +import com.daedan.festabook.BuildKonfig +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.di.coroutine.IO +import com.google.firebase.Firebase +import com.google.firebase.analytics.analytics +import com.google.firebase.analytics.logEvent +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.util.Locale + +@Inject +@ContributesBinding(AppScope::class) +class AndroidFirebaseAnalytics( + @IO scope: CoroutineScope, + festivalLocalDataSource: FestivalLocalDataSource, + deviceLocalDataSource: DeviceLocalDataSource, +) : FirebaseAnalytics(scope, festivalLocalDataSource, deviceLocalDataSource) { + private val platform = Firebase.analytics + + override val initializingJob = + scope.launch { + platform.setDefaultEventParameters( + Bundle().apply { + putString("platform", "android") + putString("app_version", BuildKonfig.APP_VERSION_NAME) + putInt("os_version", Build.VERSION.SDK_INT) + putString("device_model", Build.MODEL) + putString("language", Locale.getDefault().language) + putString("user_id", userId.first { it != null }) + putLong("timestamp", currentTimeMillis()) + }, + ) + } + + init { + flushPendingEvents() + } + + override fun onLogEvent( + name: String, + params: Map?, + ) { + platform.logEvent(name) { + param("festival_id", festivalId.value ?: -1) + params?.forEach { + when (val v = it.value) { + is Long -> param(it.key, v) + is Double -> param(it.key, v) + is String -> param(it.key, v) + else -> param(it.key, v.toString()) + } + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt deleted file mode 100644 index 832d25bd..00000000 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.android.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.daedan.festabook.logging - -import com.google.firebase.Firebase -import com.google.firebase.analytics.analytics -import com.google.firebase.analytics.logEvent -import kotlinx.coroutines.tasks.await - -actual object FirebaseAnalytics { - private val platform = Firebase.analytics - - actual fun logEvent( - name: String, - params: Map?, - ) { - platform.logEvent(name) { - params?.forEach { - when (val v = it.value) { - is Long -> param(it.key, v) - is Double -> param(it.key, v) - is String -> param(it.key, v) - else -> param(it.key, v.toString()) - } - } - } - } - - actual suspend fun getAppInstanceId(): String = platform.appInstanceId.await() - - actual suspend fun getSessionId(): Long = platform.sessionId.await() -} diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/LoggingUtil.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/LoggingUtil.android.kt new file mode 100644 index 00000000..e0935c30 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/logging/LoggingUtil.android.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.logging + +actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt index a21d79f8..f2dffafa 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.android.kt @@ -55,13 +55,16 @@ private fun Intent.consumeDeepLinkAction(): FcmDeepLinkAction? { FcmMessageType.ANNOUNCEMENT, null -> { val announcementId = getLongExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, DeepLinkKeys.INITIALIZED_ID) + val notificationSentAt = + getLongExtra(DeepLinkKeys.KEY_NOTIFICATION_SENT_AT, DeepLinkKeys.INITIALIZED_ID) if (announcementId == DeepLinkKeys.INITIALIZED_ID) { null } else { removeExtra(DeepLinkKeys.KEY_TYPE) removeExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND) removeExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS) - FcmDeepLinkAction.OpenAnnouncement(announcementId) + removeExtra(DeepLinkKeys.KEY_NOTIFICATION_SENT_AT) + FcmDeepLinkAction.OpenAnnouncement(announcementId, notificationSentAt) } } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt index 5faf62fc..f8a43790 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/MyFirebaseMessagingService.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.service import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.di.androidAppGraph +import com.daedan.festabook.logging.currentTimeMillis import com.daedan.festabook.presentation.platform.DeepLinkKeys import com.daedan.festabook.presentation.platform.FcmDeepLinkAction import com.daedan.festabook.presentation.platform.FcmMessageType @@ -66,7 +67,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { FcmMessageType.ANNOUNCEMENT, null -> { val announcementId = this[DeepLinkKeys.KEY_ANNOUNCEMENT_ID]?.toLongOrNull() ?: return null - FcmDeepLinkAction.OpenAnnouncement(announcementId) + FcmDeepLinkAction.OpenAnnouncement(announcementId, currentTimeMillis()) } } } diff --git a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt index eb995f19..dfcf887d 100644 --- a/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt +++ b/composeApp/src/androidMain/kotlin/com/daedan/festabook/service/NotificationHelper.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import com.daedan.festabook.MainActivity import com.daedan.festabook.R +import com.daedan.festabook.logging.currentTimeMillis import com.daedan.festabook.presentation.platform.DeepLinkKeys import com.daedan.festabook.presentation.platform.FcmDeepLinkAction import com.daedan.festabook.presentation.platform.FcmMessageType @@ -90,6 +91,7 @@ object NotificationHelper { intent.putExtra(DeepLinkKeys.KEY_TYPE, FcmMessageType.ANNOUNCEMENT.name) intent.putExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, true) intent.putExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, announcementId) + intent.putExtra(DeepLinkKeys.KEY_NOTIFICATION_SENT_AT, currentTimeMillis()) } FcmDeepLinkAction.OpenMyWaiting -> { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt index eff253d0..5ba56c70 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/di/FestabookAppGraph.kt @@ -1,9 +1,17 @@ package com.daedan.festabook.di +import com.daedan.festabook.di.coroutine.IO +import com.daedan.festabook.logging.FirebaseAnalytics import com.daedan.festabook.presentation.NotificationPermissionManager import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import kotlinx.coroutines.CoroutineScope interface FestabookAppGraph { val metroViewModelFactory: MetroViewModelFactory val notificationPermissionManagerFactory: NotificationPermissionManager.Factory + + val analytics: FirebaseAnalytics + + @IO + val coroutineScope: CoroutineScope } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ClickableLogger.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ClickableLogger.kt new file mode 100644 index 00000000..c0974b21 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ClickableLogger.kt @@ -0,0 +1,26 @@ +package com.daedan.festabook.logging + +import androidx.compose.runtime.Composable +import com.daedan.festabook.presentation.common.rememberAnalytics + +@Composable +fun logClick( + identifier: String, + screenName: String, + extraParam: Map = emptyMap(), + onClick: () -> Unit, +): () -> Unit { + val analytics = rememberAnalytics() + return { + analytics.logEvent( + "button_click", + buildMap { + put("button_id", identifier) + put("screen", screenName) + putAll(extraParam) + }, + ) + + onClick() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt index e2fc6d08..f28ada88 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.kt @@ -1,12 +1,59 @@ package com.daedan.festabook.logging -expect object FirebaseAnalytics { +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +abstract class FirebaseAnalytics( + private val scope: CoroutineScope, + festivalLocalDataSource: FestivalLocalDataSource, + deviceLocalDataSource: DeviceLocalDataSource, +) { + val festivalId = + festivalLocalDataSource + .getFestivalId() + .stateIn(scope, SharingStarted.Eagerly, null) + + val userId = + deviceLocalDataSource + .getUuid() + + private val pendingEvent = Channel(Channel.UNLIMITED) + + abstract val initializingJob: Job + + fun flushPendingEvents() { + scope.launch { + initializingJob.join() + for (event in pendingEvent) { + logEvent(event.name, event.params) + } + } + } + fun logEvent( name: String, params: Map? = null, - ) + ) { + if (!initializingJob.isCompleted) { + pendingEvent.trySend(PendingEvent(name, params)) + return + } + onLogEvent(name, params) + } - suspend fun getAppInstanceId(): String + abstract fun onLogEvent( + name: String, + params: Map?, + ) - suspend fun getSessionId(): Long + protected data class PendingEvent( + val name: String, + val params: Map?, + ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/LoggingUtil.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/LoggingUtil.kt new file mode 100644 index 00000000..f99900a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/LoggingUtil.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.logging + +expect fun currentTimeMillis(): Long diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ScreenViewLogger.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ScreenViewLogger.kt new file mode 100644 index 00000000..dfdef6fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/logging/ScreenViewLogger.kt @@ -0,0 +1,72 @@ +package com.daedan.festabook.logging + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.daedan.festabook.presentation.common.rememberAnalytics + +@Composable +fun ScreenViewLogger(screenName: String) { + val analytics = rememberAnalytics() + DisposableEffect(Unit) { + val enteredTime = currentTimeMillis() + analytics.logEvent( + "festabook_screen_view", + mapOf( + "screen_name" to screenName, + "entered_at" to enteredTime, + ), + ) + onDispose { + val exitedTime = currentTimeMillis() + analytics.logEvent( + "screen_exit", + mapOf( + "screen_name" to screenName, + "exit_at" to exitedTime, + "duration_sec" to (exitedTime - enteredTime) / 1000, + ), + ) + } + } +} + +@Composable +fun ScreenViewVisibilityLogger( + screenName: String, + isVisible: Boolean, +) { + val analytics = rememberAnalytics() + var enteredTime by remember { mutableLongStateOf(0L) } + + LaunchedEffect(isVisible) { + if (isVisible) { + enteredTime = currentTimeMillis() + analytics.logEvent( + "screen_view", + mapOf( + "screen_name" to screenName, + "entered_at" to enteredTime, + ), + ) + } else if (enteredTime != 0L) { + val exitedTime = currentTimeMillis() + val durationMs = exitedTime - enteredTime + + analytics.logEvent( + "screen_exit", + mapOf( + "screen_name" to screenName, + "exit_at" to exitedTime, + // 소수점까지 기록하기 위해 1000.0(Double)으로 나눔 + "duration_sec" to durationMs / 1000.0, + ), + ) + enteredTime = 0L // 시간 초기화 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RememberAnalytics.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RememberAnalytics.kt new file mode 100644 index 00000000..4be6ca64 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/common/RememberAnalytics.kt @@ -0,0 +1,8 @@ +package com.daedan.festabook.presentation.common + +import androidx.compose.runtime.Composable +import com.daedan.festabook.logging.FirebaseAnalytics +import com.daedan.festabook.presentation.platform.rememberAppGraph + +@Composable +fun rememberAnalytics(): FirebaseAnalytics = rememberAppGraph().analytics diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt index 1564c94f..ede19e15 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.explore.ExploreSideEffect import com.daedan.festabook.presentation.explore.ExploreUiState import com.daedan.festabook.presentation.explore.ExploreViewModel @@ -41,11 +43,13 @@ fun ExploreScreen( onBackClick: () -> Unit, viewModel: ExploreViewModel, ) { + ScreenViewLogger("ExploreScreen") val exploreUiState by viewModel.uiState.collectAsStateWithLifecycle() val keyboardController = LocalSoftwareKeyboardController.current val latestNavigateToMain by rememberUpdatedState(onNavigateToMain) val latestKeyboardController by rememberUpdatedState(keyboardController) + val loggedOnBackClick = logClick(identifier = "back", screenName = "ExploreScreen", onClick = onBackClick) LaunchedEffect(viewModel) { viewModel.sideEffect.collect { effect -> @@ -64,7 +68,7 @@ fun ExploreScreen( exploreUiState = exploreUiState, onQueryChange = viewModel::onTextInputChanged, onUniversitySelect = viewModel::onUniversitySelected, - onBackClick = onBackClick, + onBackClick = loggedOnBackClick, onClearRecentSearches = viewModel::onClearRecentSearches, onUniversityDelete = viewModel::onRecentSearchDelete, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt index 63398b9f..8087ee9b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.explore.ExploreUiState import com.daedan.festabook.presentation.explore.SearchUiState import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import org.jetbrains.compose.ui.tooling.preview.Preview @@ -50,9 +51,15 @@ fun ExploreSearchResultList( items = searchUiState.universitiesFound, key = { it.festivalId }, ) { university -> + val loggedOnItemClick = logClick( + identifier = "university_select", + screenName = "ExploreScreen", + extraParam = mapOf("festival_id" to university.festivalId.toString()), + onClick = { onUniversitySelect(university) }, + ) ExploreResultItem( university = university, - onItemClick = onUniversitySelect, + onItemClick = { loggedOnItemClick() }, ) } } @@ -74,9 +81,15 @@ fun ExploreSearchResultList( items = exploreUiState.recentSearches, key = { it.festivalId }, ) { recentSearch -> + val loggedOnRecentClick = logClick( + identifier = "recent_search_select", + screenName = "ExploreScreen", + extraParam = mapOf("festival_id" to recentSearch.festivalId.toString()), + onClick = { onUniversitySelect(recentSearch) }, + ) ExploreResultItem( university = recentSearch, - onItemClick = onUniversitySelect, + onItemClick = { loggedOnRecentClick() }, canDelete = true, onDeleteClick = onUniversityDelete, ) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt index 2bd8de79..5f4b831b 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/festating/component/FestatingScreen.kt @@ -10,6 +10,7 @@ import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import com.daedan.festabook.BuildKonfig +import com.daedan.festabook.logging.ScreenViewLogger import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.festating.FestatingUiState @@ -31,6 +32,7 @@ fun FestatingScreen( modifier: Modifier = Modifier, festatingViewModel: FestatingViewModel = metroViewModel(), ) { + ScreenViewLogger("FestaingScreen") val festatingUiState by festatingViewModel.festatingUiState.collectAsStateWithLifecycle() when (val uiState = festatingUiState) { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 4e9c40c9..2dbe7325 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.ErrorStateScreen @@ -63,6 +65,8 @@ fun HomeScreen( onShowErrorSnackbar: (Throwable) -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("HomeScreen") + val festivalUiState by homeViewModel.festivalUiState.collectAsStateWithLifecycle() val lineupUiState by homeViewModel.lineupUiState.collectAsStateWithLifecycle() val waitingBarUiState by homeViewModel.waitingBarUiState.collectAsStateWithLifecycle() @@ -99,6 +103,31 @@ fun HomeScreen( } } + val loggedOnNavigateToExplore = + logClick( + identifier = "navigate_to_explore", + screenName = "HomeScreen", + onClick = onNavigateToExplore, + ) + val loggedOnNavigateToFestating = + logClick( + identifier = "navigate_to_festating", + screenName = "HomeScreen", + onClick = onNavigateToFestating, + ) + val loggedOnNavigateToSchedule = + logClick( + identifier = "schedule_tab_click", + screenName = "HomeScreen", + onClick = homeViewModel::navigateToScheduleClick, + ) + val loggedOnNavigateToMyWaiting = + logClick( + identifier = "waiting_bar_click", + screenName = "HomeScreen", + onClick = homeViewModel::navigateToMyWaitingClick, + ) + when (val state = festivalUiState) { is FestivalUiState.Loading -> { LoadingStateScreen(modifier = modifier) @@ -114,9 +143,9 @@ fun HomeScreen( festivalUiState = state, lineupUiState = lineupUiState, showWaitingBar = waitingBarUiState is WaitingBarUiState.Visible, - onNavigateToExplore = onNavigateToExplore, - onNavigateToSchedule = homeViewModel::navigateToScheduleClick, - onFestatingClick = onNavigateToFestating, + onNavigateToExplore = loggedOnNavigateToExplore, + onNavigateToSchedule = loggedOnNavigateToSchedule, + onFestatingClick = loggedOnNavigateToFestating, ) when (val waitingBarUiState = waitingBarUiState) { is WaitingBarUiState.Visible -> { @@ -124,7 +153,7 @@ fun HomeScreen( order = waitingBarUiState.order, estimatedMinutes = waitingBarUiState.estimatedWaitTime, status = waitingBarUiState.status, - onClick = homeViewModel::navigateToMyWaitingClick, + onClick = loggedOnNavigateToMyWaiting, modifier = Modifier .align(Alignment.BottomCenter) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt index 0966968d..6ed48711 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.main.FestabookMainTab import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.selectedColor import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.unselectedColor @@ -98,6 +99,13 @@ private fun RowScope.FestabookNavigationItem( selected: Boolean, modifier: Modifier = Modifier, ) { + val loggedOnClick = + logClick( + identifier = "main_tab_select", + screenName = "MainScreen", + extraParam = mapOf("tab" to tab.name), + onClick = { onClick(tab) }, + ) Box( modifier = modifier @@ -106,7 +114,7 @@ private fun RowScope.FestabookNavigationItem( .background(color = FestabookColor.white) .selectable( selected = selected, - onClick = { onClick(tab) }, + onClick = loggedOnClick, interactionSource = remember { MutableInteractionSource() }, indication = null, // 리플(Ripple) 완벽 제거 ), @@ -136,14 +144,19 @@ private fun PlaceMapNavigationItem( onClick: (FestabookMainTab) -> Unit, modifier: Modifier = Modifier, ) { + val loggedOnClick = + logClick( + identifier = "main_tab_select", + screenName = "MainScreen", + extraParam = mapOf("tab" to FestabookMainTab.PLACE_MAP.name), + onClick = { onClick(FestabookMainTab.PLACE_MAP) }, + ) Image( modifier = modifier .offset(y = -festabookSpacing.paddingBody4) .clickable( - onClick = { - onClick(FestabookMainTab.PLACE_MAP) - }, + onClick = loggedOnClick, indication = null, interactionSource = remember { MutableInteractionSource() }, ), diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt index f215ae17..a2e1863d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -24,11 +24,13 @@ import androidx.navigationevent.compose.rememberNavigationEventState import com.daedan.festabook.Platform import com.daedan.festabook.di.FestabookAppGraph import com.daedan.festabook.getPlatform +import com.daedan.festabook.logging.currentTimeMillis import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.FestabookSnackbar import com.daedan.festabook.presentation.common.component.SnackbarManager import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager +import com.daedan.festabook.presentation.common.rememberAnalytics import com.daedan.festabook.presentation.festating.navigation.festatingNavGraph import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.navigation.homeNavGraph @@ -85,6 +87,7 @@ fun MainScreen( val backPressExitMessage = stringResource(Res.string.back_press_exit_message) val openAppSettings = rememberOpenAppSettings() val state = rememberNavigationEventState(NavigationEventInfo.None) + val analytics = rememberAnalytics() val notificationPermissionManager = rememberNotificationPermissionManager( @@ -133,9 +136,21 @@ fun MainScreen( } } + LaunchedEffect(Unit) { + analytics.logEvent("app_foreground") + } + RememberDeepLinkHandler { action, festivalIdChanged -> when (action) { is FcmDeepLinkAction.OpenAnnouncement -> { + analytics.logEvent( + "notification_tapped", + mapOf( + "notification_id" to action.announcementId, + "notification_sent_at" to action.notificationSentAt, + "elapsed_sec" to (currentTimeMillis() - action.notificationSentAt) / 1000, + ), + ) if (festivalIdChanged) { festabookNavigator.navigate( FestabookRoute.Main(pendingAnnouncementId = action.announcementId), diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/component/NewsScreen.kt index 30822283..8c980446 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.logging.ScreenViewLogger import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.news.NewsViewModel @@ -29,6 +30,7 @@ fun NewsScreen( newsViewModel: NewsViewModel = metroViewModel(), onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 ) { + ScreenViewLogger("NewsScreen") val pageState = rememberPagerState { NewsTab.entries.size } val scope = rememberCoroutineScope() diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index b7a94c62..6a5a1abc 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen @@ -25,6 +27,8 @@ fun FAQScreen( onFaqClick: (FAQItemUiModel) -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("FAQScreen") + when (uiState) { is FAQUiState.Error -> { // Timber.w(uiState.throwable.stackTraceToString()) @@ -52,11 +56,18 @@ fun FAQScreen( items = uiState.faqs, key = { faq -> faq.questionId }, ) { faq -> + val loggedOnFaqClick = + logClick( + identifier = "faq_item_click", + screenName = "FAQScreen", + extraParam = mapOf("faq_id" to faq.questionId.toString()), + onClick = { onFaqClick(faq) }, + ) NewsItem( title = stringResource(Res.string.tab_faq_question, faq.question), description = faq.answer, isExpanded = faq.isExpanded, - onclick = { onFaqClick(faq) }, + onclick = loggedOnFaqClick, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index bb803739..a68196d5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen @@ -48,8 +50,11 @@ fun LostItemScreen( onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("LostItemScreen") + var clickedLostItem by remember { mutableStateOf(null) } val scrollState = rememberScrollState() + val loggedOnRefresh = logClick(identifier = "refresh", screenName = "LostItemScreen", onClick = onRefresh) clickedLostItem?.let { LostItemModalDialog( @@ -60,7 +65,7 @@ fun LostItemScreen( PullToRefreshContainer( isRefreshing = lostUiState.isRefreshing, - onRefresh = onRefresh, + onRefresh = loggedOnRefresh, modifier = modifier, ) { graphicsLayer -> when (val content = lostUiState.content) { @@ -125,11 +130,17 @@ private fun LostItemContent( item(span = { GridItemSpan(SPAN_COUNT) }) { val guide = lostItems.firstOrNull() as? LostUiModel.Guide guide?.let { + val loggedOnLostGuideClick = + logClick( + identifier = "lost_guide_click", + screenName = "LostItemScreen", + onClick = onLostGuideClick, + ) NewsItem( title = stringResource(Res.string.lost_item_guide), description = it.description, isExpanded = it.isExpanded, - onclick = onLostGuideClick, + onclick = loggedOnLostGuideClick, icon = { Icon( @@ -144,9 +155,16 @@ private fun LostItemContent( items = lostItems.drop(1).filterIsInstance(), key = { lostItem -> lostItem.lostItemId }, ) { lostItem -> + val loggedOnLostItemClick = + logClick( + identifier = "lost_item_click", + screenName = "LostItemScreen", + extraParam = mapOf("lost_item_id" to lostItem.lostItemId.toString()), + onClick = { onLostItemClick(lostItem) }, + ) LostItem( url = lostItem.imageUrl, - onLostItemClick = { onLostItemClick(lostItem) }, + onLostItemClick = loggedOnLostItemClick, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index 05d182ed..e607165d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen @@ -44,11 +46,14 @@ fun NoticeScreen( onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("NoticeScreen") + val scrollState = rememberScrollState() + val loggedOnRefresh = logClick(identifier = "refresh", screenName = "NoticeScreen", onClick = onRefresh) PullToRefreshContainer( isRefreshing = uiState.isRefreshing, - onRefresh = onRefresh, + onRefresh = loggedOnRefresh, modifier = modifier, ) { graphicsLayer -> when (val content = uiState.content) { @@ -115,11 +120,18 @@ private fun NoticeContent( items = notices, key = { notice -> notice.id }, ) { notice -> + val loggedOnNoticeClick = + logClick( + identifier = "notice_click", + screenName = "NoticeScreen", + extraParam = mapOf("notice_id" to notice.id.toString()), + onClick = { onNoticeClick(notice) }, + ) NewsItem( title = notice.title, description = notice.content, isExpanded = notice.isExpanded, - onclick = { onNoticeClick(notice) }, + onclick = loggedOnNoticeClick, icon = { if (notice.isPinned) { Icon( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt index 33fb91f9..13965f62 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.getIconId import com.daedan.festabook.presentation.placeMap.model.getTextId @@ -91,11 +92,16 @@ private fun CategoryChip( icon: @Composable (() -> Unit)? = null, onClick: () -> Unit = {}, ) { + val loggedOnClick = + logClick( + identifier = "category_chip_click", + screenName = "PlaceMapScreen", + extraParam = mapOf("category" to text), + onClick = onClick, + ) FilterChip( selected = selected, - onClick = { - onClick() - }, + onClick = { loggedOnClick() }, modifier = modifier, label = { Text( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index 03d46ee8..8602aa2f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.URLText import com.daedan.festabook.presentation.common.convertImageUrl @@ -49,6 +51,22 @@ fun PlaceDetailPreviewScreen( onClick: (LoadState) -> Unit = {}, onBackPress: () -> Unit = {}, ) { + ScreenViewLogger("PlaceDetailPreviewScreen") + + val placeId = + (selectedPlace as? LoadState.Success) + ?.value + ?.place + ?.id + ?.toString() + val loggedOnClick = + logClick( + identifier = "place_preview_click", + screenName = "PlaceDetailPreviewScreen", + extraParam = if (placeId != null) mapOf("place_id" to placeId) else emptyMap(), + onClick = { onClick(selectedPlace) }, + ) + PlaceDetailPreviewBackHandler(enabled = visible) { onBackPress() } @@ -57,7 +75,7 @@ fun PlaceDetailPreviewScreen( modifier = modifier .wrapContentSize() - .clickable { onClick(selectedPlace) }, + .clickable { loggedOnClick() }, ) { when (selectedPlace) { is LoadState.Success -> { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt index 00b32f49..b16e4e79 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.daedan.festabook.logging.ScreenViewLogger import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel @@ -36,6 +37,8 @@ fun PlaceDetailPreviewSecondaryScreen( onBackPress: () -> Unit = {}, visible: Boolean = false, ) { + ScreenViewLogger("PlaceDetailPreviewSecondaryScreen") + PlaceDetailPreviewBackHandler(enabled = visible) { onBackPress() } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index e51e7bec..024b0fa9 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.FestabookImage @@ -209,12 +210,19 @@ private fun PlaceListItem( modifier: Modifier = Modifier, onPlaceClick: (PlaceUiModel) -> Unit = {}, ) { + val loggedOnPlaceClick = + logClick( + identifier = "place_click", + screenName = "PlaceMapScreen", + extraParam = mapOf("place_id" to place.id.toString()), + onClick = { onPlaceClick(place) }, + ) Column( modifier = modifier .padding(bottom = festabookSpacing.paddingBody3) .clickable( - onClick = { onPlaceClick(place) }, + onClick = loggedOnPlaceClick, interactionSource = null, ), ) { diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index fc1d8efe..b7125b2f 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -20,6 +20,7 @@ import coil3.PlatformContext import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.ImageResult +import com.daedan.festabook.logging.ScreenViewVisibilityLogger import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel @@ -126,6 +127,8 @@ fun PlaceMapScreen( modifier: Modifier = Modifier, isVisible: Boolean = true, ) { + ScreenViewVisibilityLogger(screenName = "PlaceMapScreen", isVisible = isVisible) + val isPlaceListVisible = uiState.selectedPlace is LoadState.Empty NaverMapContent( diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt index 34626e3e..742e28d5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt @@ -51,6 +51,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.FestabookImage @@ -98,11 +100,27 @@ fun PlaceDetailRoute( onNavigateToMyWaiting: () -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("PlaceDetailScreen") val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var duplicateWaitingId by remember { mutableStateOf(null) } var showCancelConfirmDialog by remember { mutableStateOf(false) } + val loggedOnBackToPreviousClick = logClick(identifier = "back", screenName = "PlaceDetailScreen", onClick = onBackToPreviousClick) + val placeId = (placeDetailUiState as? PlaceDetailUiState.Success)?.placeDetail?.place?.id + val loggedOnNavigateToMyWaiting = logClick( + identifier = "navigate_to_my_waiting", + screenName = "PlaceDetailScreen", + extraParam = if (placeId != null) mapOf("place_id" to placeId.toString()) else emptyMap(), + onClick = onNavigateToMyWaiting, + ) + val loggedOnRegisterWaitingClick = logClick( + identifier = "register_waiting", + screenName = "PlaceDetailScreen", + extraParam = if (placeId != null) mapOf("place_id" to placeId.toString()) else emptyMap(), + onClick = viewModel::onRegisterWaitingClick, + ) + ObserveAsEvents(viewModel.navigateToWaitingRegisterEvent) { placeId -> duplicateWaitingId = null showCancelConfirmDialog = false @@ -120,7 +138,7 @@ fun PlaceDetailRoute( WaitingDuplicateBottomSheet( onMyWaitingClick = { duplicateWaitingId = null - onNavigateToMyWaiting() + loggedOnNavigateToMyWaiting() }, onRegisterNewClick = { showCancelConfirmDialog = true }, onDismiss = { duplicateWaitingId = null }, @@ -144,9 +162,9 @@ fun PlaceDetailRoute( PlaceDetailScreen( modifier = modifier, uiState = placeDetailUiState, - onBackToPreviousClick = onBackToPreviousClick, + onBackToPreviousClick = loggedOnBackToPreviousClick, onShowErrorSnackbar = onShowErrorSnackbar, - onRegisterWaitingClick = viewModel::onRegisterWaitingClick, + onRegisterWaitingClick = loggedOnRegisterWaitingClick, onWaitingRefresh = { scope.launch { viewModel.refreshWaitingStatus() } }, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt index 2470ac46..6852797c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt @@ -43,6 +43,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.PermissionState import com.daedan.festabook.presentation.common.ObserveAsEvents @@ -92,6 +94,8 @@ fun WaitingRegisterRoute( onNavigateToMyWaiting: () -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("WaitingRegisterScreen") + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isNotificationAllowed by settingViewModel.isAllowed.collectAsStateWithLifecycle() val successMessage = stringResource(Res.string.waiting_register_success) @@ -198,6 +202,17 @@ fun WaitingRegisterScreen( val isSubmitting = (uiState as? WaitingRegisterUiState.Success)?.waitingRegister?.isSubmitting ?: false + val loggedOnBackToPreviousClick = logClick(identifier = "back", screenName = "WaitingRegisterScreen", onClick = onBackToPreviousClick) + val loggedOnToggleServiceAgreement = + logClick(identifier = "toggle_service_agreement", screenName = "WaitingRegisterScreen", onClick = onToggleServiceAgreement) + val partySize = (uiState as? WaitingRegisterUiState.Success)?.waitingRegister?.partySize + val loggedOnSubmitClick = logClick( + identifier = "submit_waiting", + screenName = "WaitingRegisterScreen", + extraParam = if (partySize != null) mapOf("party_size" to partySize.toString()) else emptyMap(), + onClick = onSubmitClick, + ) + NavigationBackHandler( state = state, isBackEnabled = !isSubmitting, @@ -212,7 +227,7 @@ fun WaitingRegisterScreen( } Column(modifier = modifier.fillMaxSize()) { - WaitingRegisterTopBar(onBackClick = onBackToPreviousClick) + WaitingRegisterTopBar(onBackClick = loggedOnBackToPreviousClick) when (uiState) { is WaitingRegisterUiState.Loading, @@ -263,7 +278,7 @@ fun WaitingRegisterScreen( AgreementSection( isAgreed = waitingRegister.isServiceAgreed, - onToggle = onToggleServiceAgreement, + onToggle = loggedOnToggleServiceAgreement, ) Spacer(modifier = Modifier.height(80.dp)) @@ -272,7 +287,7 @@ fun WaitingRegisterScreen( WaitingRegisterSubmitButton( isEnabled = waitingRegister.canSubmit, isSubmitting = waitingRegister.isSubmitting, - onClick = onSubmitClick, + onClick = loggedOnSubmitClick, modifier = Modifier.align(Alignment.BottomCenter), ) } diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt index f60a45c8..928feb0d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt @@ -10,6 +10,8 @@ object DeepLinkKeys { const val KEY_TYPE = "type" const val KEY_PLACE_ID = "placeId" const val INITIALIZED_ID = -1L + + const val KEY_NOTIFICATION_SENT_AT = "notificationSentAt" } enum class FcmMessageType { @@ -27,6 +29,7 @@ enum class FcmMessageType { sealed interface FcmDeepLinkAction { data class OpenAnnouncement( val announcementId: Long, + val notificationSentAt: Long, ) : FcmDeepLinkAction data object OpenMyWaiting : FcmDeepLinkAction diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 672e0520..4843347d 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.logging.ScreenViewLogger import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.common.component.LoadingStateScreen @@ -36,6 +37,7 @@ fun ScheduleScreen( scheduleViewModel: ScheduleViewModel = metroViewModel(), onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 ) { + ScreenViewLogger("ScheduleScreen") val scheduleUiState by scheduleViewModel.scheduleUiState.collectAsStateWithLifecycle() val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 1f04dc8a..56b25d54 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.BuildKonfig +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.PermissionState import com.daedan.festabook.presentation.common.ObserveAsEvents @@ -87,6 +89,7 @@ fun SettingRoute( onPhoneNumberClick: () -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("SettingScreen") val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() val isSubscribedLoading by settingViewModel.isLoading.collectAsStateWithLifecycle() @@ -135,6 +138,37 @@ fun SettingRoute( val phoneNumber = (waitingInfoUiState as? WaitingInfoUiState.Registered)?.phoneNumber + val loggedOnSubscribeClick = + logClick( + identifier = "subscribe_toggle", + screenName = "SettingScreen", + onClick = { settingViewModel.notificationAllowClick() }, + ) + val loggedOnServicePolicyClick = + logClick( + identifier = "service_policy_click", + screenName = "SettingScreen", + onClick = { uriHandler.openUri(SERVICE_POLICY_URL) }, + ) + val loggedOnPersonalInfoPolicyClick = + logClick( + identifier = "personal_info_policy_click", + screenName = "SettingScreen", + onClick = { uriHandler.openUri(PERSONAL_INFORMATION_POLICY_URL) }, + ) + val loggedOnContactUsClick = + logClick( + identifier = "contact_us_click", + screenName = "SettingScreen", + onClick = { uriHandler.openUri(CONTACT_US_URL) }, + ) + val loggedOnPhoneNumberClick = + logClick( + identifier = "phone_number_click", + screenName = "SettingScreen", + onClick = onPhoneNumberClick, + ) + SettingScreen( modifier = modifier, festivalUiState = festival, @@ -142,11 +176,11 @@ fun SettingRoute( appVersion = "v ${BuildKonfig.APP_VERSION_NAME}", isSubscribeEnabled = !isSubscribedLoading, phoneNumber = phoneNumber, - onSubscribeClick = { settingViewModel.notificationAllowClick() }, - onServicePolicyClick = { uriHandler.openUri(SERVICE_POLICY_URL) }, - onPersonalInformationPolicyClick = { uriHandler.openUri(PERSONAL_INFORMATION_POLICY_URL) }, - onContactUsClick = { uriHandler.openUri(CONTACT_US_URL) }, - onPhoneNumberClick = onPhoneNumberClick, + onSubscribeClick = { loggedOnSubscribeClick() }, + onServicePolicyClick = loggedOnServicePolicyClick, + onPersonalInformationPolicyClick = loggedOnPersonalInfoPolicyClick, + onContactUsClick = loggedOnContactUsClick, + onPhoneNumberClick = loggedOnPhoneNumberClick, onError = { onShowErrorSnackBar(it.throwable) }, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt index 9bfa26b6..96cfbc1e 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/setting/waitinginfo/component/AddWaitingInfoScreen.kt @@ -54,6 +54,8 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.setting.waitinginfo.WaitingInfoViewModel @@ -89,6 +91,7 @@ fun AddWaitingInfoRoute( modifier: Modifier = Modifier, onSaveSuccess: () -> Unit = onBackClick, ) { + ScreenViewLogger("AddWaitingInfoScreen") val waitingInfoUiState by viewModel.waitingInfoUiState.collectAsStateWithLifecycle() val phoneNumber by viewModel.phoneNumber.collectAsStateWithLifecycle() val isTermsAgreed by viewModel.isTermsAgreed.collectAsStateWithLifecycle() @@ -106,6 +109,13 @@ fun AddWaitingInfoRoute( viewModel.loadWaitingInfo() } + val loggedOnBackClick = + logClick( + identifier = "back", + screenName = "AddWaitingInfoScreen", + onClick = onBackClick, + ) + when (waitingInfoUiState) { is WaitingInfoUiState.Error -> { ErrorStateScreen( @@ -123,7 +133,7 @@ fun AddWaitingInfoRoute( onPhoneNumberChange = viewModel::updatePhoneNumber, onTermsAgreedChange = viewModel::setTermsAgreed, onSaveClick = viewModel::saveWaitingInfo, - onBackClick = onBackClick, + onBackClick = loggedOnBackClick, ) } } @@ -309,6 +319,12 @@ private fun WaitingInfoAddTerms( onTermsAgreedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val loggedOnTermsChange = + logClick( + identifier = "toggle_terms_agreement", + screenName = "AddWaitingInfoScreen", + onClick = { onTermsAgreedChange(!isTermsAgreed) }, + ) Column(modifier = modifier) { Text( text = stringResource(Res.string.setting_waiting_info_add_terms_title), @@ -321,7 +337,7 @@ private fun WaitingInfoAddTerms( Checkbox( modifier = Modifier.clip(festabookShapes.radius1), checked = isTermsAgreed, - onCheckedChange = onTermsAgreedChange, + onCheckedChange = { loggedOnTermsChange() }, colors = CheckboxDefaults.colors( checkedColor = FestabookColor.black, @@ -351,8 +367,14 @@ private fun ConfirmButton( onSaveClick: (String) -> Unit, modifier: Modifier = Modifier, ) { + val loggedOnSaveClick = + logClick( + identifier = "save_waiting_info", + screenName = "AddWaitingInfoScreen", + onClick = { onSaveClick(phoneNumber) }, + ) Button( - onClick = { onSaveClick(phoneNumber) }, + onClick = loggedOnSaveClick, enabled = isSaveEnabled, modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt index 6d54844c..d41b03e5 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt @@ -44,6 +44,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState +import com.daedan.festabook.logging.ScreenViewLogger +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.ErrorStateScreen @@ -82,6 +84,7 @@ fun MyWaitingRoute( onShowErrorSnackbar: (Throwable) -> Unit, modifier: Modifier = Modifier, ) { + ScreenViewLogger("MyWaitingScreen") val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) val cancelSuccessText = stringResource(Res.string.my_waiting_cancel_success) @@ -117,6 +120,16 @@ fun MyWaitingScreen( val state = rememberNavigationEventState(NavigationEventInfo.None) NavigationBackHandler(state = state) { onBack() } + val loggedOnBack = logClick(identifier = "back", screenName = "MyWaitingScreen", onClick = onBack) + val loggedOnRefresh = logClick(identifier = "refresh", screenName = "MyWaitingScreen", onClick = onRefresh) + val waitingId = (uiState as? MyWaitingUiState.Success)?.myWaiting?.waitingId + val loggedOnCancelWaiting = logClick( + identifier = "cancel_waiting", + screenName = "MyWaitingScreen", + extraParam = if (waitingId != null) mapOf("waiting_id" to waitingId.toString()) else emptyMap(), + onClick = onCancelWaiting, + ) + var showCancelConfirmDialog by remember { mutableStateOf(false) } val isCancelEnabled = uiState is MyWaitingUiState.Success && !uiState.myWaiting.isCanceling val isCancelling = uiState is MyWaitingUiState.Success && uiState.myWaiting.isCanceling @@ -129,7 +142,7 @@ fun MyWaitingScreen( Scaffold( modifier = modifier, - topBar = { MyWaitingTopBar(onBack = onBack) }, + topBar = { MyWaitingTopBar(onBack = loggedOnBack) }, bottomBar = { if (uiState is MyWaitingUiState.Success) { Column( @@ -161,7 +174,7 @@ fun MyWaitingScreen( onDismissClick = { showCancelConfirmDialog = false }, onCancelClick = { showCancelConfirmDialog = false - onCancelWaiting() + loggedOnCancelWaiting() }, onDismissRequest = { showCancelConfirmDialog = false }, ) @@ -182,8 +195,8 @@ fun MyWaitingScreen( is MyWaitingUiState.Success -> { MyWaitingContent( uiState = uiState, - onRefresh = onRefresh, - onCancelWaiting = onCancelWaiting, + onRefresh = loggedOnRefresh, + onCancelWaiting = loggedOnCancelWaiting, onNavigateToPlaceDetail = onNavigateToPlaceDetail, modifier = Modifier.padding(innerPadding), ) @@ -255,13 +268,20 @@ private fun MyWaitingContent( Spacer(modifier = Modifier.height(16.dp)) uiState.myWaiting.placeDetail?.let { placeDetail -> + val loggedOnNavigateToPlaceDetail = + logClick( + identifier = "navigate_to_place_detail", + screenName = "MyWaitingScreen", + extraParam = mapOf("place_id" to uiState.myWaiting.placeId.toString()), + onClick = { onNavigateToPlaceDetail(uiState.myWaiting.placeId) }, + ) Box( modifier = Modifier .cardBackground( backgroundColor = FestabookColor.white, shape = festabookShapes.radius4, - ).clickable { onNavigateToPlaceDetail(uiState.myWaiting.placeId) }, + ).clickable { loggedOnNavigateToPlaceDetail() }, ) { PlaceDetailPreviewContent(placeDetail = placeDetail) } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt index b873c3ab..c9478561 100644 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/delegate/DefaultUserNotificationDelegate.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import platform.Foundation.timeIntervalSince1970 import platform.UserNotifications.UNNotification import platform.UserNotifications.UNNotificationPresentationOptionAlert import platform.UserNotifications.UNNotificationPresentationOptionSound @@ -42,13 +43,15 @@ class DefaultUserNotificationDelegate( withCompletionHandler: () -> Unit, ) { val userInfo = didReceiveNotificationResponse.notification.request.content.userInfo + val notificationSendAt = + (didReceiveNotificationResponse.notification.date.timeIntervalSince1970 * 1000).toLong() val newFestivalId = (userInfo[DeepLinkKeys.KEY_FESTIVAL_ID] as? String)?.toLongOrNull() ?: DeepLinkKeys.INITIALIZED_ID val action = - userInfo.toDeepLinkAction() ?: run { + userInfo.toDeepLinkAction(notificationSendAt) ?: run { withCompletionHandler() return } @@ -72,7 +75,7 @@ class DefaultUserNotificationDelegate( } } - private fun Map.toDeepLinkAction(): FcmDeepLinkAction? { + private fun Map.toDeepLinkAction(notificationSendAt: Long): FcmDeepLinkAction? { val type = FcmMessageType.from(this[DeepLinkKeys.KEY_TYPE] as? String) return when (type) { FcmMessageType.WAITING_CALL, @@ -91,7 +94,7 @@ class DefaultUserNotificationDelegate( val announcementId = (this[DeepLinkKeys.KEY_ANNOUNCEMENT_ID] as? String)?.toLongOrNull() ?: return null - FcmDeepLinkAction.OpenAnnouncement(announcementId) + FcmDeepLinkAction.OpenAnnouncement(announcementId, notificationSendAt) } } } diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt deleted file mode 100644 index 13ef9b36..00000000 --- a/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/FirebaseAnalytics.ios.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.daedan.festabook.logging - -import cocoapods.FirebaseAnalytics.FIRAnalytics -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.suspendCancellableCoroutine - -@OptIn(ExperimentalForeignApi::class) -actual object FirebaseAnalytics { - private val platform = FIRAnalytics - private const val KEY_UNINITIALIZED_USER_ID = "undefined" - - @Suppress("UNCHECKED_CAST") - actual fun logEvent( - name: String, - params: Map?, - ) { - platform.logEventWithName( - name, - params as? Map, - ) - } - - actual suspend fun getAppInstanceId(): String = platform.appInstanceID() ?: KEY_UNINITIALIZED_USER_ID - - actual suspend fun getSessionId(): Long = - suspendCancellableCoroutine { cont -> - platform.sessionIDWithCompletion { id, error -> - if (error != null) { - cont.resumeWith(Result.failure(RuntimeException(error.localizedDescription))) - } else { - cont.resumeWith(Result.success(id)) - } - } - } -} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/IosFirebaseAnalytics.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/IosFirebaseAnalytics.kt new file mode 100644 index 00000000..8bc11e19 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/IosFirebaseAnalytics.kt @@ -0,0 +1,83 @@ +package com.daedan.festabook.logging + +import cocoapods.FirebaseAnalytics.FIRAnalytics +import com.daedan.festabook.BuildKonfig +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.di.coroutine.IO +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import platform.Foundation.NSLocale +import platform.Foundation.NSProcessInfo +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.currentLocale +import platform.Foundation.languageCode +import platform.Foundation.stringWithCString +import platform.UIKit.UIDevice +import platform.posix.uname +import platform.posix.utsname + +@Inject +@ContributesBinding(AppScope::class) +@OptIn(ExperimentalForeignApi::class) +class IosFirebaseAnalytics( + @IO scope: CoroutineScope, + festivalLocalDataSource: FestivalLocalDataSource, + deviceLocalDataSource: DeviceLocalDataSource, +) : FirebaseAnalytics(scope, festivalLocalDataSource, deviceLocalDataSource) { + private val platform = FIRAnalytics + + override val initializingJob: Job = + scope.launch { + platform.setDefaultEventParameters( + mapOf( + "platform" to "ios", + "app_version" to BuildKonfig.APP_VERSION_NAME, + "os_version" to NSProcessInfo.processInfo.operatingSystemVersionString, + "device_model" to getDeviceModel(), + "language" to NSLocale.currentLocale.languageCode, + "user_id" to userId.first { it != null }, + "timestamp" to currentTimeMillis(), + ), + ) + } + + init { + flushPendingEvents() + } + + @Suppress("UNCHECKED_CAST") + override fun onLogEvent( + name: String, + params: Map?, + ) { + val mergedParams = + buildMap { + params?.let { putAll(it) } + put("festival_id", festivalId.value ?: -1) + } as? Map + + platform.logEventWithName(name, mergedParams) + } + + private fun getDeviceModel(): String { + memScoped { + val systemInfo = alloc() + uname(systemInfo.ptr) + return NSString.stringWithCString( + systemInfo.machine, + encoding = NSUTF8StringEncoding, + ) ?: UIDevice.currentDevice.model + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/LoggingUtil.ios.kt b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/LoggingUtil.ios.kt new file mode 100644 index 00000000..53abc87c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/daedan/festabook/logging/LoggingUtil.ios.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.logging + +import platform.Foundation.NSDate +import platform.Foundation.date +import platform.Foundation.timeIntervalSince1970 + +actual fun currentTimeMillis(): Long = (NSDate.date().timeIntervalSince1970 * 1000).toLong() From 54d77d3029313de51c8cf110c1ac09c7d7f35ba5 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 5 May 2026 02:32:34 +0900 Subject: [PATCH 54/54] [Release] Release 2.1.1(2010107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **build: iOS 빌드 번호 업데이트 및 디버그 표시 명칭 수정** * `CFBundleVersion`을 `2010103`에서 `2010106`으로 업데이트했습니다. * 디버그 빌드의 앱 표시 명칭(`CFBundleDisplayName`) 형식을 `festabook(Debug)`에서 `(Debug)festabook`으로 변경했습니다. * **style: Compose UI 코드 포맷팅 및 가독성 개선** * `MyWaitingScreen`, `WaitingRegisterScreen`, `PlaceDetailScreen`, `ExploreSearchResultList` 내 `logClick` 호출부의 들여쓰기 및 코드 구조를 정리했습니다. * `PlaceWaitingContent` 및 `WaitingStatusCard` 내 애니메이션 사양(`animationSpec`) 설정 코드의 포맷을 일관되게 수정했습니다. * `ExploreSearchResultList` 내 미사용 임포트 순서를 조정했습니다. --- .../component/ExploreSearchResultList.kt | 28 ++++++++++--------- .../component/PlaceDetailScreen.kt | 26 +++++++++-------- .../component/PlaceWaitingContent.kt | 18 ++++++------ .../component/WaitingRegisterScreen.kt | 13 +++++---- .../waiting/component/MyWaitingScreen.kt | 13 +++++---- .../waiting/component/WaitingStatusCard.kt | 22 +++++++++------ gradle.properties | 4 +-- iosApp/Configuration/Config.xcconfig | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 7 +++-- iosApp/iosApp/Info.plist | 2 +- 10 files changed, 74 insertions(+), 61 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt index 8087ee9b..ee348c12 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt @@ -11,10 +11,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.explore.ExploreUiState import com.daedan.festabook.presentation.explore.SearchUiState import com.daedan.festabook.presentation.explore.model.SearchResultUiModel -import com.daedan.festabook.logging.logClick import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import org.jetbrains.compose.ui.tooling.preview.Preview @@ -51,12 +51,13 @@ fun ExploreSearchResultList( items = searchUiState.universitiesFound, key = { it.festivalId }, ) { university -> - val loggedOnItemClick = logClick( - identifier = "university_select", - screenName = "ExploreScreen", - extraParam = mapOf("festival_id" to university.festivalId.toString()), - onClick = { onUniversitySelect(university) }, - ) + val loggedOnItemClick = + logClick( + identifier = "university_select", + screenName = "ExploreScreen", + extraParam = mapOf("festival_id" to university.festivalId.toString()), + onClick = { onUniversitySelect(university) }, + ) ExploreResultItem( university = university, onItemClick = { loggedOnItemClick() }, @@ -81,12 +82,13 @@ fun ExploreSearchResultList( items = exploreUiState.recentSearches, key = { it.festivalId }, ) { recentSearch -> - val loggedOnRecentClick = logClick( - identifier = "recent_search_select", - screenName = "ExploreScreen", - extraParam = mapOf("festival_id" to recentSearch.festivalId.toString()), - onClick = { onUniversitySelect(recentSearch) }, - ) + val loggedOnRecentClick = + logClick( + identifier = "recent_search_select", + screenName = "ExploreScreen", + extraParam = mapOf("festival_id" to recentSearch.festivalId.toString()), + onClick = { onUniversitySelect(recentSearch) }, + ) ExploreResultItem( university = recentSearch, onItemClick = { loggedOnRecentClick() }, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt index 742e28d5..a65e7bca 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceDetailScreen.kt @@ -108,18 +108,20 @@ fun PlaceDetailRoute( val loggedOnBackToPreviousClick = logClick(identifier = "back", screenName = "PlaceDetailScreen", onClick = onBackToPreviousClick) val placeId = (placeDetailUiState as? PlaceDetailUiState.Success)?.placeDetail?.place?.id - val loggedOnNavigateToMyWaiting = logClick( - identifier = "navigate_to_my_waiting", - screenName = "PlaceDetailScreen", - extraParam = if (placeId != null) mapOf("place_id" to placeId.toString()) else emptyMap(), - onClick = onNavigateToMyWaiting, - ) - val loggedOnRegisterWaitingClick = logClick( - identifier = "register_waiting", - screenName = "PlaceDetailScreen", - extraParam = if (placeId != null) mapOf("place_id" to placeId.toString()) else emptyMap(), - onClick = viewModel::onRegisterWaitingClick, - ) + val loggedOnNavigateToMyWaiting = + logClick( + identifier = "navigate_to_my_waiting", + screenName = "PlaceDetailScreen", + extraParam = if (placeId != null) mapOf("place_id" to placeId.toString()) else emptyMap(), + onClick = onNavigateToMyWaiting, + ) + val loggedOnRegisterWaitingClick = + logClick( + identifier = "register_waiting", + screenName = "PlaceDetailScreen", + extraParam = if (placeId != null) mapOf("place_id" to placeId.toString()) else emptyMap(), + onClick = viewModel::onRegisterWaitingClick, + ) ObserveAsEvents(viewModel.navigateToWaitingRegisterEvent) { placeId -> duplicateWaitingId = null diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt index 2a0e5596..4b5f8c4c 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/placeDetail/component/PlaceWaitingContent.kt @@ -219,10 +219,11 @@ private fun RefreshButton( rotation.snapTo(rotation.value % 360f) rotation.animateTo( targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = durationMillis, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = durationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), ) } else { val remainder = rotation.value % 360f @@ -230,10 +231,11 @@ private fun RefreshButton( val remainingFraction = (360f - remainder) / 360f rotation.animateTo( targetValue = 360f, - animationSpec = tween( - durationMillis = (remainingFraction * durationMillis).toInt().coerceAtLeast(1), - easing = LinearEasing, - ), + animationSpec = + tween( + durationMillis = (remainingFraction * durationMillis).toInt().coerceAtLeast(1), + easing = LinearEasing, + ), ) } rotation.snapTo(0f) diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt index 6852797c..855b4697 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/placeMap/waitingRegister/component/WaitingRegisterScreen.kt @@ -206,12 +206,13 @@ fun WaitingRegisterScreen( val loggedOnToggleServiceAgreement = logClick(identifier = "toggle_service_agreement", screenName = "WaitingRegisterScreen", onClick = onToggleServiceAgreement) val partySize = (uiState as? WaitingRegisterUiState.Success)?.waitingRegister?.partySize - val loggedOnSubmitClick = logClick( - identifier = "submit_waiting", - screenName = "WaitingRegisterScreen", - extraParam = if (partySize != null) mapOf("party_size" to partySize.toString()) else emptyMap(), - onClick = onSubmitClick, - ) + val loggedOnSubmitClick = + logClick( + identifier = "submit_waiting", + screenName = "WaitingRegisterScreen", + extraParam = if (partySize != null) mapOf("party_size" to partySize.toString()) else emptyMap(), + onClick = onSubmitClick, + ) NavigationBackHandler( state = state, diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt index d41b03e5..79b78e05 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/MyWaitingScreen.kt @@ -123,12 +123,13 @@ fun MyWaitingScreen( val loggedOnBack = logClick(identifier = "back", screenName = "MyWaitingScreen", onClick = onBack) val loggedOnRefresh = logClick(identifier = "refresh", screenName = "MyWaitingScreen", onClick = onRefresh) val waitingId = (uiState as? MyWaitingUiState.Success)?.myWaiting?.waitingId - val loggedOnCancelWaiting = logClick( - identifier = "cancel_waiting", - screenName = "MyWaitingScreen", - extraParam = if (waitingId != null) mapOf("waiting_id" to waitingId.toString()) else emptyMap(), - onClick = onCancelWaiting, - ) + val loggedOnCancelWaiting = + logClick( + identifier = "cancel_waiting", + screenName = "MyWaitingScreen", + extraParam = if (waitingId != null) mapOf("waiting_id" to waitingId.toString()) else emptyMap(), + onClick = onCancelWaiting, + ) var showCancelConfirmDialog by remember { mutableStateOf(false) } val isCancelEnabled = uiState is MyWaitingUiState.Success && !uiState.myWaiting.isCanceling diff --git a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt index 048fb48b..f6ef6469 100644 --- a/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt +++ b/composeApp/src/commonMain/kotlin/com/daedan/festabook/presentation/waiting/component/WaitingStatusCard.kt @@ -198,10 +198,11 @@ private fun RefreshButton( rotation.snapTo(rotation.value % 360f) rotation.animateTo( targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = durationMillis, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = durationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), ) } else { val remainder = rotation.value % 360f @@ -209,11 +210,14 @@ private fun RefreshButton( val remainingFraction = (360f - remainder) / 360f rotation.animateTo( targetValue = 360f, - animationSpec = tween( - durationMillis = (remainingFraction * durationMillis).toInt() - .coerceAtLeast(1), - easing = LinearEasing, - ), + animationSpec = + tween( + durationMillis = + (remainingFraction * durationMillis) + .toInt() + .coerceAtLeast(1), + easing = LinearEasing, + ), ) } rotation.snapTo(0f) diff --git a/gradle.properties b/gradle.properties index 3909b18a..bec159a6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ kotlin.native.binary.stripDebugSymbols=false kotlin.native.cacheKind=static kotlin.native.disableCompilerDaemon=false -buildkonfig.flavor=dev +buildkonfig.flavor=release APP_VERSION_NAME=2.1.1 -APP_VERSION_CODE=2010106 +APP_VERSION_CODE=2010107 diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 93b82f3f..a774cc2d 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Festabookkmp PRODUCT_BUNDLE_IDENTIFIER=com.daedan.festabook.Festabookkmp$(TEAM_ID) -CURRENT_PROJECT_VERSION=2010103 +CURRENT_PROJECT_VERSION=2010106 MARKETING_VERSION=2.1.1 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ade4d3ab..7d3c6ead 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -382,7 +382,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; + INFOPLIST_KEY_CFBundleDisplayName = "(Debug)festabook"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -406,6 +406,7 @@ TARGETED_DEVICE_FAMILY = 1; }; name = Staging; + }; 94BDB26E51A3BD8821288379 /* Debug */ = { isa = XCBuildConfiguration; @@ -427,7 +428,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; + INFOPLIST_KEY_CFBundleDisplayName = "(Debug)festabook"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -531,7 +532,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "festabook(Debug)"; + INFOPLIST_KEY_CFBundleDisplayName = "festabook"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "현재 위치를 지도에 표시하고 가까운 장소를 찾기 위해 위치 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 3bd36f72..b4cd36eb 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 2.1.1 CFBundleVersion - 2010103 + 2010106 LSApplicationQueriesSchemes supertoss