diff --git a/.gitignore b/.gitignore index aa724b7..efb17ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,332 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,android,windows,intellij,androidstudio +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,android,windows,intellij,androidstudio + +### Android ### +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ *.iml -.gradle -/local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml +.idea/ +!.idea/icon.svg +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### macOS ### +# General .DS_Store -/build -/captures +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ + +# Gradle files +.gradle + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*~ +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later .externalNativeBuild -.cxx -local.properties + +# NDK +obj/ + +# IntelliJ IDEA +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.classpath +.project +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.ear + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) +hs_err_pid* + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/macos,android,windows,intellij,androidstudio \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 0000000..8f87d94 --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d4e24e..de7646d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + - \ No newline at end of file + diff --git a/app/src/main/java/com/kiero/core/common/base/BaseUiStateProvider.kt b/app/src/main/java/com/kiero/core/common/base/BaseUiStateProvider.kt new file mode 100644 index 0000000..d1127e3 --- /dev/null +++ b/app/src/main/java/com/kiero/core/common/base/BaseUiStateProvider.kt @@ -0,0 +1,14 @@ +package com.kiero.core.common.base + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.kiero.core.model.UiState + +open class BaseUiStateProvider( + successData: T +) : PreviewParameterProvider> { + override val values: Sequence> = sequenceOf( + UiState.Loading, + UiState.Success(successData), + UiState.Failure("네트워크 연결을 확인해주세요. (테스트 에러)") + ) +} diff --git a/app/src/main/java/com/kiero/core/common/extension/FlowExt.kt b/app/src/main/java/com/kiero/core/common/extension/FlowExt.kt new file mode 100644 index 0000000..2a3eef0 --- /dev/null +++ b/app/src/main/java/com/kiero/core/common/extension/FlowExt.kt @@ -0,0 +1,47 @@ +package com.kiero.core.common.extension + +import androidx.compose.runtime.Composable +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 androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import kotlinx.coroutines.flow.Flow + +@Composable +fun Flow.collectSideEffect( + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + action: suspend (T) -> Unit +) { + val lifecycle = lifecycleOwner.lifecycle + + LaunchedEffect(this, lifecycle) { + flowWithLifecycle(lifecycle, minActiveState) + .collect(action) + } +} + +@Composable +fun Flow.collectSingleEvent( + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + throttleTime: Long = 500L, + action: suspend (T) -> Unit +) { + val lifecycle = lifecycleOwner.lifecycle + var lastEmitTime by remember { mutableLongStateOf(0L) } + + LaunchedEffect(this, lifecycle) { + flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { value -> + val currentTime = System.currentTimeMillis() + if (currentTime - lastEmitTime > throttleTime) { + lastEmitTime = currentTime + action(value) + } + } + } +} diff --git a/app/src/main/java/com/kiero/core/common/extension/StateFlowExt.kt b/app/src/main/java/com/kiero/core/common/extension/StateFlowExt.kt new file mode 100644 index 0000000..789611f --- /dev/null +++ b/app/src/main/java/com/kiero/core/common/extension/StateFlowExt.kt @@ -0,0 +1,17 @@ +package com.kiero.core.common.extension + +import com.kiero.core.model.UiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +inline fun MutableStateFlow>.updateSuccess( + crossinline onUpdate: (T) -> T +) { + update { currentState -> + if (currentState is UiState.Success) { + currentState.copy(data = onUpdate(currentState.data)) + } else { + currentState + } + } +} diff --git a/app/src/main/java/com/kiero/core/common/util/HandleError.kt b/app/src/main/java/com/kiero/core/common/extension/ThrowableExt.kt similarity index 67% rename from app/src/main/java/com/kiero/core/common/util/HandleError.kt rename to app/src/main/java/com/kiero/core/common/extension/ThrowableExt.kt index 8c00884..5c2d953 100644 --- a/app/src/main/java/com/kiero/core/common/util/HandleError.kt +++ b/app/src/main/java/com/kiero/core/common/extension/ThrowableExt.kt @@ -1,14 +1,16 @@ -package com.kiero.core.common.util +package com.kiero.core.common.extension import java.io.IOException +import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.concurrent.TimeoutException -fun handleError(throwable: Throwable): String { - return when (throwable) { +fun Throwable.toHandleErrorMessage(): String { + return when (this) { is TimeoutException -> "네트워크 시간이 초과되었습니다. 다시 시도해주세요." + is SocketTimeoutException -> "네트워크 시간이 초과되었습니다. 다시 시도해주세요" is UnknownHostException -> "서버에 연결할 수 없습니다. 인터넷 연결을 확인하세요." is IOException -> "네트워크 연결에 문제가 발생했습니다. 다시 시도하세요." else -> "알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도하세요." } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/core/model/trigger/DialogState.kt b/app/src/main/java/com/kiero/core/model/trigger/DialogState.kt new file mode 100644 index 0000000..0a77c5e --- /dev/null +++ b/app/src/main/java/com/kiero/core/model/trigger/DialogState.kt @@ -0,0 +1,17 @@ +package com.kiero.core.model.trigger + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Immutable +data class DialogState( + val isVisible: Boolean = false, + val onClickAction: () -> Unit = {} +) + +@Stable +class DialogTrigger( + val show: (() -> Unit) -> Unit, + val dismiss: () -> Unit +) + diff --git a/app/src/main/java/com/kiero/core/model/trigger/GlobalUiEventHolder.kt b/app/src/main/java/com/kiero/core/model/trigger/GlobalUiEventHolder.kt new file mode 100644 index 0000000..7eddbe8 --- /dev/null +++ b/app/src/main/java/com/kiero/core/model/trigger/GlobalUiEventHolder.kt @@ -0,0 +1,10 @@ +package com.kiero.core.model.trigger + +import androidx.compose.runtime.Stable + +@Stable +class GlobalUiEventHolder( + val dialogTrigger: DialogTrigger, + val showToast: (String) -> Unit, + val showSnackbar: (SnackbarState) -> Unit +) diff --git a/app/src/main/java/com/kiero/core/model/trigger/SnackbarState.kt b/app/src/main/java/com/kiero/core/model/trigger/SnackbarState.kt new file mode 100644 index 0000000..cb0d256 --- /dev/null +++ b/app/src/main/java/com/kiero/core/model/trigger/SnackbarState.kt @@ -0,0 +1,8 @@ +package com.kiero.core.model.trigger + +import androidx.compose.runtime.Immutable + +@Immutable +data class SnackbarState( + val message: String = "", +) diff --git a/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitor.kt b/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitor.kt new file mode 100644 index 0000000..d74f912 --- /dev/null +++ b/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitor.kt @@ -0,0 +1,7 @@ +package com.kiero.core.network.monitor + +import kotlinx.coroutines.flow.Flow + +interface NetworkMonitor { + val isOnline: Flow +} diff --git a/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitorImpl.kt b/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitorImpl.kt new file mode 100644 index 0000000..bba80f1 --- /dev/null +++ b/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitorImpl.kt @@ -0,0 +1,61 @@ +package com.kiero.core.network.monitor + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import javax.inject.Inject + +class NetworkMonitorImpl @Inject constructor( + @param:ApplicationContext private val context: Context, +) : NetworkMonitor { + override val isOnline: Flow = callbackFlow { + val connectivityManager = context.getSystemService() + if (connectivityManager == null) { + channel.trySend(false) + channel.close() + return@callbackFlow + } + + val callback = object : ConnectivityManager.NetworkCallback() { + private val networks = mutableSetOf() + + override fun onAvailable(network: Network) { + networks += network + channel.trySend(true) + } + + override fun onLost(network: Network) { + networks -= network + channel.trySend(networks.isNotEmpty()) + } + } + + val request = NetworkRequest.Builder() + .addCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + // 현재 활성 네트워크가 있고 + 그 네트워크가 인터넷이 되는지 확인용 + val activeNetwork = connectivityManager.activeNetwork + val netCapability = connectivityManager.getNetworkCapabilities(activeNetwork) + val isConnected = + netCapability?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + + channel.trySend(isConnected) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.conflate() +} diff --git a/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitorModule.kt b/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitorModule.kt new file mode 100644 index 0000000..4d2e574 --- /dev/null +++ b/app/src/main/java/com/kiero/core/network/monitor/NetworkMonitorModule.kt @@ -0,0 +1,19 @@ +package com.kiero.core.network.monitor + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkMonitorModule { + + @Binds + @Singleton + abstract fun bindNetworkMonitor( + networkMonitor: NetworkMonitorImpl + ): NetworkMonitor + +} diff --git a/app/src/main/java/com/kiero/core/trigger/LocalGlobalUiEventTrigger.kt b/app/src/main/java/com/kiero/core/trigger/LocalGlobalUiEventTrigger.kt new file mode 100644 index 0000000..510dd1f --- /dev/null +++ b/app/src/main/java/com/kiero/core/trigger/LocalGlobalUiEventTrigger.kt @@ -0,0 +1,8 @@ +package com.kiero.core.trigger + +import androidx.compose.runtime.staticCompositionLocalOf +import com.kiero.core.model.trigger.GlobalUiEventHolder + +val LocalGlobalUiEventTrigger = staticCompositionLocalOf { + error("No GlobalUiEvent Trigger provided") +} diff --git a/app/src/main/java/com/kiero/data/di/DataSourceModule.kt b/app/src/main/java/com/kiero/data/di/DataSourceModule.kt index b3c506d..eac15f7 100644 --- a/app/src/main/java/com/kiero/data/di/DataSourceModule.kt +++ b/app/src/main/java/com/kiero/data/di/DataSourceModule.kt @@ -21,8 +21,7 @@ abstract class DummyDataSourceModule { abstract fun bindDummyDataSource( dummyDataSourceImpl: DummyDataSourceImpl, ): DummyDataSource - - + @Binds @Singleton abstract fun bindAuthDataSource( @@ -34,4 +33,4 @@ abstract class DummyDataSourceModule { abstract fun bindAuthLocalDataSource( authLocalDataSourceImpl: AuthLocalDataSourceImpl, ): AuthLocalDataSource -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt b/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt index 64f0302..5e17189 100644 --- a/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt @@ -6,6 +6,11 @@ import com.kiero.core.common.util.handleError import com.kiero.data.auth.repository.AuthRepository import com.kiero.presentation.auth.model.AuthSideEffect import com.kiero.presentation.auth.model.AuthState +import com.kiero.core.common.extension.toHandleErrorMessage +import com.kiero.core.model.UiState +import com.kiero.data.auth.repository.DummyRepository +import com.kiero.presentation.auth.model.DummySideEffect +import com.kiero.presentation.auth.model.DummyState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt b/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt index 5835052..f860c09 100644 --- a/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt +++ b/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt @@ -8,16 +8,38 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.kiero.core.common.extension.collectSideEffect import com.kiero.core.designsystem.theme.KieroTheme +import com.kiero.core.model.trigger.SnackbarState +import com.kiero.core.trigger.LocalGlobalUiEventTrigger +import com.kiero.presentation.kid.journey.state.KidJourneySideEffect +import com.kiero.presentation.kid.journey.viewmodel.KidJourneyViewModel @Composable fun KidJourneyRoute( paddingValues: PaddingValues, navigateUp: () -> Unit, + viewModel: KidJourneyViewModel = hiltViewModel() ) { + val globalTrigger = LocalGlobalUiEventTrigger.current + + viewModel.sideEffect.collectSideEffect { sideEffect -> + when (sideEffect) { + is KidJourneySideEffect.ShowToast -> globalTrigger.showToast(sideEffect.message) + is KidJourneySideEffect.ShowSnackbar -> globalTrigger.showSnackbar( + SnackbarState( + message = sideEffect.message + ) + ) + + KidJourneySideEffect.ShowDialog -> globalTrigger.dialogTrigger.show {} + } + } + KidJourneyScreen( paddingValues = paddingValues, - navigateUp = navigateUp + navigateUp = navigateUp, ) } @@ -30,7 +52,7 @@ private fun KidJourneyScreen( Column( modifier = modifier .fillMaxSize() - .padding(paddingValues), + .padding(paddingValues) ) { Text( text = "오늘의 여정" @@ -42,4 +64,4 @@ private fun KidJourneyScreen( @Preview private fun KidJourneyScreenPreview() { KieroTheme {} -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/kid/journey/state/KidJourneyContract.kt b/app/src/main/java/com/kiero/presentation/kid/journey/state/KidJourneyContract.kt new file mode 100644 index 0000000..ea03dc3 --- /dev/null +++ b/app/src/main/java/com/kiero/presentation/kid/journey/state/KidJourneyContract.kt @@ -0,0 +1,13 @@ +package com.kiero.presentation.kid.journey.state + +sealed interface KidJourneySideEffect { + data object ShowDialog : KidJourneySideEffect + + data class ShowToast( + val message: String + ) : KidJourneySideEffect + + data class ShowSnackbar( + val message: String, + ) : KidJourneySideEffect +} diff --git a/app/src/main/java/com/kiero/presentation/kid/journey/viewmodel/KidJourneyViewModel.kt b/app/src/main/java/com/kiero/presentation/kid/journey/viewmodel/KidJourneyViewModel.kt new file mode 100644 index 0000000..56f09b7 --- /dev/null +++ b/app/src/main/java/com/kiero/presentation/kid/journey/viewmodel/KidJourneyViewModel.kt @@ -0,0 +1,16 @@ +package com.kiero.presentation.kid.journey.viewmodel + +import androidx.lifecycle.ViewModel +import com.kiero.presentation.kid.journey.state.KidJourneySideEffect +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + +@HiltViewModel +class KidJourneyViewModel @Inject constructor( +) : ViewModel() { + private val _sideEffect = MutableSharedFlow() + val sideEffect: SharedFlow = _sideEffect.asSharedFlow() +} diff --git a/app/src/main/java/com/kiero/presentation/main/activity/MainActivity.kt b/app/src/main/java/com/kiero/presentation/main/activity/MainActivity.kt index 92e1ed3..a2a00ea 100644 --- a/app/src/main/java/com/kiero/presentation/main/activity/MainActivity.kt +++ b/app/src/main/java/com/kiero/presentation/main/activity/MainActivity.kt @@ -1,23 +1,46 @@ package com.kiero.presentation.main.activity +import android.annotation.SuppressLint +import android.content.pm.ActivityInfo import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import com.kiero.R import com.kiero.core.designsystem.theme.KieroTheme +import com.kiero.core.network.monitor.NetworkMonitor +import com.kiero.presentation.main.navigation.rememberMainAppState import com.kiero.presentation.main.screen.MainRoute import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var networkMonitor: NetworkMonitor + + @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark( + getColor(R.color.bottombar_color) + ), + navigationBarStyle = SystemBarStyle.dark( + getColor(R.color.bottombar_color) + ) + ) setContent { KieroTheme { - MainRoute() + val appState = rememberMainAppState(networkMonitor = networkMonitor) + + MainRoute( + appState = appState + ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt b/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt index afc33b4..974f6e1 100644 --- a/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt +++ b/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt @@ -1,6 +1,5 @@ package com.kiero.presentation.main.navigation -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember @@ -10,6 +9,7 @@ import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.kiero.core.network.monitor.NetworkMonitor import com.kiero.presentation.auth.navigation.AuthGraph import com.kiero.presentation.kid.journey.navigation.navigateToJourney import com.kiero.presentation.kid.mission.navigation.navigateToMission @@ -34,7 +34,18 @@ import kotlinx.coroutines.flow.stateIn class MainAppState( val navController: NavHostController, coroutineScope: CoroutineScope, + networkMonitor: NetworkMonitor ) { + val startDestination = AuthGraph + + val isOffline: StateFlow = networkMonitor.isOnline + .map(Boolean::not) + .stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false + ) + private val currentDestination = navController.currentBackStackEntryFlow .map { it.destination } .stateIn( @@ -87,7 +98,6 @@ class MainAppState( initialValue = false ) - fun navigateToParent() { navController.navigate(ParentGraph) { popUpTo(AuthGraph) { inclusive = true } @@ -157,14 +167,15 @@ class MainAppState( @Composable fun rememberMainAppState( + networkMonitor: NetworkMonitor, navController: NavHostController = rememberNavController(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, coroutineScope: CoroutineScope = rememberCoroutineScope(), ): MainAppState { - return remember(navController, snackbarHostState, coroutineScope) { + return remember(networkMonitor, navController, coroutineScope) { MainAppState( + networkMonitor = networkMonitor, navController = navController, coroutineScope = coroutineScope ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt b/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt index 40ce788..3fcc831 100644 --- a/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt +++ b/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt @@ -1,29 +1,49 @@ package com.kiero.presentation.main.screen +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kiero.core.designsystem.component.KieroSnackbar +import com.kiero.core.model.trigger.DialogTrigger +import com.kiero.core.model.trigger.GlobalUiEventHolder +import com.kiero.core.model.trigger.SnackbarState +import com.kiero.core.trigger.LocalGlobalUiEventTrigger import com.kiero.presentation.main.navigation.KidMainTab import com.kiero.presentation.main.navigation.KieroNavHost import com.kiero.presentation.main.navigation.MainAppState import com.kiero.presentation.main.navigation.ParentMainTab import com.kiero.presentation.main.navigation.component.MainBottomBar -import com.kiero.presentation.main.navigation.rememberMainAppState +import com.kiero.presentation.main.state.rememberDialogStateHolder import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber @Composable fun MainRoute( - appState: MainAppState = rememberMainAppState() + appState: MainAppState, ) { val snackBarHostState = remember { SnackbarHostState() } @@ -39,12 +59,20 @@ fun MainScreen( snackBarHostState: SnackbarHostState, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val isOffline by appState.isOffline.collectAsStateWithLifecycle() + val showParentBottomBar by appState.showParentBottomBar.collectAsStateWithLifecycle() val showKidBottomBar by appState.showKidBottomBar.collectAsStateWithLifecycle() + val currentParentTab by appState.currentParentTab.collectAsStateWithLifecycle() val currentKidTab by appState.currentKidTab.collectAsStateWithLifecycle() + var currentSnackbarState by remember { mutableStateOf(null) } val isVisible = showParentBottomBar || showKidBottomBar + val containerShape = if (showParentBottomBar) { RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) } else { @@ -57,35 +85,131 @@ fun MainScreen( } val currentTab = if (showParentBottomBar) currentParentTab else currentKidTab - Scaffold( - modifier = modifier.fillMaxSize(), - snackbarHost = { - SnackbarHost(hostState = snackBarHostState) { data -> - KieroSnackbar( - message = data.visuals.message, - // TODO: 디자인 확정 후 스낵바 높이 및 패딩 수정 필요 - modifier = Modifier.padding(16.dp) - ) - } - }, - bottomBar = { - MainBottomBar( - isVisible = isVisible, - containerShape = containerShape, - tabs = tabs, - currentTab = currentTab, - onTabSelected = { tab -> - when (tab) { - is ParentMainTab -> appState.navigateParentTab(tab) - is KidMainTab -> appState.navigateKidTab(tab) + val dialogState = rememberDialogStateHolder() + + val onShowToast: (String) -> Unit = remember { + { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + val onShowSnackbar: (SnackbarState) -> Unit = remember(scope, snackBarHostState) { + { state -> + currentSnackbarState = state + scope.launch { + snackBarHostState.currentSnackbarData?.dismiss() + + val job = launch { + snackBarHostState.showSnackbar( + message = state.message, + ) + } + job.invokeOnCompletion { + if (currentSnackbarState == state) { + currentSnackbarState = null } } - ) + delay(2000L) + job.cancel() + } } - ) { paddingValues -> - KieroNavHost( - appState = appState, - paddingValues = paddingValues, + } + + val eventHolder = remember(dialogState, onShowToast, onShowSnackbar) { + GlobalUiEventHolder( + dialogTrigger = DialogTrigger( + show = { onConfirm -> + dialogState.showDialog(onConfirm) + }, + dismiss = { + dialogState.dismissDialog() + } + ), + showToast = onShowToast, + showSnackbar = onShowSnackbar ) } -} \ No newline at end of file + + LaunchedEffect(isOffline) { + Timber.e("네트워크 상태 변경 감지: isOffline = $isOffline") + + if (isOffline && !dialogState.dialogState.isVisible) { + eventHolder.dialogTrigger.show { + eventHolder.dialogTrigger.dismiss() + } + } + } + + HandleBackPressToExit( + onShowToast = { + onShowToast("버튼을 한번 더 누르면 앱이 종료됩니다.") + } + ) + + CompositionLocalProvider( + LocalGlobalUiEventTrigger provides eventHolder + ) { + Box( + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + ) { + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackBarHostState) { data -> + KieroSnackbar( + message = data.visuals.message, + // TODO: 디자인 확정 후 스낵바 높이 및 패딩 수정 필요 + modifier = Modifier.padding(16.dp) + ) + } + }, + bottomBar = { + MainBottomBar( + isVisible = isVisible, + containerShape = containerShape, + tabs = tabs, + currentTab = currentTab, + onTabSelected = { tab -> + when (tab) { + is ParentMainTab -> appState.navigateParentTab(tab) + is KidMainTab -> appState.navigateKidTab(tab) + } + } + ) + } + ) { paddingValues -> + if (dialogState.dialogState.isVisible) { + // Todo : 공통 Dialog 띄우기 + Timber.e("Dialog 띄워짐") + } + + KieroNavHost( + appState = appState, + paddingValues = paddingValues, + startDestination = appState.startDestination + ) + } + } + } +} + +@Composable +private fun HandleBackPressToExit( + enabled: Boolean = true, + exitDuration: Long = 2000L, + onShowToast: () -> Unit = {} +) { + val activity = LocalActivity.current + var backPressedTime by remember { mutableLongStateOf(0L) } + + BackHandler(enabled = enabled) { + if (System.currentTimeMillis() - backPressedTime <= exitDuration) { + activity?.finish() + } else { + onShowToast() + } + backPressedTime = System.currentTimeMillis() + } +} diff --git a/app/src/main/java/com/kiero/presentation/main/state/DialogStateHolder.kt b/app/src/main/java/com/kiero/presentation/main/state/DialogStateHolder.kt new file mode 100644 index 0000000..94acf8d --- /dev/null +++ b/app/src/main/java/com/kiero/presentation/main/state/DialogStateHolder.kt @@ -0,0 +1,28 @@ +package com.kiero.presentation.main.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.kiero.core.model.trigger.DialogState + +@Stable +class DialogStateHolder { + var dialogState by mutableStateOf(DialogState()) + private set + + fun showDialog(onClick: () -> Unit) { + dialogState = DialogState(isVisible = true, onClickAction = onClick) + } + + fun dismissDialog() { + dialogState = dialogState.copy(isVisible = false) + } +} + +@Composable +fun rememberDialogStateHolder(): DialogStateHolder = remember { + DialogStateHolder() +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..7090aac 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,6 @@ #FF018786 #FF000000 #FFFFFFFF + + #FF232428 \ No newline at end of file