diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe2278788..a5644595c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.neki.android.application) + alias(libs.plugins.neki.android.application.compose) } android { @@ -14,19 +15,21 @@ dependencies { implementation(projects.core.common) implementation(projects.core.dataApi) implementation(projects.core.data) + implementation(projects.core.designsystem) implementation(projects.core.domain) implementation(projects.core.model) - implementation(projects.core.designsystem) + implementation(projects.core.navigation) implementation(projects.feature.sample.impl) implementation(projects.feature.sample.api) - - implementation(projects.core.common) - implementation(projects.core.data) - implementation(projects.core.dataApi) - implementation(projects.core.designsystem) - implementation(projects.core.domain) - implementation(projects.core.model) + implementation(projects.feature.pose.api) + implementation(projects.feature.pose.impl) + implementation(projects.feature.archive.api) + implementation(projects.feature.archive.impl) + implementation(projects.feature.map.api) + implementation(projects.feature.map.impl) implementation(libs.timber) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation3.ui) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f892cf440..e0cdfadaf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:theme="@style/Theme.Neki"> diff --git a/app/src/main/java/com/neki/android/app/MainActivity.kt b/app/src/main/java/com/neki/android/app/MainActivity.kt new file mode 100644 index 000000000..aeffbba4b --- /dev/null +++ b/app/src/main/java/com/neki/android/app/MainActivity.kt @@ -0,0 +1,66 @@ +package com.neki.android.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.neki.android.app.ui.BottomNavigationBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.NavigatorImpl +import com.neki.android.core.navigation.toEntries +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var navigator: NavigatorImpl + + @Inject + lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + val shouldShowBottomBar by remember(navigator.state.currentKey) { + mutableStateOf(navigator.state.currentKey in navigator.state.topLevelKeys) + } + + NekiTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + BottomNavigationBar( + visible = shouldShowBottomBar, + currentTab = navigator.state.currentTopLevelKey, + currentKey = navigator.state.currentKey, + onTabSelected = { navigator.navigate(it.navKey) }, + ) + } + ) { innerPadding -> + NavDisplay( + modifier = Modifier.padding(innerPadding), + entries = navigator.state.toEntries( + entryProvider = entryProvider { + entryProviderScopes.forEach { builder -> this.builder() } + }, + ), + onBack = { navigator.goBack() }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/neki/android/app/navigation/TopLevelNavItem.kt b/app/src/main/java/com/neki/android/app/navigation/TopLevelNavItem.kt new file mode 100644 index 000000000..cfbc08bdb --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/TopLevelNavItem.kt @@ -0,0 +1,39 @@ +package com.neki.android.app.navigation + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.navigation3.runtime.NavKey +import com.neki.android.app.R +import com.neki.android.feature.archive.api.ArchiveNavKey +import com.neki.android.feature.map.api.MapNavKey +import com.neki.android.feature.pose.api.PoseNavKey + +enum class TopLevelNavItem( + @DrawableRes val selectedIcon: Int, + @DrawableRes val unselectedIcon: Int, + @StringRes val iconTextId: Int, + val navKey: NavKey, +) { + POSE_RECOMMEND( + selectedIcon = R.drawable.ic_nav_pose_selected, + unselectedIcon = R.drawable.ic_nav_pose_unselected, + iconTextId = R.string.top_level_nav_pose, + navKey = PoseNavKey.Pose, + ), + ARCHIVE( + selectedIcon = R.drawable.ic_nav_archive_selected, + unselectedIcon = R.drawable.ic_nav_archive_unselected, + iconTextId = R.string.top_level_nav_archive, + navKey = ArchiveNavKey.Archive + ), + MAP( + selectedIcon = R.drawable.ic_nav_map_selected, + unselectedIcon = R.drawable.ic_nav_map_unselected, + iconTextId = R.string.top_level_nav_map, + navKey = MapNavKey.Map + ); + + companion object { + val startTopLevelItem = ARCHIVE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/neki/android/app/navigation/di/AppModule.kt b/app/src/main/java/com/neki/android/app/navigation/di/AppModule.kt new file mode 100644 index 000000000..17ce598fd --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/di/AppModule.kt @@ -0,0 +1,18 @@ +package com.neki.android.app.navigation.di + +import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.NavigatorImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent + +@Module +@InstallIn(ActivityRetainedComponent::class) +internal interface AppModule { + + @Binds + fun bindsNavigator( + impl: NavigatorImpl, + ): Navigator +} \ No newline at end of file diff --git a/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt b/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt new file mode 100644 index 000000000..05c0ec065 --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt @@ -0,0 +1,24 @@ +package com.neki.android.app.navigation.di + +import com.neki.android.app.navigation.keys.START_NAV_KEY +import com.neki.android.app.navigation.keys.TOP_LEVEL_NAV_KEYS +import com.neki.android.core.navigation.NavigationState +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +internal object NavigationModule { + + @Provides + @ActivityRetainedScoped + fun providesNavigationState(): NavigationState { + return NavigationState( + startKey = START_NAV_KEY, + topLevelKeys = TOP_LEVEL_NAV_KEYS.toSet(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt b/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt new file mode 100644 index 000000000..2809e30ce --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt @@ -0,0 +1,7 @@ +package com.neki.android.app.navigation.keys + +import com.neki.android.app.navigation.TopLevelNavItem +import com.neki.android.feature.archive.api.ArchiveNavKey + +internal val START_NAV_KEY = ArchiveNavKey.Archive +internal val TOP_LEVEL_NAV_KEYS = TopLevelNavItem.entries.map { it.navKey } diff --git a/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt b/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt new file mode 100644 index 000000000..3def40c5a --- /dev/null +++ b/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt @@ -0,0 +1,113 @@ +package com.neki.android.app.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey +import com.neki.android.app.navigation.TopLevelNavItem +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun BottomNavigationBar( + visible: Boolean, + currentKey: NavKey, + currentTab: NavKey, + tabs: List = TopLevelNavItem.entries, + onTabSelected: (TopLevelNavItem) -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + Surface( + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(2.5.dp) + ) { + tabs.forEach { tab -> + BottomNavigationBarItem( + modifier = Modifier.weight(1f), + selected = tab.navKey == currentTab, + tab = tab, + onClick = { if (tab.navKey != currentKey) onTabSelected(tab) } + ) + } + } + } + } +} + +@Composable +fun BottomNavigationBarItem( + selected: Boolean, + tab: TopLevelNavItem, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + val icon = if (selected) tab.selectedIcon else tab.unselectedIcon + val color = if (selected) Color(0xFF3C3E48) else Color(0xFFB7B9C3) + + Surface( + modifier = modifier, + onClick = onClick + ) { + Column( + modifier = Modifier.padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(icon), + contentDescription = stringResource(tab.iconTextId), + tint = color, + ) + Text( + text = stringResource(tab.iconTextId), + color = color, + ) + } + } +} + +@Preview +@Composable +private fun BottomNavigationBarPreview() { + var currentTab by remember { mutableStateOf(TopLevelNavItem.ARCHIVE) } + NekiTheme { + BottomNavigationBar( + visible = true, + tabs = TopLevelNavItem.entries, + currentTab = currentTab.navKey, + currentKey = currentTab.navKey, + ) { currentTab = it } + } +} diff --git a/app/src/main/res/drawable/ic_nav_archive_selected.xml b/app/src/main/res/drawable/ic_nav_archive_selected.xml new file mode 100644 index 000000000..cbc544202 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_archive_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_archive_unselected.xml b/app/src/main/res/drawable/ic_nav_archive_unselected.xml new file mode 100644 index 000000000..3326a37a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_archive_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_map_selected.xml b/app/src/main/res/drawable/ic_nav_map_selected.xml new file mode 100644 index 000000000..bf9d67933 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_map_selected.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_nav_map_unselected.xml b/app/src/main/res/drawable/ic_nav_map_unselected.xml new file mode 100644 index 000000000..f8fa1da48 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_map_unselected.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_nav_pose_selected.xml b/app/src/main/res/drawable/ic_nav_pose_selected.xml new file mode 100644 index 000000000..ef5d248c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_pose_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_pose_unselected.xml b/app/src/main/res/drawable/ic_nav_pose_unselected.xml new file mode 100644 index 000000000..61143f466 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_pose_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 820454ae5..69f838a75 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,8 @@ Neki + + 포즈 추천 + 아카이빙 + 네컷지도 + \ No newline at end of file diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 4c85a0abf..f6ad754b4 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -31,9 +31,13 @@ gradlePlugin { id = "neki.kotlin.library" implementationClass = "KotlinLibraryConventionPlugin" } - register("androidFeatureCompose") { - id = "neki.android.feature" - implementationClass = "AndroidFeatureConventionPlugin" + register("androidFeatureApi") { + id = "neki.android.feature.api" + implementationClass = "AndroidFeatureApiConventionPlugin" + } + register("androidFeatureImplCompose") { + id = "neki.android.feature.impl" + implementationClass = "AndroidFeatureImplConventionPlugin" } register("hilt") { id = "neki.hilt" diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt b/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt index ddc7aa593..64d82394d 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt @@ -9,8 +9,8 @@ object BuildConst { const val VERSION_NAME = "1.0.0" const val MIN_SDK = 29 - const val TARGET_SDK = 35 - const val COMPILE_SDK = 35 + const val TARGET_SDK = 36 + const val COMPILE_SDK = 36 const val JDK_VERSION = 21 val JAVA_VERSION = JavaVersion.VERSION_21 diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureApiConventionPlugin.kt b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureApiConventionPlugin.kt new file mode 100644 index 000000000..ee2cd726e --- /dev/null +++ b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureApiConventionPlugin.kt @@ -0,0 +1,19 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class AndroidFeatureApiConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("neki.android.library") + apply("org.jetbrains.kotlin.plugin.serialization") + } + + dependencies { + "api"(project(":core:navigation")) + } + } + } +} diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureConventionPlugin.kt b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt similarity index 68% rename from build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureConventionPlugin.kt rename to build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt index 5d6c60438..90e2a7091 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureConventionPlugin.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt @@ -1,8 +1,9 @@ +import com.neki.android.buildlogic.extensions.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies -class AndroidFeatureConventionPlugin: Plugin { +class AndroidFeatureImplConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { @@ -16,6 +17,9 @@ class AndroidFeatureConventionPlugin: Plugin { "implementation"(project(":core:model")) "implementation"(project(":core:data-api")) "implementation"(project(":core:common")) + + "implementation"(libs.findLibrary("androidx.navigation3.runtime").get()) + "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModel.compose").get()) } } } diff --git a/build.gradle.kts b/build.gradle.kts index 4d0913b8b..4ba23462b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false diff --git a/core/navigation/.gitignore b/core/navigation/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/navigation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts new file mode 100644 index 000000000..2d21a85a2 --- /dev/null +++ b/core/navigation/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.neki.android.library) + alias(libs.plugins.neki.android.library.compose) + alias(libs.plugins.neki.hilt) +} + +android { + namespace = "com.neki.android.core.navigation" +} + +dependencies { + api(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewModel.navigation3) +} \ No newline at end of file diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt new file mode 100644 index 000000000..b0b17c67f --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt @@ -0,0 +1,50 @@ +package com.neki.android.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import javax.inject.Inject + +class NavigationState @Inject constructor( + val startKey: NavKey, + val topLevelKeys: Set, +) { + val topLevelStack: SnapshotStateList = mutableStateListOf(startKey) + val subStacks = topLevelKeys.associateWith { key -> mutableStateListOf(key) } + val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() } + + val currentSubStack: SnapshotStateList + get() = subStacks[currentTopLevelKey] + ?: error("Sub stack for $currentTopLevelKey does not exist") + + val currentKey: NavKey by derivedStateOf { currentSubStack.last() } +} + +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry, +): SnapshotStateList> { + val decoratedEntries = subStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider, + ) + } + + return topLevelStack + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/Navigator.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/Navigator.kt new file mode 100644 index 000000000..5ad7a01f7 --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/Navigator.kt @@ -0,0 +1,11 @@ +package com.neki.android.core.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey + +typealias EntryProviderInstaller = EntryProviderScope.() -> Unit + +interface Navigator { + fun navigate(key: NavKey) + fun goBack() +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt new file mode 100644 index 000000000..83e275e2a --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt @@ -0,0 +1,51 @@ +package com.neki.android.core.navigation + +import androidx.navigation3.runtime.NavKey +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject + +@ActivityRetainedScoped +class NavigatorImpl @Inject constructor( + val state: NavigationState, +) : Navigator { + + override fun navigate(key: NavKey) { + when (key) { + state.currentTopLevelKey -> clearSubStack() + in state.topLevelKeys -> goToTopLevel(key) + else -> goToKey(key) + } + } + + override fun goBack() { + when (state.currentKey) { + state.startKey -> error("You cannot go back from the start route") + state.currentTopLevelKey -> { + state.topLevelStack.removeLastOrNull() + } + + else -> state.currentSubStack.removeLastOrNull() + } + } + + private fun goToKey(key: NavKey) { + state.currentSubStack.apply { + remove(key) + add(key) + } + } + + private fun goToTopLevel(key: NavKey) { + state.topLevelStack.apply { + if (key == state.startKey) clear() + else remove(key) + add(key) + } + } + + private fun clearSubStack() { + state.currentSubStack.run { + if (size > 1) subList(1, size).clear() + } + } +} diff --git a/feature/archive/api/.gitignore b/feature/archive/api/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/feature/archive/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/archive/api/build.gradle.kts b/feature/archive/api/build.gradle.kts new file mode 100644 index 000000000..356bd1f24 --- /dev/null +++ b/feature/archive/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.archive.api" +} diff --git a/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt new file mode 100644 index 000000000..4f5815878 --- /dev/null +++ b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt @@ -0,0 +1,15 @@ +package com.neki.android.feature.archive.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface ArchiveNavKey : NavKey { + + @Serializable + data object Archive : ArchiveNavKey +} + +fun Navigator.navigateToArchive() { + navigate(ArchiveNavKey.Archive) +} diff --git a/feature/archive/impl/.gitignore b/feature/archive/impl/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/feature/archive/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/archive/impl/build.gradle.kts b/feature/archive/impl/build.gradle.kts new file mode 100644 index 000000000..b38d38ef0 --- /dev/null +++ b/feature/archive/impl/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.archive.impl" +} + +dependencies { + implementation(projects.feature.archive.api) +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt new file mode 100644 index 000000000..c9076d642 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt @@ -0,0 +1,27 @@ +package com.neki.android.feature.archive.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.feature.archive.api.ArchiveNavKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object ArchiveEntryProviderModule { + + @IntoSet + @Provides + fun provideArchiveEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + archiveEntry(navigator) + } +} + +private fun EntryProviderScope.archiveEntry(navigator: Navigator) { + entry {} +} diff --git a/feature/map/api/.gitignore b/feature/map/api/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/map/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/map/api/build.gradle.kts b/feature/map/api/build.gradle.kts new file mode 100644 index 000000000..673d2a9b5 --- /dev/null +++ b/feature/map/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.map.api" +} diff --git a/feature/map/api/src/main/java/com/neki/android/feature/map/api/MapNavKey.kt b/feature/map/api/src/main/java/com/neki/android/feature/map/api/MapNavKey.kt new file mode 100644 index 000000000..16472c6a2 --- /dev/null +++ b/feature/map/api/src/main/java/com/neki/android/feature/map/api/MapNavKey.kt @@ -0,0 +1,15 @@ +package com.neki.android.feature.map.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface MapNavKey : NavKey { + + @Serializable + data object Map : MapNavKey +} + +fun Navigator.navigateToMap() { + navigate(MapNavKey.Map) +} diff --git a/feature/map/impl/.gitignore b/feature/map/impl/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/map/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/map/impl/build.gradle.kts b/feature/map/impl/build.gradle.kts new file mode 100644 index 000000000..2e60125ab --- /dev/null +++ b/feature/map/impl/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.map.impl" +} + +dependencies { + implementation(projects.feature.map.api) +} \ No newline at end of file diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapEntryProvider.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapEntryProvider.kt new file mode 100644 index 000000000..c4fa14ef2 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapEntryProvider.kt @@ -0,0 +1,29 @@ +package com.neki.android.feature.map.impl + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.feature.map.api.MapNavKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object MapEntryProviderModule { + + @IntoSet + @Provides + fun provideMapEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + mapEntry(navigator) + } +} + +private fun EntryProviderScope.mapEntry(navigator: Navigator) { + entry {} +} + + diff --git a/feature/pose/api/.gitignore b/feature/pose/api/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/pose/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/pose/api/build.gradle.kts b/feature/pose/api/build.gradle.kts new file mode 100644 index 000000000..89699e514 --- /dev/null +++ b/feature/pose/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.pose.api" +} diff --git a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt new file mode 100644 index 000000000..ffe02a250 --- /dev/null +++ b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt @@ -0,0 +1,15 @@ +package com.neki.android.feature.pose.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface PoseNavKey : NavKey { + + @Serializable + data object Pose : PoseNavKey +} + +fun Navigator.navigateToPose() { + navigate(PoseNavKey.Pose) +} diff --git a/feature/pose/impl/.gitignore b/feature/pose/impl/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/pose/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/pose/impl/build.gradle.kts b/feature/pose/impl/build.gradle.kts new file mode 100644 index 000000000..26615b81b --- /dev/null +++ b/feature/pose/impl/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.pose.impl" +} + +dependencies { + implementation(projects.feature.pose.api) +} \ No newline at end of file diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt new file mode 100644 index 000000000..7323ae4f3 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt @@ -0,0 +1,29 @@ +package com.neki.android.feature.pose.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.feature.pose.api.PoseNavKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object PoseEntryProviderModule { + + @IntoSet + @Provides + fun providePoseEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + poseEntry(navigator) + } +} + +private fun EntryProviderScope.poseEntry(navigator: Navigator) { + entry { } +} + + diff --git a/feature/sample/api/build.gradle.kts b/feature/sample/api/build.gradle.kts index 3b9f860f1..128f6619a 100644 --- a/feature/sample/api/build.gradle.kts +++ b/feature/sample/api/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - alias(libs.plugins.neki.android.library) + alias(libs.plugins.neki.android.feature.api) } android { diff --git a/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/MyClass.kt b/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/MyClass.kt deleted file mode 100644 index d1d5f9f94..000000000 --- a/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/MyClass.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.neki.android.feature.sample.api - -class MyClass { -} \ No newline at end of file diff --git a/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/SampleNavKey.kt b/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/SampleNavKey.kt new file mode 100644 index 000000000..c5a5d3763 --- /dev/null +++ b/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/SampleNavKey.kt @@ -0,0 +1,15 @@ +package com.neki.android.feature.sample.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface SampleNavKey : NavKey { + + @Serializable + data class Sample(val id: Long) : SampleNavKey +} + +fun Navigator.navigateToSample(id: Long) { + navigate(SampleNavKey.Sample(id)) +} diff --git a/feature/sample/impl/build.gradle.kts b/feature/sample/impl/build.gradle.kts index 9f36724af..17361a44a 100644 --- a/feature/sample/impl/build.gradle.kts +++ b/feature/sample/impl/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - alias(libs.plugins.neki.android.feature) + alias(libs.plugins.neki.android.feature.impl) } android { @@ -10,5 +10,5 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) - + implementation(projects.feature.sample.api) } \ No newline at end of file diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainActivity.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainActivity.kt deleted file mode 100644 index 26aa9bcaa..000000000 --- a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.neki.android.feature.sample.impl - -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.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.neki.android.core.designsystem.ui.theme.NekiTheme -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - NekiTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } - } - } - - viewModel - } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - NekiTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainViewModel.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainViewModel.kt deleted file mode 100644 index f6ee44861..000000000 --- a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.neki.android.feature.sample.impl - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.neki.android.core.dataapi.repository.SampleRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MainViewModel @Inject constructor( - private val sampleRepository: SampleRepository -): ViewModel() { - - init { - getPost(id = 1) - } - - fun getPosts() { - viewModelScope.launch { - sampleRepository.getPosts() - } - } - - fun getPost( - id: Int - ) { - viewModelScope.launch { - sampleRepository.getPost(id = id) - } - } -} diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleEntryProvider.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleEntryProvider.kt new file mode 100644 index 000000000..616549fee --- /dev/null +++ b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleEntryProvider.kt @@ -0,0 +1,35 @@ +package com.neki.android.feature.sample.impl + +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.feature.sample.api.SampleNavKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object SampleEntryProviderModule { + + @IntoSet + @Provides + fun provideSampleEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + sampleEntry(navigator) + } +} + +private fun EntryProviderScope.sampleEntry(navigator: Navigator) { + entry { key -> + val viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(key) + } + ) + SampleScreen(viewModel = viewModel) + } +} diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleScreen.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleScreen.kt new file mode 100644 index 000000000..8e8dd2340 --- /dev/null +++ b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleScreen.kt @@ -0,0 +1,14 @@ +package com.neki.android.feature.sample.impl + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel + +@Composable +fun SampleScreen( + modifier: Modifier = Modifier, + viewModel: SampleViewModel = hiltViewModel(), +) { + Text("Sample") +} diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleViewModel.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleViewModel.kt new file mode 100644 index 000000000..91021e222 --- /dev/null +++ b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleViewModel.kt @@ -0,0 +1,21 @@ +package com.neki.android.feature.sample.impl + +import androidx.lifecycle.ViewModel +import com.neki.android.feature.sample.api.SampleNavKey +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel + +@HiltViewModel(assistedFactory = SampleViewModel.Factory::class) +class SampleViewModel @AssistedInject constructor( + @Assisted val navKey: SampleNavKey.Sample, +) : ViewModel() { + + val id = navKey.id + + @AssistedFactory + interface Factory { + fun create(navKey: SampleNavKey.Sample): SampleViewModel + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b17f43a6..99fdf5726 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,10 +14,14 @@ ksp = "2.1.0-1.0.29" appcompat = "1.7.1" kotlinxSerializationJson = "1.9.0" jetbrainsKotlinJvmVersion = "2.1.0" -hilt = "2.51.1" +hilt = "2.54" ktor = "2.3.12" androidxDatastore = "1.1.2" timber = "5.0.1" +androidxNavigation3 = "1.0.0" +androidxLifecycleViewModelNavigation3 = "2.10.0" +androidxHiltLifecycleViewmodelCompose = "1.3.0-alpha02" +material = "1.13.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,9 +40,14 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "androidxNavigation3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "androidxNavigation3" } +androidx-lifecycle-viewModel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycleViewModelNavigation3" } + kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-lifecycle-viewModel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHiltLifecycleViewmodelCompose" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } @@ -56,6 +65,7 @@ timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "tim kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] # Plugins defined by this project @@ -63,7 +73,8 @@ neki-android-application = { id = "neki.android.application", version = "unspeci neki-android-application-compose = { id = "neki.android.application.compose", version = "unspecified" } neki-android-library = { id = "neki.android.library", version = "unspecified" } neki-android-library-compose = { id = "neki.android.library.compose", version = "unspecified" } -neki-android-feature = { id = "neki.android.feature", version = "unspecified" } +neki-android-feature-impl = { id = "neki.android.feature.impl", version = "unspecified" } +neki-android-feature-api = { id = "neki.android.feature.api", version = "unspecified" } neki-kotlin-library = { id = "neki.kotlin.library", version = "unspecified" } neki-hilt = { id = "neki.hilt", version = "unspecified" } @@ -74,4 +85,5 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvmVersion" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d8bc5d032..79c5f5252 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,3 +32,10 @@ include(":core:data-api") include(":core:model") include(":feature:sample:api") include(":feature:sample:impl") +include(":core:navigation") +include(":feature:pose:api") +include(":feature:pose:impl") +include(":feature:archive:api") +include(":feature:archive:impl") +include(":feature:map:api") +include(":feature:map:impl")