diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 961e841..af864a2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,8 +87,10 @@ dependencies { // features implementation(projects.feature.home) implementation(projects.feature.details) + implementation(projects.feature.settings) // cores + implementation(projects.core.data) implementation(projects.core.model) implementation(projects.core.designsystem) implementation(projects.core.navigation) diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/MainActivity.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/MainActivity.kt index cefdc9f..450d1d7 100644 --- a/app/src/main/kotlin/com/skydoves/pokedex/compose/MainActivity.kt +++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/MainActivity.kt @@ -20,20 +20,47 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.skydoves.pokedex.compose.ui.PokedexMain import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val viewModel: MainActivityViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() + val splashScreen = installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) + var uiState: MainActivityUiState by mutableStateOf(value = MainActivityUiState.Loading) + + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { + viewModel.userData + .onEach { uiState = it } + .collect() + } + } + + splashScreen.setKeepOnScreenCondition { uiState.shouldKeepSplashScreen() } + setContent { - PokedexMain() + PokedexMain( + darkTheme = (uiState.shouldUseDarkTheme(isSystemDarkTheme = isSystemInDarkTheme())), + ) } } } diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/MainActivityViewModel.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/MainActivityViewModel.kt new file mode 100644 index 0000000..a214bfa --- /dev/null +++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/MainActivityViewModel.kt @@ -0,0 +1,61 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.skydoves.pokedex.compose.MainActivityUiState.Loading +import com.skydoves.pokedex.compose.core.data.repository.userdata.UserDataRepository +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.model.UserData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class MainActivityViewModel @Inject constructor( + userDataRepository: UserDataRepository, +) : ViewModel() { + + val userData = userDataRepository.userData + .map(MainActivityUiState::Success) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = Loading, + ) +} + +sealed interface MainActivityUiState { + data object Loading : MainActivityUiState + data class Success(val userData: UserData) : MainActivityUiState { + fun shouldUseDarkTheme(isSystemDarkTheme: Boolean): Boolean = when (userData.uiTheme) { + UiTheme.FOLLOW_SYSTEM -> isSystemDarkTheme + UiTheme.DARK -> true + UiTheme.LIGHT -> false + } + } +} + +fun MainActivityUiState.shouldKeepSplashScreen() = this is Loading + +fun MainActivityUiState.shouldUseDarkTheme(isSystemDarkTheme: Boolean) = when (this) { + Loading -> isSystemDarkTheme + is MainActivityUiState.Success -> shouldUseDarkTheme(isSystemDarkTheme = isSystemDarkTheme) +} diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/navigation/PokedexNavHost.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/navigation/PokedexNavHost.kt index bdd1ef6..c7aeb12 100644 --- a/app/src/main/kotlin/com/skydoves/pokedex/compose/navigation/PokedexNavHost.kt +++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/navigation/PokedexNavHost.kt @@ -25,6 +25,7 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.scene.DialogSceneStrategy import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay import com.skydoves.compose.stability.runtime.TraceRecomposition @@ -33,12 +34,14 @@ import com.skydoves.pokedex.compose.core.navigation.PokedexNavigatorImpl import com.skydoves.pokedex.compose.core.navigation.PokedexScreen import com.skydoves.pokedex.compose.feature.details.PokedexDetails import com.skydoves.pokedex.compose.feature.home.PokedexHome +import com.skydoves.pokedex.compose.feature.settings.PokedexSettings @OptIn(ExperimentalSharedTransitionApi::class) @Composable @TraceRecomposition fun PokedexNavHost() { val backStack = rememberNavBackStack(PokedexScreen.Home) + val dialogStrategy = remember { DialogSceneStrategy() } val navigator = remember(backStack) { PokedexNavigatorImpl(backStack) } CompositionLocalProvider( @@ -48,6 +51,7 @@ fun PokedexNavHost() { NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, + sceneStrategy = dialogStrategy, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()), entryProvider = entryProvider { entry { @@ -64,6 +68,12 @@ fun PokedexNavHost() { pokemon = screen.pokemon, ) } + + entry( + metadata = DialogSceneStrategy.dialog(), + ) { + PokedexSettings() + } }, ) } diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt index d6f1298..b3ebeef 100644 --- a/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt +++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt @@ -23,8 +23,8 @@ import com.skydoves.pokedex.compose.navigation.PokedexNavHost @Composable @TraceRecomposition -fun PokedexMain() { - PokedexTheme { +fun PokedexMain(darkTheme: Boolean) { + PokedexTheme(darkTheme = darkTheme) { PokedexNavHost() } } diff --git a/build.gradle.kts b/build.gradle.kts index b6248b2..0727ff6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.android.test) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlinx.serialization) apply false alias(libs.plugins.ksp) apply false diff --git a/core/common/.gitignore b/core/common/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 0000000..54ab722 --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.ksp) +} + +dependencies { + implementation(libs.hilt.core) + implementation(libs.kotlinx.coroutines.core) + + ksp(libs.hilt.compiler) +} + diff --git a/core/network/src/main/kotlin/com/skydoves/pokedex/compose/core/network/PokedexAppDispatchers.kt b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/PokedexAppDispatchers.kt similarity index 93% rename from core/network/src/main/kotlin/com/skydoves/pokedex/compose/core/network/PokedexAppDispatchers.kt rename to core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/PokedexAppDispatchers.kt index 31878e7..f3143eb 100644 --- a/core/network/src/main/kotlin/com/skydoves/pokedex/compose/core/network/PokedexAppDispatchers.kt +++ b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/PokedexAppDispatchers.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.skydoves.pokedex.compose.core.network +package com.skydoves.pokedex.compose.core.common.network import javax.inject.Qualifier import kotlin.annotation.AnnotationRetention.RUNTIME diff --git a/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/PokedexAppScope.kt b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/PokedexAppScope.kt new file mode 100644 index 0000000..58c41fc --- /dev/null +++ b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/PokedexAppScope.kt @@ -0,0 +1,7 @@ +package com.skydoves.pokedex.compose.core.common.network + +import javax.inject.Qualifier + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class PokedexAppScope() diff --git a/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/di/CoroutineScopesModule.kt b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/di/CoroutineScopesModule.kt new file mode 100644 index 0000000..0b8aed2 --- /dev/null +++ b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/di/CoroutineScopesModule.kt @@ -0,0 +1,25 @@ +package com.skydoves.pokedex.compose.core.common.network.di + +import com.skydoves.pokedex.compose.core.common.network.Dispatcher +import com.skydoves.pokedex.compose.core.common.network.PokedexAppDispatchers +import com.skydoves.pokedex.compose.core.common.network.PokedexAppScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object CoroutineScopesModule { + + @Provides + @Singleton + @PokedexAppScope + fun providesCoroutineScope( + @Dispatcher(PokedexAppDispatchers.IO) dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) +} \ No newline at end of file diff --git a/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/di/DispatchersModule.kt b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/di/DispatchersModule.kt new file mode 100644 index 0000000..a7a7e65 --- /dev/null +++ b/core/common/src/main/java/com/skydoves/pokedex/compose/core/common/network/di/DispatchersModule.kt @@ -0,0 +1,19 @@ +package com.skydoves.pokedex.compose.core.common.network.di + +import com.skydoves.pokedex.compose.core.common.network.Dispatcher +import com.skydoves.pokedex.compose.core.common.network.PokedexAppDispatchers +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@Module +@InstallIn(SingletonComponent::class) +internal object DispatchersModule { + + @Provides + @Dispatcher(PokedexAppDispatchers.IO) + fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO +} \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 5ea412f..25da0b8 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { api(projects.core.model) implementation(projects.core.network) implementation(projects.core.database) + implementation(projects.core.datastore) testImplementation(projects.core.test) // kotlinx @@ -47,4 +48,5 @@ dependencies { testImplementation(libs.androidx.test.core) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) + testImplementation(libs.protobuf.kotlin.lite) } diff --git a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/di/DataModule.kt index 5207b5e..11e8eca 100644 --- a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/di/DataModule.kt @@ -20,6 +20,8 @@ import com.skydoves.pokedex.compose.core.data.repository.details.DetailsReposito import com.skydoves.pokedex.compose.core.data.repository.details.DetailsRepositoryImpl import com.skydoves.pokedex.compose.core.data.repository.home.HomeRepository import com.skydoves.pokedex.compose.core.data.repository.home.HomeRepositoryImpl +import com.skydoves.pokedex.compose.core.data.repository.userdata.UserDataRepository +import com.skydoves.pokedex.compose.core.data.repository.userdata.UserDataRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -34,4 +36,7 @@ internal interface DataModule { @Binds fun bindsDetailRepository(detailsRepositoryImpl: DetailsRepositoryImpl): DetailsRepository + + @Binds + fun bindsUserDataRepository(userDataRepositoryImpl: UserDataRepositoryImpl): UserDataRepository } diff --git a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/details/DetailsRepositoryImpl.kt b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/details/DetailsRepositoryImpl.kt index ae217a4..1f6f82a 100644 --- a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/details/DetailsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/details/DetailsRepositoryImpl.kt @@ -18,12 +18,12 @@ package com.skydoves.pokedex.compose.core.data.repository.details import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import com.skydoves.pokedex.compose.core.common.network.Dispatcher +import com.skydoves.pokedex.compose.core.common.network.PokedexAppDispatchers import com.skydoves.pokedex.compose.core.database.PokemonInfoDao import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asDomain import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asEntity import com.skydoves.pokedex.compose.core.model.PokemonInfo -import com.skydoves.pokedex.compose.core.network.Dispatcher -import com.skydoves.pokedex.compose.core.network.PokedexAppDispatchers import com.skydoves.pokedex.compose.core.network.model.PokemonErrorResponse import com.skydoves.pokedex.compose.core.network.model.mapper.ErrorResponseMapper import com.skydoves.pokedex.compose.core.network.service.PokedexClient diff --git a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt index effae23..4436ba7 100644 --- a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt @@ -18,12 +18,12 @@ package com.skydoves.pokedex.compose.core.data.repository.home import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import com.skydoves.pokedex.compose.core.common.network.Dispatcher +import com.skydoves.pokedex.compose.core.common.network.PokedexAppDispatchers import com.skydoves.pokedex.compose.core.database.PokemonDao import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asDomain import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asEntity import com.skydoves.pokedex.compose.core.model.Pokemon -import com.skydoves.pokedex.compose.core.network.Dispatcher -import com.skydoves.pokedex.compose.core.network.PokedexAppDispatchers import com.skydoves.pokedex.compose.core.network.service.PokedexClient import com.skydoves.sandwich.ApiResponse import com.skydoves.sandwich.message diff --git a/core/network/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/DispatchersModule.kt b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/FakeUserDataRepository.kt similarity index 51% rename from core/network/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/DispatchersModule.kt rename to core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/FakeUserDataRepository.kt index d2694ab..8168cea 100644 --- a/core/network/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/DispatchersModule.kt +++ b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/FakeUserDataRepository.kt @@ -14,22 +14,18 @@ * limitations under the License. */ -package com.skydoves.pokedex.compose.core.network.di +package com.skydoves.pokedex.compose.core.data.repository.userdata -import com.skydoves.pokedex.compose.core.network.Dispatcher -import com.skydoves.pokedex.compose.core.network.PokedexAppDispatchers -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.model.UserData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf -@Module -@InstallIn(SingletonComponent::class) -internal object DispatchersModule { +class FakeUserDataRepository : UserDataRepository { + override val userData: Flow = flowOf( + UserData(uiTheme = UiTheme.FOLLOW_SYSTEM), + ) - @Provides - @Dispatcher(PokedexAppDispatchers.IO) - fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO + override suspend fun setUiTheme(uiTheme: UiTheme) { + } } diff --git a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/UserDataRepository.kt b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/UserDataRepository.kt new file mode 100644 index 0000000..0a4cfe7 --- /dev/null +++ b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/UserDataRepository.kt @@ -0,0 +1,28 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.data.repository.userdata + +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.model.UserData +import kotlinx.coroutines.flow.Flow + +interface UserDataRepository { + + val userData: Flow + + suspend fun setUiTheme(uiTheme: UiTheme) +} diff --git a/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/UserDataRepositoryImpl.kt b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/UserDataRepositoryImpl.kt new file mode 100644 index 0000000..5c28e4c --- /dev/null +++ b/core/data/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/userdata/UserDataRepositoryImpl.kt @@ -0,0 +1,40 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.data.repository.userdata + +import com.skydoves.pokedex.compose.core.common.network.Dispatcher +import com.skydoves.pokedex.compose.core.common.network.PokedexAppDispatchers +import com.skydoves.pokedex.compose.core.datastore.PreferencesDataSource +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.model.UserData +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +class UserDataRepositoryImpl @Inject constructor( + private val preferencesDataSource: PreferencesDataSource, + @Dispatcher(PokedexAppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : UserDataRepository { + + override val userData: Flow = preferencesDataSource.userData + .flowOn(ioDispatcher) + + override suspend fun setUiTheme(uiTheme: UiTheme) { + preferencesDataSource.setUiTheme(uiTheme) + } +} diff --git a/core/data/src/test/kotlin/com/skydoves/pokedex/compose/core/data/HomeRepositoryImplTest.kt b/core/data/src/test/kotlin/com/skydoves/pokedex/compose/core/data/HomeRepositoryImplTest.kt index 89cff6f..54ba062 100644 --- a/core/data/src/test/kotlin/com/skydoves/pokedex/compose/core/data/HomeRepositoryImplTest.kt +++ b/core/data/src/test/kotlin/com/skydoves/pokedex/compose/core/data/HomeRepositoryImplTest.kt @@ -75,6 +75,7 @@ class HomeRepositoryImplTest { page = 0, onStart = {}, onComplete = {}, + onLastPageReached = {}, onError = {}, onLastPageReached = {}, ).test(2.toDuration(DurationUnit.SECONDS)) { @@ -103,6 +104,7 @@ class HomeRepositoryImplTest { page = 0, onStart = {}, onComplete = {}, + onLastPageReached = {}, onError = {}, onLastPageReached = {}, ).test(2.toDuration(DurationUnit.SECONDS)) { diff --git a/core/data/src/test/kotlin/com/skydoves/pokedex/compose/core/data/UserDataRepositoryTest.kt b/core/data/src/test/kotlin/com/skydoves/pokedex/compose/core/data/UserDataRepositoryTest.kt new file mode 100644 index 0000000..69b95b0 --- /dev/null +++ b/core/data/src/test/kotlin/com/skydoves/pokedex/compose/core/data/UserDataRepositoryTest.kt @@ -0,0 +1,83 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.data + +import androidx.datastore.core.DataStore +import com.skydoves.pokedex.compose.core.data.repository.userdata.UserDataRepositoryImpl +import com.skydoves.pokedex.compose.core.datastore.PreferencesDataSource +import com.skydoves.pokedex.compose.core.datastore.UserPreferences +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.model.UserData +import com.skydoves.pokedex.compose.core.test.MainCoroutinesRule +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class UserDataRepositoryTest { + + private lateinit var repository: UserDataRepositoryImpl + private lateinit var preferencesDataSource: PreferencesDataSource + + @get:Rule + val coroutinesRule = MainCoroutinesRule() + + @Before + fun setup() { + preferencesDataSource = PreferencesDataSource( + userPreferences = InMemoryDataStore(UserPreferences.getDefaultInstance()), + ) + repository = UserDataRepositoryImpl(preferencesDataSource, coroutinesRule.testDispatcher) + } + + @Test + fun default_user_data_is_correct() = runTest { + assertEquals( + UserData(uiTheme = UiTheme.FOLLOW_SYSTEM), + repository.userData.first(), + ) + } + + @Test + fun set_ui_theme_to_preferences() = runTest { + repository.setUiTheme(uiTheme = UiTheme.DARK) + + assertEquals( + UiTheme.DARK, + repository.userData + .map { it.uiTheme } + .first(), + ) + assertEquals( + UiTheme.DARK, + preferencesDataSource.userData + .map { it.uiTheme } + .first(), + ) + } +} + +class InMemoryDataStore(initialValue: T) : DataStore { + override val data = MutableStateFlow(initialValue) + override suspend fun updateData(transform: suspend (T) -> T): T = + data.updateAndGet { transform(it) } +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index be34d0e..c3bbf4f 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -39,6 +39,7 @@ android { } dependencies { + api(projects.core.common) implementation(projects.core.model) testImplementation(projects.core.test) diff --git a/core/datastore/.gitignore b/core/datastore/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/datastore/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts new file mode 100644 index 0000000..efdf0ef --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("skydoves.pokedex.android.library") + id("skydoves.pokedex.android.hilt") + id("skydoves.pokedex.spotless") + alias(libs.plugins.protobuf.plugin) +} + +android { + namespace = "com.skydoves.pokedex.compose.core.datastore" + + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + } +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + + api(libs.androidx.dataStore) + implementation(libs.protobuf.kotlin.lite) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} + +protobuf { + + protoc { + artifact = libs.protobuf.protoc.get().toString() + } + + generateProtoTasks { + all().forEach { task -> + task.builtins { + register("java") { + option("lite") + } + register("kotlin") { + option("lite") + } + } + } + } +} \ No newline at end of file diff --git a/core/datastore/consumer-rules.pro b/core/datastore/consumer-rules.pro new file mode 100644 index 0000000..1732739 --- /dev/null +++ b/core/datastore/consumer-rules.pro @@ -0,0 +1,4 @@ +# Keep DataStore fields +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite* { + ; +} \ No newline at end of file diff --git a/core/datastore/src/main/AndroidManifest.xml b/core/datastore/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9781954 --- /dev/null +++ b/core/datastore/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/PreferencesDataSource.kt b/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/PreferencesDataSource.kt new file mode 100644 index 0000000..1375adf --- /dev/null +++ b/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/PreferencesDataSource.kt @@ -0,0 +1,56 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.datastore + +import androidx.datastore.core.DataStore +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.model.UserData +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class PreferencesDataSource @Inject constructor( + private val userPreferences: DataStore, +) { + val userData = userPreferences.data + .map { + UserData( + uiTheme = it.uiThemeConfig.asUiTheme(), + ) + } + + suspend fun setUiTheme(uiTheme: UiTheme) { + userPreferences.updateData { + it.copy { + uiThemeConfig = uiTheme.asUiThemeConfigProto() + } + } + } +} + +fun UiThemeConfigProto.asUiTheme(): UiTheme = when (this) { + UiThemeConfigProto.UI_THEME_CONFIG_UNSPECIFIED -> UiTheme.FOLLOW_SYSTEM + UiThemeConfigProto.UI_THEME_CONFIG_FOLLOW_SYSTEM -> UiTheme.FOLLOW_SYSTEM + UiThemeConfigProto.UI_THEME_CONFIG_LIGHT -> UiTheme.LIGHT + UiThemeConfigProto.UI_THEME_CONFIG_DARK -> UiTheme.DARK + UiThemeConfigProto.UNRECOGNIZED -> UiTheme.FOLLOW_SYSTEM +} + +fun UiTheme.asUiThemeConfigProto(): UiThemeConfigProto = when (this) { + UiTheme.FOLLOW_SYSTEM -> UiThemeConfigProto.UI_THEME_CONFIG_FOLLOW_SYSTEM + UiTheme.LIGHT -> UiThemeConfigProto.UI_THEME_CONFIG_LIGHT + UiTheme.DARK -> UiThemeConfigProto.UI_THEME_CONFIG_DARK +} diff --git a/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/UserPreferencesSerializer.kt b/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/UserPreferencesSerializer.kt new file mode 100644 index 0000000..f1139c7 --- /dev/null +++ b/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/UserPreferencesSerializer.kt @@ -0,0 +1,39 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +class UserPreferencesSerializer @Inject constructor() : Serializer { + + override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): UserPreferences = try { + UserPreferences.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo(t: UserPreferences, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/di/DataStoreModule.kt new file mode 100644 index 0000000..3ad98e2 --- /dev/null +++ b/core/datastore/src/main/kotlin/com/skydoves/pokedex/compose/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,54 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.skydoves.pokedex.compose.core.common.network.Dispatcher +import com.skydoves.pokedex.compose.core.common.network.PokedexAppDispatchers +import com.skydoves.pokedex.compose.core.common.network.PokedexAppScope +import com.skydoves.pokedex.compose.core.datastore.UserPreferences +import com.skydoves.pokedex.compose.core.datastore.UserPreferencesSerializer +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + @Singleton + fun providesUserPreferencesDataStore( + @ApplicationContext context: Context, + @Dispatcher(PokedexAppDispatchers.IO) dispatcher: CoroutineDispatcher, + @PokedexAppScope scope: CoroutineScope, + userPreferencesSerializer: UserPreferencesSerializer, + ): DataStore = DataStoreFactory + .create( + serializer = userPreferencesSerializer, + scope = CoroutineScope(scope.coroutineContext + dispatcher), + produceFile = { context.dataStoreFile(fileName = "user_preferences.pb") }, + ) +} diff --git a/core/datastore/src/main/proto/com/skydoves/pokedex/compose/core/datastore/ui_theme_config.proto b/core/datastore/src/main/proto/com/skydoves/pokedex/compose/core/datastore/ui_theme_config.proto new file mode 100644 index 0000000..0956f96 --- /dev/null +++ b/core/datastore/src/main/proto/com/skydoves/pokedex/compose/core/datastore/ui_theme_config.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +option java_package = "com.skydoves.pokedex.compose.core.datastore"; +option java_multiple_files = true; + +enum UiThemeConfigProto { + UI_THEME_CONFIG_UNSPECIFIED = 0; + UI_THEME_CONFIG_FOLLOW_SYSTEM = 1; + UI_THEME_CONFIG_LIGHT = 2; + UI_THEME_CONFIG_DARK = 3; +} \ No newline at end of file diff --git a/core/datastore/src/main/proto/com/skydoves/pokedex/compose/core/datastore/user_preferences.proto b/core/datastore/src/main/proto/com/skydoves/pokedex/compose/core/datastore/user_preferences.proto new file mode 100644 index 0000000..c7fbe43 --- /dev/null +++ b/core/datastore/src/main/proto/com/skydoves/pokedex/compose/core/datastore/user_preferences.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +import "com/skydoves/pokedex/compose/core/datastore/ui_theme_config.proto"; + +option java_package = "com.skydoves.pokedex.compose.core.datastore"; +option java_multiple_files = true; + +message UserPreferences { + + UiThemeConfigProto ui_theme_config = 1; +} diff --git a/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/InMemoryDataStore.kt b/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/InMemoryDataStore.kt new file mode 100644 index 0000000..2738a8a --- /dev/null +++ b/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/InMemoryDataStore.kt @@ -0,0 +1,27 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.datastore + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.updateAndGet + +class InMemoryDataStore(initialValue: T) : DataStore { + override val data = MutableStateFlow(initialValue) + override suspend fun updateData(transform: suspend (T) -> T): T = + data.updateAndGet { transform(it) } +} diff --git a/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/PreferencesDataSourceTest.kt b/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/PreferencesDataSourceTest.kt new file mode 100644 index 0000000..e982f53 --- /dev/null +++ b/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/PreferencesDataSourceTest.kt @@ -0,0 +1,51 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.datastore + +import com.skydoves.pokedex.compose.core.model.UiTheme +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class PreferencesDataSourceTest { + + private val testScope = TestScope(UnconfinedTestDispatcher()) + + private lateinit var subject: PreferencesDataSource + + @Before + fun setup() { + subject = PreferencesDataSource( + userPreferences = InMemoryDataStore(UserPreferences.getDefaultInstance()), + ) + } + + @Test + fun shouldThemeIsFollowSystemByDefault() = testScope.runTest { + assertEquals(UiTheme.FOLLOW_SYSTEM, subject.userData.first().uiTheme) + } + + @Test + fun userShouldThemeIsDarkWhenSet() = testScope.runTest { + subject.setUiTheme(uiTheme = UiTheme.DARK) + assertEquals(UiTheme.DARK, subject.userData.first().uiTheme) + } +} diff --git a/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/UserPreferencesSerializerTest.kt b/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/UserPreferencesSerializerTest.kt new file mode 100644 index 0000000..7045646 --- /dev/null +++ b/core/datastore/src/test/kotlin/com/skydoves/pokedex/compose/core/datastore/UserPreferencesSerializerTest.kt @@ -0,0 +1,62 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.datastore + +import androidx.datastore.core.CorruptionException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +class UserPreferencesSerializerTest { + + @Test + fun defaultUserPreferences_isEmpty() { + val serializer = UserPreferencesSerializer() + assertEquals( + userPreferences { }, + serializer.defaultValue, + ) + } + + @Test + fun writingAndReadingUserPreferences_outputsCorrectValue() = runTest { + val serializer = UserPreferencesSerializer() + + val expected = userPreferences { + uiThemeConfig = UiThemeConfigProto.UI_THEME_CONFIG_FOLLOW_SYSTEM + } + + val outputStream = ByteArrayOutputStream() + + expected.writeTo(outputStream) + + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + + val actual = serializer.readFrom(input = inputStream) + + assertEquals(expected, actual) + } + + @Test(expected = CorruptionException::class) + fun readingInvalidUserPreferences_throwsCorruptionException() = runTest { + val serializer = UserPreferencesSerializer() + + serializer.readFrom(ByteArrayInputStream(byteArrayOf(0))) + } +} diff --git a/core/designsystem/src/main/kotlin/com/skydoves/pokedex/compose/core/designsystem/component/PokedexAppBar.kt b/core/designsystem/src/main/kotlin/com/skydoves/pokedex/compose/core/designsystem/component/PokedexAppBar.kt index 56f8fe9..9a76619 100644 --- a/core/designsystem/src/main/kotlin/com/skydoves/pokedex/compose/core/designsystem/component/PokedexAppBar.kt +++ b/core/designsystem/src/main/kotlin/com/skydoves/pokedex/compose/core/designsystem/component/PokedexAppBar.kt @@ -16,6 +16,10 @@ package com.skydoves.pokedex.compose.core.designsystem.component +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -28,7 +32,7 @@ import com.skydoves.pokedex.compose.core.designsystem.theme.PokedexTheme import com.skydoves.pokedex.compose.designsystem.R @Composable -fun PokedexAppBar() { +fun PokedexAppBar(onActionClick: () -> Unit) { TopAppBar( title = { Text( @@ -41,6 +45,15 @@ fun PokedexAppBar() { colors = TopAppBarDefaults.topAppBarColors().copy( containerColor = PokedexTheme.colors.primary, ), + actions = { + IconButton(onClick = onActionClick) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = PokedexTheme.colors.absoluteWhite, + ) + } + }, ) } @@ -48,6 +61,6 @@ fun PokedexAppBar() { @Composable private fun PokedexAppBarPreview() { PokedexTheme { - PokedexAppBar() + PokedexAppBar(onActionClick = {}) } } diff --git a/core/model/src/main/kotlin/com/skydoves/pokedex/compose/core/model/UserData.kt b/core/model/src/main/kotlin/com/skydoves/pokedex/compose/core/model/UserData.kt new file mode 100644 index 0000000..f779771 --- /dev/null +++ b/core/model/src/main/kotlin/com/skydoves/pokedex/compose/core/model/UserData.kt @@ -0,0 +1,27 @@ +/* + * Designed and developed by 2024 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.compose.core.model + +data class UserData( + val uiTheme: UiTheme, +) + +enum class UiTheme { + FOLLOW_SYSTEM, + DARK, + LIGHT, +} diff --git a/core/navigation/src/main/kotlin/com/skydoves/pokedex/compose/core/navigation/PokedexScreen.kt b/core/navigation/src/main/kotlin/com/skydoves/pokedex/compose/core/navigation/PokedexScreen.kt index 8406d62..041473a 100644 --- a/core/navigation/src/main/kotlin/com/skydoves/pokedex/compose/core/navigation/PokedexScreen.kt +++ b/core/navigation/src/main/kotlin/com/skydoves/pokedex/compose/core/navigation/PokedexScreen.kt @@ -26,4 +26,7 @@ sealed interface PokedexScreen : NavKey { @Serializable data class Details(val pokemon: Pokemon) : PokedexScreen + + @Serializable + data object Settings : PokedexScreen } diff --git a/feature/home/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt b/feature/home/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt index 0189f0b..475f36e 100644 --- a/feature/home/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt +++ b/feature/home/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt @@ -80,8 +80,12 @@ fun PokedexHome( val uiState by homeViewModel.uiState.collectAsStateWithLifecycle() val pokemonList by homeViewModel.pokemonList.collectAsStateWithLifecycle() + val composeNavigator = currentComposeNavigator + Column(modifier = Modifier.fillMaxSize()) { - PokedexAppBar() + PokedexAppBar( + onActionClick = { composeNavigator.navigate(PokedexScreen.Settings) } + ) HomeContent( sharedTransitionScope = sharedTransitionScope, @@ -89,6 +93,7 @@ fun PokedexHome( uiState = uiState, pokemonList = pokemonList.toImmutableList(), fetchNextPokemonList = homeViewModel::fetchNextPokemonList, + navigateToDetails = { composeNavigator.navigate(PokedexScreen.Details(pokemon = it)) } ) } } @@ -100,6 +105,7 @@ private fun HomeContent( uiState: HomeUiState, pokemonList: ImmutableList, fetchNextPokemonList: () -> Unit, + navigateToDetails: (Pokemon) -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { val threadHold = 8 @@ -122,6 +128,7 @@ private fun HomeContent( pokemon = pokemon, onPaletteLoaded = { palette = it }, backgroundColor = backgroundColor, + onCardClick = { navigateToDetails(pokemon) } ) } } @@ -139,8 +146,8 @@ private fun PokemonCard( onPaletteLoaded: (Palette) -> Unit, backgroundColor: Color, pokemon: Pokemon, + onCardClick: () -> Unit, ) { - val composeNavigator = currentComposeNavigator with(sharedTransitionScope) { Card( @@ -152,9 +159,7 @@ private fun PokemonCard( sharedContentState = rememberSharedContentState(key = "pokemon-${pokemon.name}"), animatedVisibilityScope = animatedContentScope, ) - .clickable { - composeNavigator.navigate(PokedexScreen.Details(pokemon = pokemon)) - }, + .clickable { onCardClick() }, shape = RoundedCornerShape(14.dp), colors = CardColors( containerColor = backgroundColor, @@ -221,6 +226,7 @@ private fun HomeContentPreview() { uiState = HomeUiState.Idle, pokemonList = PreviewUtils.mockPokemonList().toImmutableList(), fetchNextPokemonList = { }, + navigateToDetails = { } ) } } diff --git a/feature/settings/.gitignore b/feature/settings/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/settings/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts new file mode 100644 index 0000000..3ad55e6 --- /dev/null +++ b/feature/settings/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("skydoves.pokedex.android.feature") + id("skydoves.pokedex.android.hilt") +} + +android { + namespace = "com.skydoves.pokedex.compose.feature.settings" +} \ No newline at end of file diff --git a/feature/settings/src/main/AndroidManifest.xml b/feature/settings/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/settings/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/com/skydoves/pokedex/compose/feature/settings/PokedexSettings.kt b/feature/settings/src/main/kotlin/com/skydoves/pokedex/compose/feature/settings/PokedexSettings.kt new file mode 100644 index 0000000..d4e34d6 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/skydoves/pokedex/compose/feature/settings/PokedexSettings.kt @@ -0,0 +1,241 @@ +package com.skydoves.pokedex.compose.feature.settings + +import android.content.res.Configuration +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.skydoves.pokedex.compose.core.data.repository.userdata.FakeUserDataRepository +import com.skydoves.pokedex.compose.core.designsystem.component.PokedexCircularProgress +import com.skydoves.pokedex.compose.core.designsystem.component.PokedexText +import com.skydoves.pokedex.compose.core.designsystem.theme.PokedexTheme +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.navigation.currentComposeNavigator +import com.skydoves.pokedex.compose.core.preview.PokedexPreviewTheme + +@Composable +fun PokedexSettings( + settingsViewModel: SettingsViewModel = hiltViewModel() +) { + val uiState by settingsViewModel.uiState.collectAsStateWithLifecycle() + + val windowInfo = LocalWindowInfo.current + + Box( + modifier = Modifier + .widthIn(max = (windowInfo.containerSize.width - 80).dp) + .background( + color = PokedexTheme.colors.background, + shape = RoundedCornerShape(size = 32.dp) + ) + ) { + SettingsDialog( + settingsUiState = uiState, + onChangeUiTheme = settingsViewModel::setUiTheme + ) + } +} + +@Composable +fun SettingsDialog( + settingsUiState: SettingsUiState, + onChangeUiTheme: (uiTheme: UiTheme) -> Unit, +) { + + val composeNavigator = currentComposeNavigator + + Box( + contentAlignment = Alignment.Center, + content = { + + Column( + modifier = Modifier + .wrapContentSize(align = Alignment.Center) + .padding(all = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + space = 24.dp, + alignment = Alignment.Top + ), + content = { + + PokedexText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.feature_settings_title), + color = PokedexTheme.colors.black, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Start, + fontSize = 22.sp, + ) + + HorizontalDivider() + + if (settingsUiState is SettingsUiState.Success) { + SettingsDialogContent( + uiTheme = settingsUiState.userData.uiTheme, + onChangeUiTheme = onChangeUiTheme + ) + } + + if (settingsUiState is SettingsUiState.Loading) { + Box(modifier = Modifier.fillMaxSize()) { + PokedexCircularProgress() + } + } + + HorizontalDivider() + + TextButton( + onClick = composeNavigator::navigateUp, + content = { + Text(text = stringResource(id = R.string.feature_settings_dismiss_dialog_button_text)) + }, + colors = ButtonDefaults.textButtonColors(contentColor = PokedexTheme.colors.primary), + modifier = Modifier.align(alignment = Alignment.End) + ) + } + ) + } + ) +} + +@Composable +private fun SettingsDialogContent( + uiTheme: UiTheme, + onChangeUiTheme: (UiTheme) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically + ), + content = { + + SettingsDialogThemeSection( + uiTheme = uiTheme, + onChangeUiTheme = onChangeUiTheme + ) + } + ) +} + +@Composable +private fun SettingsDialogThemeSection( + uiTheme: UiTheme, + onChangeUiTheme: (UiTheme) -> Unit, + modifier: Modifier = Modifier +) { + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically + ), + content = { + + PokedexText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.feature_settings_theme), + color = PokedexTheme.colors.black, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Start, + fontSize = 16.sp, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .selectableGroup(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically + ), + content = { + + UiTheme.entries.forEach { + Row( + Modifier + .selectable( + selected = uiTheme == it, + role = Role.RadioButton, + onClick = { onChangeUiTheme(it) }, + ), + horizontalArrangement = Arrangement.spacedBy( + space = 8.dp, + alignment = Alignment.Start + ), + verticalAlignment = Alignment.CenterVertically, + content = { + RadioButton( + selected = uiTheme == it, + onClick = null, + colors = RadioButtonDefaults.colors(selectedColor = PokedexTheme.colors.primary) + ) + + PokedexText( + text = stringResource( + id = when (it) { + UiTheme.FOLLOW_SYSTEM -> R.string.feature_settings_theme_follow_system + UiTheme.LIGHT -> R.string.feature_settings_theme_light + UiTheme.DARK -> R.string.feature_settings_theme_dark + } + ), + color = PokedexTheme.colors.black, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Start, + fontSize = 14.sp, + ) + } + ) + } + } + ) + } + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SettingsDialogPreview() { + PokedexPreviewTheme { + PokedexSettings( + settingsViewModel = SettingsViewModel(userDataRepository = FakeUserDataRepository()) + ) + } +} \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/com/skydoves/pokedex/compose/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/com/skydoves/pokedex/compose/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000..18dfc0e --- /dev/null +++ b/feature/settings/src/main/kotlin/com/skydoves/pokedex/compose/feature/settings/SettingsViewModel.kt @@ -0,0 +1,46 @@ +package com.skydoves.pokedex.compose.feature.settings + +import androidx.compose.runtime.Stable +import androidx.lifecycle.viewModelScope +import com.skydoves.pokedex.compose.core.data.repository.userdata.UserDataRepository +import com.skydoves.pokedex.compose.core.model.UiTheme +import com.skydoves.pokedex.compose.core.model.UserData +import com.skydoves.pokedex.compose.core.viewmodel.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val userDataRepository: UserDataRepository +) : BaseViewModel() { + + val uiState: StateFlow = userDataRepository.userData + .map(SettingsUiState::Success) + .catch { SettingsUiState.Error(it.message) } + .stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = SettingsUiState.Loading, + ) + + fun setUiTheme(uiTheme: UiTheme) = viewModelScope.launch { + userDataRepository.setUiTheme(uiTheme) + } +} + +@Stable +sealed interface SettingsUiState { + + data object Loading : SettingsUiState + + data class Success(val userData: UserData) : SettingsUiState + + data class Error(val message: String?) : SettingsUiState +} + diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml new file mode 100644 index 0000000..6a927c2 --- /dev/null +++ b/feature/settings/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + Settings + OK + Theme + Follow system + Dark + Light + Use Dynamic Color + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3ecc85b..4728d84 100644 --- a/gradle.properties +++ b/gradle.properties @@ -38,4 +38,4 @@ org.gradle.configureondemand=true # AndroidX Migration https://developer.android.com/jetpack/androidx/migrate android.useAndroidX=true -org.gradle.configuration-cache=true \ No newline at end of file +org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e524ea..733d77f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ coreSplashscreen = "1.0.1" kotlinxImmutable = "0.4.0" androidxActivity = "1.11.0" androidxCore = "1.17.0" +androidxDataStore = "1.1.7" androidxLifecycle = "2.9.2" androidxRoom = "2.8.3" androidxArchCore = "2.2.0" @@ -39,6 +40,8 @@ mockito = "5.11.0" mockito-kotlin = "6.1.0" spotless = "6.25.0" baselineprofile = "1.4.0" +protobufPlugin = "0.9.5" +protobuf = "4.31.1" [libraries] androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } @@ -49,6 +52,7 @@ androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", versi androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidXStartup" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-dataStore = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "androidxNavigation3" } androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "androidxNavigation3" } @@ -70,6 +74,7 @@ landscapist-image = { group = "com.github.skydoves", name = "landscapist-image", landscapist-animation = { group = "com.github.skydoves", name = "landscapist-animation", version.ref = "landscapist" } landscapist-placeholder = { group = "com.github.skydoves", name = "landscapist-placeholder", version.ref = "landscapist" } landscapist-palette = { group = "com.github.skydoves", name = "landscapist-palette", version.ref = "landscapist" } +hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } @@ -80,9 +85,12 @@ retrofit-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-ko okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okHttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-immutable-collection = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxImmutable" } +protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } # unit test junit = { module = "junit:junit", version.ref = "junit" } @@ -108,6 +116,7 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-test = { id = "com.android.test", version = "8.12.0" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "composeStabilityAnalyzer" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } @@ -116,6 +125,7 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } hilt-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +protobuf-plugin = { id = "com.google.protobuf", version.ref = "protobufPlugin" } [bundles] retrofitBundle = ["retrofit", "retrofit-kotlinx-serialization", "okhttp-logging-interceptor"] diff --git a/settings.gradle.kts b/settings.gradle.kts index 707d157..7e0de52 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,11 @@ @file:Suppress("UnstableApiUsage") +include(":feature:settings") + + +include(":core:common") + + include(":baselineprofile") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") @@ -92,6 +98,7 @@ include(":core:model") include(":core:network") include(":core:viewmodel") include(":core:database") +include(":core:datastore") include(":core:data") include(":core:test") include(":core:navigation")