diff --git a/feature/addlink/.gitignore b/feature/addlink/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/addlink/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/addlink/build.gradle.kts b/feature/addlink/build.gradle.kts
new file mode 100644
index 00000000..d37b8318
--- /dev/null
+++ b/feature/addlink/build.gradle.kts
@@ -0,0 +1,64 @@
+plugins {
+ alias(libs.plugins.com.android.library)
+ alias(libs.plugins.org.jetbrains.kotlin.android)
+}
+
+android {
+ namespace = "com.strayalpaca.addlink"
+ 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.5.1"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+
+ implementation(libs.orbit.compose)
+ implementation(libs.orbit.core)
+ implementation(libs.orbit.viewmodel)
+
+ implementation(project(":core:ui"))
+}
diff --git a/feature/addlink/consumer-rules.pro b/feature/addlink/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/addlink/proguard-rules.pro b/feature/addlink/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/addlink/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/addlink/src/androidTest/java/com/strayalpaca/addlink/ExampleInstrumentedTest.kt b/feature/addlink/src/androidTest/java/com/strayalpaca/addlink/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..8675bf96
--- /dev/null
+++ b/feature/addlink/src/androidTest/java/com/strayalpaca/addlink/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.strayalpaca.addlink
+
+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.strayalpaca.addlink.test", appContext.packageName)
+ }
+}
diff --git a/feature/addlink/src/main/AndroidManifest.xml b/feature/addlink/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/feature/addlink/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt
new file mode 100644
index 00000000..10e36ede
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt
@@ -0,0 +1,341 @@
+package com.strayalpaca.addlink
+
+import android.widget.Toast
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.LocalOverscrollConfiguration
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.strayalpaca.addlink.components.block.Link
+import com.strayalpaca.addlink.components.block.Toolbar
+import com.strayalpaca.addlink.model.AddLinkScreenSideEffect
+import com.strayalpaca.addlink.model.AddLinkScreenState
+import com.strayalpaca.addlink.model.Pokit
+import com.strayalpaca.addlink.model.ScreenStep
+import com.strayalpaca.addlink.utils.BackPressHandler
+import org.orbitmvi.orbit.compose.collectAsState
+import org.orbitmvi.orbit.compose.collectSideEffect
+import pokitmons.pokit.core.ui.components.atom.button.PokitButton
+import pokitmons.pokit.core.ui.components.atom.button.attributes.PokitButtonIcon
+import pokitmons.pokit.core.ui.components.atom.button.attributes.PokitButtonIconPosition
+import pokitmons.pokit.core.ui.components.atom.button.attributes.PokitButtonSize
+import pokitmons.pokit.core.ui.components.atom.inputarea.PokitInputArea
+import pokitmons.pokit.core.ui.components.block.labeledinput.LabeledInput
+import pokitmons.pokit.core.ui.components.block.pokitlist.PokitList
+import pokitmons.pokit.core.ui.components.block.pokitlist.attributes.PokitListState
+import pokitmons.pokit.core.ui.components.block.select.PokitSelect
+import pokitmons.pokit.core.ui.components.block.switchradio.PokitSwitchRadio
+import pokitmons.pokit.core.ui.components.block.switchradio.attributes.PokitSwitchRadioStyle
+import pokitmons.pokit.core.ui.components.template.bottomsheet.PokitBottomSheet
+import pokitmons.pokit.core.ui.theme.PokitTheme
+
+@Composable
+fun AddLinkScreenContainer(
+ linkId: String?,
+ viewModel: AddLinkViewModel,
+ onBackPressed: () -> Unit,
+) {
+ val state by viewModel.collectAsState()
+ val context = LocalContext.current
+
+ BackPressHandler(onBackPressed = viewModel::onBackPressed)
+
+ LaunchedEffect(Unit) {
+ linkId?.let {
+ viewModel.loadPokitLink(it)
+ }
+ }
+
+ viewModel.collectSideEffect { sideEffect ->
+ when (sideEffect) {
+ AddLinkScreenSideEffect.AddLinkSuccess -> {
+ onBackPressed()
+ }
+ AddLinkScreenSideEffect.OnNavigationBack -> {
+ onBackPressed()
+ }
+ is AddLinkScreenSideEffect.ToastMessage -> {
+ Toast.makeText(context, context.getString(sideEffect.toastMessageEvent.stringResourceId), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ val url by viewModel.linkUrl.collectAsState()
+ val title by viewModel.title.collectAsState()
+ val memo by viewModel.memo.collectAsState()
+ val pokitName by viewModel.pokitName.collectAsState()
+
+ AddLinkScreen(
+ isModifyLink = (linkId != null),
+ url = url,
+ title = title,
+ memo = memo,
+ state = state,
+ pokitName = pokitName,
+ inputUrl = viewModel::inputLinkUrl,
+ inputTitle = viewModel::inputTitle,
+ inputMemo = viewModel::inputMemo,
+ inputNewPokitName = viewModel::inputNewPokitName,
+ onClickAddPokit = viewModel::showAddPokitBottomSheet,
+ onClickSavePokit = viewModel::savePokit,
+ dismissPokitAddBottomSheet = viewModel::hideAddPokitBottomSheet,
+ onClickSelectPokit = viewModel::showSelectPokitBottomSheet,
+ onClickSelectPokitItem = viewModel::selectPokit,
+ dismissPokitSelectBottomSheet = viewModel::hideSelectPokitBottomSheet,
+ toggleRemindRadio = viewModel::setRemind,
+ onBackPressed = viewModel::onBackPressed,
+ onClickSaveButton = viewModel::saveLink
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun AddLinkScreen(
+ isModifyLink: Boolean,
+ url: String,
+ title: String,
+ memo: String,
+ pokitName: String,
+ state: AddLinkScreenState,
+ inputUrl: (String) -> Unit,
+ inputTitle: (String) -> Unit,
+ inputMemo: (String) -> Unit,
+ inputNewPokitName: (String) -> Unit,
+ onClickAddPokit: () -> Unit,
+ onClickSavePokit: () -> Unit,
+ dismissPokitAddBottomSheet: () -> Unit,
+ onClickSelectPokit: () -> Unit,
+ onClickSelectPokitItem: (Pokit) -> Unit,
+ dismissPokitSelectBottomSheet: () -> Unit,
+ toggleRemindRadio: (Boolean) -> Unit,
+ onBackPressed: () -> Unit,
+ onClickSaveButton: () -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ val enable = remember(state.step) {
+ !(
+ state.step == ScreenStep.SAVE_LOADING ||
+ state.step == ScreenStep.LOADING ||
+ state.step == ScreenStep.POKIT_ADD_LOADING ||
+ state.step == ScreenStep.LINK_LOADING
+ )
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Toolbar(
+ modifier = Modifier.fillMaxWidth(),
+ onClickBack = onBackPressed,
+ title = if (isModifyLink) stringResource(id = R.string.modify_link) else stringResource(id = R.string.add_link)
+ )
+
+ CompositionLocalProvider(
+ LocalOverscrollConfiguration provides null
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(vertical = 16.dp, horizontal = 20.dp)
+ .verticalScroll(
+ state = scrollState,
+ flingBehavior = null
+ )
+ ) {
+ if (state.link != null) {
+ Link(state.link)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ LabeledInput(
+ label = stringResource(id = R.string.link),
+ sub = "",
+ maxLength = null,
+ inputText = url,
+ hintText = stringResource(id = R.string.placeholder_link),
+ onChangeText = inputUrl,
+ enable = enable
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ LabeledInput(
+ label = stringResource(id = R.string.title),
+ sub = "",
+ maxLength = 20,
+ inputText = title,
+ hintText = stringResource(id = R.string.placeholder_title),
+ onChangeText = inputTitle,
+ enable = enable
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ PokitSelect(
+ text = if (state.currentPokit == null) stringResource(id = R.string.uncategorized) else state.currentPokit.title,
+ hintText = stringResource(id = R.string.uncategorized),
+ label = stringResource(id = R.string.pokit),
+ modifier = Modifier.weight(1f),
+ onClick = onClickSelectPokit,
+ enable = enable
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ PokitButton(
+ text = null,
+ icon = PokitButtonIcon(
+ resourceId = pokitmons.pokit.core.ui.R.drawable.icon_24_plus,
+ position = PokitButtonIconPosition.LEFT
+ ),
+ size = PokitButtonSize.LARGE,
+ onClick = onClickAddPokit,
+ enable = enable
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(id = R.string.memo),
+ style = PokitTheme.typography.body2Medium.copy(color = PokitTheme.colors.textSecondary)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ PokitInputArea(
+ text = memo,
+ hintText = stringResource(id = R.string.placeholder_memo),
+ onChangeText = inputMemo,
+ enable = enable
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "${memo.length}/100",
+ style = PokitTheme.typography.detail1.copy(color = PokitTheme.colors.textTertiary),
+ textAlign = TextAlign.End
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(id = R.string.title_remind),
+ style = PokitTheme.typography.body2Medium.copy(color = PokitTheme.colors.textSecondary)
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ PokitSwitchRadio(
+ modifier = Modifier.fillMaxWidth(),
+ itemList = listOf(
+ Pair(stringResource(id = R.string.reject_remind), false),
+ Pair(stringResource(id = R.string.accept_remind), true)
+ ),
+ style = PokitSwitchRadioStyle.STROKE,
+ selectedItem = if (state.useRemind) {
+ Pair(stringResource(id = R.string.accept_remind), true)
+ } else {
+ Pair(stringResource(id = R.string.reject_remind), false)
+ },
+ onClickItem = {
+ toggleRemindRadio(it.second)
+ },
+ getTitleFromItem = { it.first },
+ enabled = enable
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(id = R.string.sub_remind),
+ style = PokitTheme.typography.detail1.copy(color = PokitTheme.colors.textTertiary)
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ PokitButton(
+ text = stringResource(id = R.string.save),
+ icon = null,
+ onClick = onClickSaveButton,
+ modifier = Modifier.fillMaxWidth(),
+ size = PokitButtonSize.LARGE
+ )
+ }
+ }
+
+ if (state.step == ScreenStep.POKIT_SELECT) {
+ PokitBottomSheet(onHideBottomSheet = dismissPokitSelectBottomSheet) {
+ LazyColumn {
+ items(
+ items = state.pokitList
+ ) {
+ PokitList(
+ item = it,
+ title = it.title,
+ sub = stringResource(id = R.string.count_format, it.count),
+ onClickKebab = onClickSelectPokitItem,
+ onClickItem = onClickSelectPokitItem,
+ state = PokitListState.ACTIVE
+ )
+ }
+ }
+ }
+ }
+
+ if (state.step == ScreenStep.POKIT_ADD) {
+ PokitBottomSheet(onHideBottomSheet = dismissPokitAddBottomSheet) {
+ Column(
+ modifier = Modifier.padding(horizontal = 20.dp)
+ ) {
+ LabeledInput(
+ label = "",
+ inputText = pokitName,
+ hintText = stringResource(id = R.string.placeholder_input_pokit_name),
+ onChangeText = inputNewPokitName,
+ maxLength = 10
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ PokitButton(
+ text = stringResource(id = R.string.add),
+ icon = null,
+ onClick = onClickSavePokit,
+ modifier = Modifier.fillMaxWidth(),
+ size = PokitButtonSize.LARGE,
+ enable = pokitName.isNotEmpty()
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt
new file mode 100644
index 00000000..8f607b45
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt
@@ -0,0 +1,158 @@
+package com.strayalpaca.addlink
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.strayalpaca.addlink.model.AddLinkScreenSideEffect
+import com.strayalpaca.addlink.model.AddLinkScreenState
+import com.strayalpaca.addlink.model.Pokit
+import com.strayalpaca.addlink.model.ScreenStep
+import com.strayalpaca.addlink.model.sampleLink
+import com.strayalpaca.addlink.model.samplePokitList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.syntax.simple.intent
+import org.orbitmvi.orbit.syntax.simple.postSideEffect
+import org.orbitmvi.orbit.syntax.simple.reduce
+import org.orbitmvi.orbit.viewmodel.container
+
+class AddLinkViewModel : ContainerHost, ViewModel() {
+ override val container: Container = container(AddLinkScreenState())
+
+ private val _linkUrl = MutableStateFlow("")
+ val linkUrl: StateFlow = _linkUrl.asStateFlow()
+
+ private val _title = MutableStateFlow("")
+ val title: StateFlow = _title.asStateFlow()
+
+ private val _memo = MutableStateFlow("")
+ val memo: StateFlow = _memo.asStateFlow()
+
+ private val _pokitName = MutableStateFlow("")
+ val pokitName: StateFlow = _pokitName.asStateFlow()
+
+ init {
+ loadPokitList()
+ }
+
+ private var inputLinkJob: Job? = null
+
+ private fun loadPokitList() = intent {
+ viewModelScope.launch(Dispatchers.IO) {
+ reduce { state.copy(step = ScreenStep.LOADING) }
+ // todo 포킷 목록 가져오기 api 연결
+ delay(1000L)
+ reduce {
+ state.copy(
+ step = ScreenStep.IDLE,
+ pokitList = samplePokitList
+ )
+ }
+ }
+ }
+
+ fun loadPokitLink(linkId: String) = intent {
+ viewModelScope.launch(Dispatchers.IO) {
+ reduce { state.copy(step = ScreenStep.LOADING) }
+ // todo 포킷 링크 가져오기 api 연결
+ delay(1000L)
+ reduce {
+ state.copy(
+ step = ScreenStep.IDLE
+ )
+ }
+ }
+ }
+
+ fun inputLinkUrl(linkUrl: String) {
+ this._linkUrl.update { linkUrl }
+
+ intent {
+ inputLinkJob?.cancel()
+ inputLinkJob = viewModelScope.launch(Dispatchers.IO) {
+ delay(1000L)
+ reduce { state.copy(step = ScreenStep.LINK_LOADING) }
+ // todo 링크 카드 정보 가져오기 api 연결
+ delay(1000L)
+ reduce { state.copy(step = ScreenStep.IDLE, link = sampleLink.copy(url = linkUrl)) }
+ }
+ }
+ }
+
+ fun inputTitle(title: String) {
+ _title.update { title }
+ }
+
+ fun inputMemo(memo: String) {
+ _memo.update { memo }
+ }
+
+ fun showAddPokitBottomSheet() = intent {
+ reduce { state.copy(step = ScreenStep.POKIT_ADD) }
+ }
+
+ fun hideAddPokitBottomSheet() = intent {
+ reduce { state.copy(step = ScreenStep.IDLE) }
+ }
+
+ fun showSelectPokitBottomSheet() = intent {
+ reduce { state.copy(step = ScreenStep.POKIT_SELECT) }
+ }
+
+ fun hideSelectPokitBottomSheet() = intent {
+ reduce { state.copy(step = ScreenStep.IDLE) }
+ }
+
+ fun selectPokit(pokit: Pokit) = intent {
+ reduce { state.copy(currentPokit = pokit, step = ScreenStep.IDLE) }
+ }
+
+ fun onBackPressed() = intent {
+ val currentStep = container.stateFlow.value.step
+ when (currentStep) {
+ is ScreenStep.POKIT_ADD_LOADING -> {} // discard
+
+ is ScreenStep.SAVE_LOADING -> {} // discard
+
+ ScreenStep.POKIT_SELECT -> reduce { state.copy(step = ScreenStep.IDLE) }
+
+ is ScreenStep.POKIT_ADD -> reduce { state.copy(step = ScreenStep.IDLE) }
+
+ else -> postSideEffect(AddLinkScreenSideEffect.OnNavigationBack)
+ }
+ }
+
+ fun inputNewPokitName(pokitName: String) {
+ _pokitName.update { pokitName }
+ }
+
+ fun savePokit() = intent {
+ viewModelScope.launch(Dispatchers.IO) {
+ reduce { state.copy(step = ScreenStep.POKIT_ADD_LOADING) }
+ // todo 포킷 저장 useCase 연결
+ delay(1000L)
+ reduce { state.copy(step = ScreenStep.IDLE) }
+ }
+ }
+
+ fun saveLink() = intent {
+ viewModelScope.launch(Dispatchers.IO) {
+ reduce { state.copy(step = ScreenStep.LINK_LOADING) }
+ // todo 링크 저장 useCase 연결
+ delay(1000L)
+ reduce { state.copy(step = ScreenStep.IDLE) }
+ postSideEffect(AddLinkScreenSideEffect.AddLinkSuccess)
+ }
+ }
+
+ fun setRemind(remind: Boolean) = intent {
+ reduce { state.copy(useRemind = remind) }
+ }
+}
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/Preview.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/Preview.kt
new file mode 100644
index 00000000..869196a4
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/Preview.kt
@@ -0,0 +1,42 @@
+package com.strayalpaca.addlink
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.strayalpaca.addlink.model.AddLinkScreenState
+import pokitmons.pokit.core.ui.theme.PokitTheme
+
+@Preview(showBackground = true)
+@Composable
+fun AddLinkScreenPreview() {
+ PokitTheme {
+ Column(
+ modifier = Modifier.fillMaxSize().background(PokitTheme.colors.backgroundBase)
+ ) {
+ AddLinkScreen(
+ isModifyLink = false,
+ url = "",
+ title = "",
+ memo = "",
+ pokitName = "",
+ state = AddLinkScreenState(),
+ inputUrl = {},
+ inputTitle = {},
+ inputMemo = {},
+ inputNewPokitName = {},
+ onClickAddPokit = {},
+ onClickSavePokit = {},
+ dismissPokitAddBottomSheet = {},
+ onClickSelectPokit = {},
+ onClickSelectPokitItem = {},
+ dismissPokitSelectBottomSheet = {},
+ toggleRemindRadio = {},
+ onBackPressed = {},
+ onClickSaveButton = {}
+ )
+ }
+ }
+}
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Link.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Link.kt
new file mode 100644
index 00000000..821e6614
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Link.kt
@@ -0,0 +1,69 @@
+package com.strayalpaca.addlink.components.block
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.strayalpaca.addlink.model.Link
+import pokitmons.pokit.core.ui.theme.PokitTheme
+
+@Composable
+internal fun Link(
+ link: Link,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .height(IntrinsicSize.Min)
+ .border(
+ width = 1.dp,
+ color = PokitTheme.colors.borderTertiary,
+ shape = RoundedCornerShape(12.dp)
+ )
+ ) {
+ Image(
+ painter = painterResource(id = pokitmons.pokit.core.ui.R.drawable.icon_24_google),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.width(124.dp)
+ )
+
+ Column(
+ modifier = Modifier
+ .padding(start = 16.dp, end = 20.dp, top = 16.dp, bottom = 16.dp)
+ .weight(1f)
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = link.title,
+ maxLines = 2,
+ style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.textSecondary)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = link.url,
+ maxLines = 2,
+ style = PokitTheme.typography.detail2.copy(color = PokitTheme.colors.textTertiary)
+ )
+ }
+ }
+}
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Toolbar.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Toolbar.kt
new file mode 100644
index 00000000..5a5c5254
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Toolbar.kt
@@ -0,0 +1,53 @@
+package com.strayalpaca.addlink.components.block
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import pokitmons.pokit.core.ui.theme.PokitTheme
+
+@Composable
+internal fun Toolbar(
+ onClickBack: () -> Unit,
+ title: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .height(56.dp)
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ IconButton(
+ modifier = Modifier.size(48.dp),
+ onClick = onClickBack
+ ) {
+ Icon(
+ painter = painterResource(id = pokitmons.pokit.core.ui.R.drawable.icon_24_arrow_left),
+ contentDescription = "back button"
+ )
+ }
+
+ Text(
+ modifier = Modifier.weight(1f),
+ text = title,
+ style = PokitTheme.typography.title3.copy(color = PokitTheme.colors.textPrimary),
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+}
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/model/AddLinkScreenState.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/AddLinkScreenState.kt
new file mode 100644
index 00000000..c6a722eb
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/AddLinkScreenState.kt
@@ -0,0 +1,31 @@
+package com.strayalpaca.addlink.model
+
+import com.strayalpaca.addlink.R
+
+data class AddLinkScreenState(
+ val link: Link? = null,
+ val currentPokit: Pokit? = null,
+ val pokitList: List = emptyList(),
+ val useRemind: Boolean = false,
+ val step: ScreenStep = ScreenStep.IDLE,
+)
+
+sealed class ScreenStep {
+ data object LOADING : ScreenStep()
+ data object IDLE : ScreenStep()
+ data object LINK_LOADING : ScreenStep()
+ data object POKIT_SELECT : ScreenStep()
+ data object POKIT_ADD : ScreenStep()
+ data object POKIT_ADD_LOADING : ScreenStep()
+ data object SAVE_LOADING : ScreenStep()
+}
+
+sealed class AddLinkScreenSideEffect() {
+ data object AddLinkSuccess : AddLinkScreenSideEffect()
+ data class ToastMessage(val toastMessageEvent: ToastMessageEvent) : AddLinkScreenSideEffect()
+ data object OnNavigationBack : AddLinkScreenSideEffect()
+}
+
+enum class ToastMessageEvent(val stringResourceId: Int) {
+ NETWORK_ERROR(R.string.network_error),
+}
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Link.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Link.kt
new file mode 100644
index 00000000..b05521d4
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Link.kt
@@ -0,0 +1,9 @@
+package com.strayalpaca.addlink.model
+
+data class Link(
+ val url: String,
+ val title: String,
+ val imageUrl: String?,
+)
+
+internal val sampleLink = Link(url = "https://pokit.com/watch?v=xSTwqkUyM8k", title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", imageUrl = null)
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Pokit.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Pokit.kt
new file mode 100644
index 00000000..0088afe6
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Pokit.kt
@@ -0,0 +1,15 @@
+package com.strayalpaca.addlink.model
+
+data class Pokit(
+ val title: String,
+ val id: String,
+ val count: Int,
+)
+
+internal val samplePokitList = listOf(
+ Pokit(title = "안드로이드", id = "1", count = 2),
+ Pokit(title = "IOS", id = "2", count = 2),
+ Pokit(title = "디자인", id = "3", count = 2),
+ Pokit(title = "PM", id = "4", count = 1),
+ Pokit(title = "서버", id = "5", count = 2)
+)
diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/utils/BackPressHandler.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/utils/BackPressHandler.kt
new file mode 100644
index 00000000..dc0796d5
--- /dev/null
+++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/utils/BackPressHandler.kt
@@ -0,0 +1,35 @@
+package com.strayalpaca.addlink.utils
+
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.OnBackPressedDispatcher
+import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+
+@Composable
+internal fun BackPressHandler(
+ backPressedDispatcher: OnBackPressedDispatcher? =
+ LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher,
+ onBackPressed: () -> Unit,
+) {
+ val currentOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
+
+ val backCallback = remember {
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ currentOnBackPressed()
+ }
+ }
+ }
+
+ DisposableEffect(backPressedDispatcher) {
+ backPressedDispatcher?.addCallback(backCallback)
+
+ onDispose {
+ backCallback.remove()
+ }
+ }
+}
diff --git a/feature/addlink/src/main/res/values/string.xml b/feature/addlink/src/main/res/values/string.xml
new file mode 100644
index 00000000..cf43735d
--- /dev/null
+++ b/feature/addlink/src/main/res/values/string.xml
@@ -0,0 +1,24 @@
+
+
+ 링크 추가
+ 링크 수정
+ 링크
+ 내용을 입력해주세요.
+ 제목
+ 내용을 입력해주세요.
+ 포킷
+ 메모
+ 내용을 입력해주세요.
+ 리마인드 알림을 보내드릴까요?
+ 안받을래요
+ 받을래요
+ 일주일 후에 알림을 전송해드립니다.
+ 저장하기
+ 미분류
+
+ 포킷명을 입력해주세요.
+ 추가하기
+ 링크 %d개
+
+ 네트워크 에러가 발생했습니다. 네트워크 환경을 확인해주세요.
+
\ No newline at end of file
diff --git a/feature/addlink/src/test/java/com/strayalpaca/addlink/ExampleUnitTest.kt b/feature/addlink/src/test/java/com/strayalpaca/addlink/ExampleUnitTest.kt
new file mode 100644
index 00000000..5093231d
--- /dev/null
+++ b/feature/addlink/src/test/java/com/strayalpaca/addlink/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.strayalpaca.addlink
+
+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 995d8d42..04541faf 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,3 +25,4 @@ include(":domain")
include(":data")
include(":feature:login")
include(":core:ui")
+include(":feature:addlink")