diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 95a0eed7..d0a2ec3e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,12 +64,13 @@ android { } dependencies { - implementation(project(":feature:home")) implementation(project(":domain")) implementation(project(":data")) implementation(project(":core:coroutine")) implementation(project(":core:designsystem")) implementation(project(":core:navigation")) + implementation(project(":feature:home")) + implementation(project(":feature:storage")) implementation(libs.core.ktx) implementation(libs.kotlin.android) diff --git a/app/src/main/java/com/mashup/dorabangs/navigation/DoraAppState.kt b/app/src/main/java/com/mashup/dorabangs/navigation/DoraAppState.kt index 08ecb378..4f7b2e44 100644 --- a/app/src/main/java/com/mashup/dorabangs/navigation/DoraAppState.kt +++ b/app/src/main/java/com/mashup/dorabangs/navigation/DoraAppState.kt @@ -11,6 +11,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions import com.mashup.dorabangs.core.designsystem.component.bottomnavigation.BottomNavigationDestination import com.mashup.dorabangs.feature.navigation.navigateToHome +import com.mashup.dorabangs.feature.storage.navigation.navigateToStorage import okhttp3.internal.toImmutableList @Composable @@ -41,7 +42,7 @@ class DoraAppState( when (bottomNavigationDestination) { BottomNavigationDestination.HOME -> navController.navigateToHome(bottomNavigationOption) - BottomNavigationDestination.STORAGE -> navController.navigateToHome(bottomNavigationOption) + BottomNavigationDestination.STORAGE -> navController.navigateToStorage(bottomNavigationOption) } } diff --git a/app/src/main/java/com/mashup/dorabangs/navigation/MainNavHost.kt b/app/src/main/java/com/mashup/dorabangs/navigation/MainNavHost.kt index db330e55..956753e4 100644 --- a/app/src/main/java/com/mashup/dorabangs/navigation/MainNavHost.kt +++ b/app/src/main/java/com/mashup/dorabangs/navigation/MainNavHost.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import com.mashup.core.navigation.NavigationRoute import com.mashup.dorabangs.feature.navigation.homeNavigation +import com.mashup.dorabangs.feature.storage.navigation.storageNavigation @Composable fun MainNavHost( @@ -18,5 +19,6 @@ fun MainNavHost( startDestination = startDestination, ) { homeNavigation() + storageNavigation() } } diff --git a/feature/storage/.gitignore b/feature/storage/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/storage/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/storage/build.gradle.kts b/feature/storage/build.gradle.kts new file mode 100644 index 00000000..49854b8d --- /dev/null +++ b/feature/storage/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + alias(libs.plugins.com.android.library) + alias(libs.plugins.org.jetbrains.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.kapt) +} + +android { + namespace = "com.mashup.dorabangs.feature.storage" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.3" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_18 + targetCompatibility = JavaVersion.VERSION_18 + } + kotlinOptions { + jvmTarget = "18" + } +} + +dependencies { + implementation(project(":domain")) + implementation(project(":core:coroutine")) + implementation(project(":core:designsystem")) + implementation(project(":core:navigation")) + + // Compose + implementation(libs.ui) + implementation(libs.ui.graphics) + implementation(libs.ui.tooling.preview) + implementation(libs.material3) + implementation(platform(libs.compose.bom)) + implementation(libs.material) + + // Test + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.ui.test.junit4) + debugImplementation(libs.ui.tooling) + debugImplementation(libs.ui.test.manifest) + testImplementation(libs.junit) + + // Hilt + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + + // Orbit + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + implementation(libs.orbit.compose) +} diff --git a/feature/storage/consumer-rules.pro b/feature/storage/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/storage/proguard-rules.pro b/feature/storage/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/storage/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/storage/src/androidTest/java/com/mashup/dorabangs/feature/storage/ExampleInstrumentedTest.kt b/feature/storage/src/androidTest/java/com/mashup/dorabangs/feature/storage/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..771ce998 --- /dev/null +++ b/feature/storage/src/androidTest/java/com/mashup/dorabangs/feature/storage/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.mashup.dorabangs.feature.storage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.mashup.dorabangs.feature.storage.test", appContext.packageName) + } +} diff --git a/feature/storage/src/main/AndroidManifest.xml b/feature/storage/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/storage/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/navigation/StorageNavigation.kt b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/navigation/StorageNavigation.kt new file mode 100644 index 00000000..807c915a --- /dev/null +++ b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/navigation/StorageNavigation.kt @@ -0,0 +1,18 @@ +package com.mashup.dorabangs.feature.storage.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.mashup.core.navigation.NavigationRoute +import com.mashup.dorabangs.feature.storage.storage.StorageRoute + +fun NavController.navigateToStorage(navOptions: NavOptions) = navigate(NavigationRoute.StorageScreen.route, navOptions) + +fun NavGraphBuilder.storageNavigation() { + composable( + route = NavigationRoute.StorageScreen.route, + ) { + StorageRoute() + } +} diff --git a/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageFolderListItem.kt b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageFolderListItem.kt new file mode 100644 index 00000000..edc7c268 --- /dev/null +++ b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageFolderListItem.kt @@ -0,0 +1,167 @@ +package com.mashup.dorabangs.feature.storage.storage + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mashup.dorabangs.core.designsystem.theme.DoraColorTokens +import com.mashup.dorabangs.core.designsystem.theme.DoraTypoTokens +import com.mashup.dorabangs.feature.storage.storage.model.StorageFolderItem +import com.mashup.dorabangs.feature.storage.storage.model.StorageListState + +@Composable +fun StorageFolderList( + customList: List = StorageListState.customFolderList(), + navigateToStorageDetail: (StorageFolderItem) -> Unit = {}, + onClickAddMoreButton: (StorageFolderItem) -> Unit = {}, +) { + LazyColumn( + modifier = Modifier.padding(horizontal = 20.dp), + ) { + item { + Spacer(modifier = Modifier.height(20.dp)) + StorageDefaultFolder() + Spacer(modifier = Modifier.height(20.dp)) + } + itemsIndexed(customList) { idx, item -> + StorageListItem( + item = item, + isFirstItem = idx == 0, + isLastItem = idx == customList.lastIndex, + navigateToStorageDetail = { navigateToStorageDetail(item) }, + onClickAddMoreButton = { onClickAddMoreButton(item) }, + ) + if (idx != customList.lastIndex) { + HorizontalDivider( + modifier = + Modifier + .height(0.5.dp) + .background(color = DoraColorTokens.G1), + ) + } + } + item { + Spacer(modifier = Modifier.height(60.dp)) + } + } +} + +@Composable +fun StorageDefaultFolder( + list: List = StorageListState.defaultFolderList(), + navigateToStorageDetail: (StorageFolderItem) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxWidth(), + ) { + list.forEachIndexed { idx, item -> + StorageListItem( + item = item, + isFirstItem = idx == 0, + isLastItem = idx == list.lastIndex, + navigateToStorageDetail = { navigateToStorageDetail(item) }, + ) + if (idx != list.lastIndex) { + HorizontalDivider( + modifier = Modifier + .height(0.5.dp) + .background(color = DoraColorTokens.G1), + ) + } + } + } +} + +@Composable +fun StorageListItem( + item: StorageFolderItem, + isDefault: Boolean = false, + isFirstItem: Boolean = false, + isLastItem: Boolean = false, + navigateToStorageDetail: () -> Unit = {}, + onClickAddMoreButton: () -> Unit = {}, +) { + val shape = + if (isFirstItem) { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } else if (isLastItem) { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + } else { + RectangleShape + } + Box( + modifier = Modifier + .fillMaxWidth() + .background(color = DoraColorTokens.P1, shape = shape) + .padding(vertical = 14.dp, horizontal = 12.dp), + ) { + Row( + modifier = Modifier + .align(Alignment.CenterStart) + .clickable { navigateToStorageDetail() }, + ) { + Icon( + painter = painterResource(id = androidx.core.R.drawable.ic_call_answer), + contentDescription = "folderIcon", + ) + Text( + modifier = Modifier + .padding(start = 12.dp) + .align(Alignment.CenterVertically), + textAlign = TextAlign.Center, + text = item.folderTitle.title, + color = DoraColorTokens.G9, + style = DoraTypoTokens.caption3Medium, + ) + } + Row( + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Text( + modifier = Modifier + .padding(end = 12.dp) + .align(Alignment.CenterVertically), + text = "${item.postCount}", + color = DoraColorTokens.G4, + style = DoraTypoTokens.caption3Medium, + ) + val icon = + if (isDefault) { + androidx.core.R.drawable.ic_call_answer + } else { + androidx.core.R.drawable.ic_call_answer + } + Icon( + modifier = Modifier.clickable { if (isDefault) navigateToStorageDetail() else onClickAddMoreButton() }, + painter = painterResource(id = icon), + contentDescription = "folderIcon", + ) + } + } +} + +@Preview +@Composable +fun PreviewStorageFolderList() { + StorageFolderList() +} diff --git a/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageScreen.kt b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageScreen.kt new file mode 100644 index 00000000..41841e62 --- /dev/null +++ b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageScreen.kt @@ -0,0 +1,81 @@ +package com.mashup.dorabangs.feature.storage.storage + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.mashup.dorabangs.core.designsystem.theme.DoraColorTokens +import com.mashup.dorabangs.core.designsystem.theme.DoraTypoTokens +import com.mashup.dorabangs.feature.storage.R +import com.mashup.dorabangs.feature.storage.storage.model.StorageFolderItem + +@Composable +fun StorageRoute( + storageViewModel: StorageViewModel = hiltViewModel(), + navigateToStorageDetail: (StorageFolderItem) -> Unit = {}, +) { + StorageScreen( + navigateToStorageDetail = navigateToStorageDetail, + onClickAddMoreButton = storageViewModel::showEditFolderBottomSheet, + ) +} + +@Composable +fun StorageScreen( + navigateToStorageDetail: (StorageFolderItem) -> Unit = {}, + onClickAddMoreButton: (StorageFolderItem) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = DoraColorTokens.G1), + ) { + StorageTopAppBar() + StorageFolderList( + navigateToStorageDetail = navigateToStorageDetail, + onClickAddMoreButton = onClickAddMoreButton, + ) + } +} + +@Composable +fun StorageTopAppBar(modifier: Modifier = Modifier) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 20.dp, vertical = 12.dp) + .background(Color.Transparent), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(id = R.string.storage), + style = DoraTypoTokens.base1Bold, + ) + Icon( + painter = painterResource(id = com.google.android.material.R.drawable.ic_call_answer), + contentDescription = "folderIcon", + ) + } +} + +@Preview +@Composable +fun PreviewStorageScreen() { + StorageRoute() +} diff --git a/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageViewModel.kt b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageViewModel.kt new file mode 100644 index 00000000..f1721005 --- /dev/null +++ b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/StorageViewModel.kt @@ -0,0 +1,21 @@ +package com.mashup.dorabangs.feature.storage.storage + +import androidx.lifecycle.ViewModel +import com.mashup.dorabangs.feature.storage.storage.model.StorageFolderItem +import com.mashup.dorabangs.feature.storage.storage.model.StorageListSideEffect +import com.mashup.dorabangs.feature.storage.storage.model.StorageListState +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class StorageViewModel @Inject constructor() : ViewModel(), ContainerHost { + override val container = container(StorageListState()) + + fun showEditFolderBottomSheet(item: StorageFolderItem) = intent { + postSideEffect(sideEffect = StorageListSideEffect.ShowEditFolderBottomSheet) + } +} diff --git a/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/model/StorageListSideEffect.kt b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/model/StorageListSideEffect.kt new file mode 100644 index 00000000..07ee8a6c --- /dev/null +++ b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/model/StorageListSideEffect.kt @@ -0,0 +1,5 @@ +package com.mashup.dorabangs.feature.storage.storage.model + +sealed class StorageListSideEffect { + object ShowEditFolderBottomSheet : StorageListSideEffect() +} diff --git a/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/model/StorageListState.kt b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/model/StorageListState.kt new file mode 100644 index 00000000..5b1da38b --- /dev/null +++ b/feature/storage/src/main/java/com/mashup/dorabangs/feature/storage/storage/model/StorageListState.kt @@ -0,0 +1,45 @@ +package com.mashup.dorabangs.feature.storage.storage.model + +data class StorageListState( + val defaultStorageFolderList: List = listOf(), + val customStorageFolderList: List = listOf(), +) { + companion object { + fun defaultFolderList() = + listOf( + StorageFolderItem(folderTitle = StorageDefaultFolder.ALL, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.FAVORITE, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.LATER_READ, postCount = 0), + ) + fun customFolderList() = + listOf( + StorageFolderItem(folderTitle = StorageDefaultFolder.ALL, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.FAVORITE, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.LATER_READ, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.ALL, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.FAVORITE, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.LATER_READ, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.ALL, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.FAVORITE, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.LATER_READ, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.ALL, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.FAVORITE, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.LATER_READ, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.ALL, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.FAVORITE, postCount = 0), + StorageFolderItem(folderTitle = StorageDefaultFolder.LATER_READ, postCount = 0), + + ) + } +} + +data class StorageFolderItem( + val folderTitle: StorageDefaultFolder, + val postCount: Int = 0, +) + +enum class StorageDefaultFolder(val title: String) { + ALL("모든 링크"), + FAVORITE("즐겨찾기"), + LATER_READ("나중에 읽을 링크"), +} diff --git a/feature/storage/src/main/res/values/string.xml b/feature/storage/src/main/res/values/string.xml new file mode 100644 index 00000000..ee9f18ea --- /dev/null +++ b/feature/storage/src/main/res/values/string.xml @@ -0,0 +1,11 @@ + + + 나중에 읽을 링크 + 모든 링크 + 즐겨찾기 + 최신순 + 과거순 + 전체 + 읽지 않은 + 보관함 + \ No newline at end of file diff --git a/feature/storage/src/test/java/com/mashup/dorabangs/feature/storage/ExampleUnitTest.kt b/feature/storage/src/test/java/com/mashup/dorabangs/feature/storage/ExampleUnitTest.kt new file mode 100644 index 00000000..dd35f39a --- /dev/null +++ b/feature/storage/src/test/java/com/mashup/dorabangs/feature/storage/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.mashup.dorabangs.feature.storage + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c1374270..3299997c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,4 @@ include(":feature:home") include(":core:coroutine") include(":core:designsystem") include(":core:navigation") +include(":feature:storage")