diff --git a/app-compose/build.gradle.kts b/app-compose/build.gradle.kts index be3fc11b6..bdcaeed01 100644 --- a/app-compose/build.gradle.kts +++ b/app-compose/build.gradle.kts @@ -18,6 +18,7 @@ android { } dependencies { + implementation(projects.core.designsystem) implementation(projects.feature.navigator) implementation(projects.remote.openmajor) @@ -28,10 +29,12 @@ dependencies { implementation(projects.remote.signup) implementation(projects.remote.notice) implementation(projects.remote.user) + implementation(projects.remote.login) implementation(projects.local.openmajor) implementation(projects.local.timetable) implementation(projects.local.user) + implementation(projects.local.login) implementation(projects.data.openmajor) implementation(projects.data.timetable) @@ -41,6 +44,7 @@ dependencies { implementation(projects.data.user) implementation(projects.data.notice) implementation(projects.data.signup) + implementation(projects.data.login) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics) diff --git a/app-compose/src/main/AndroidManifest.xml b/app-compose/src/main/AndroidManifest.xml index 94776ba9c..2401fef9f 100644 --- a/app-compose/src/main/AndroidManifest.xml +++ b/app-compose/src/main/AndroidManifest.xml @@ -7,11 +7,12 @@ android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="false" - android:icon="@mipmap/ic_launcher" + android:icon="@mipmap/ic_logo" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" + android:roundIcon="@mipmap/ic_logo" android:supportsRtl="true" android:theme="@style/Theme.Suwiki" + android:usesCleartextTraffic="true" tools:replace="android:allowBackup" tools:targetApi="s" /> diff --git a/app-compose/src/main/ic_logo-playstore.png b/app-compose/src/main/ic_logo-playstore.png new file mode 100644 index 000000000..13712366c Binary files /dev/null and b/app-compose/src/main/ic_logo-playstore.png differ diff --git a/core/android/src/main/java/com/suwiki/core/android/ThrowUnknownException.kt b/core/android/src/main/java/com/suwiki/core/android/ThrowUnknownException.kt index 1bf96c61a..7e64a8095 100644 --- a/core/android/src/main/java/com/suwiki/core/android/ThrowUnknownException.kt +++ b/core/android/src/main/java/com/suwiki/core/android/ThrowUnknownException.kt @@ -2,11 +2,9 @@ package com.suwiki.core.android import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase -import com.suwiki.core.model.exception.UnknownException -fun throwUnknownException( +fun recordException( e: Throwable, ) { Firebase.crashlytics.recordException(e) - throw e.message?.let { UnknownException(it) } ?: UnknownException() } diff --git a/core/designsystem/src/main/ic_logo-playstore.png b/core/designsystem/src/main/ic_logo-playstore.png new file mode 100644 index 000000000..13712366c Binary files /dev/null and b/core/designsystem/src/main/ic_logo-playstore.png differ diff --git a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/bottomsheet/SuwikiBottomSheet.kt b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/bottomsheet/SuwikiBottomSheet.kt index 497c26710..acbadcd80 100644 --- a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/bottomsheet/SuwikiBottomSheet.kt +++ b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/bottomsheet/SuwikiBottomSheet.kt @@ -1,24 +1,24 @@ package com.suwiki.core.designsystem.component.bottomsheet import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.suwiki.core.designsystem.component.align.SuwikiAlignContainer import com.suwiki.core.designsystem.theme.Gray95 import com.suwiki.core.designsystem.theme.SuwikiTheme import com.suwiki.core.designsystem.theme.White @@ -26,20 +26,20 @@ import com.suwiki.core.designsystem.theme.White @OptIn(ExperimentalMaterial3Api::class) @Composable fun SuwikiBottomSheet( + sheetState: SheetState = rememberModalBottomSheetState(), isSheetOpen: Boolean, onDismissRequest: () -> Unit = {}, - bottomSheetItem: List<@Composable () -> Unit>, + content: @Composable ColumnScope.() -> Unit, ) { if (isSheetOpen) { ModalBottomSheet( + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), onDismissRequest = onDismissRequest, containerColor = White, dragHandle = null, ) { - Spacer(modifier = Modifier.height(36.dp)) - bottomSheetItem.forEach { item -> - item() - } + content() } } } @@ -60,10 +60,10 @@ fun SuwikiBottomSheetItem( ) } +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable fun SuwikiBottomSheetPreview() { - var isChecked by remember { mutableStateOf(false) } var isSheetOpen by rememberSaveable { mutableStateOf(false) } // 테스트용 버튼 @@ -75,16 +75,7 @@ fun SuwikiBottomSheetPreview() { SuwikiBottomSheet( isSheetOpen = isSheetOpen, onDismissRequest = { isSheetOpen = !isSheetOpen }, - bottomSheetItem = listOf( - { SuwikiBottomSheetItem(title = "타이틀") }, - { - SuwikiAlignContainer( - text = "메뉴", - isChecked = isChecked, - onClick = { isChecked = !isChecked }, - ) - }, - ), + content = {}, ) } } diff --git a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/dialog/SuwikiDialog.kt b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/dialog/SuwikiDialog.kt index 72a10e582..5cb3da9f5 100644 --- a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/dialog/SuwikiDialog.kt +++ b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/dialog/SuwikiDialog.kt @@ -30,10 +30,10 @@ fun SuwikiDialog( headerText: String, bodyText: String, confirmButtonText: String, - dismissButtonText: String, + dismissButtonText: String? = null, onDismissRequest: () -> Unit, onClickConfirm: () -> Unit, - onClickDismiss: () -> Unit, + onClickDismiss: () -> Unit = {}, ) { Dialog( onDismissRequest = onDismissRequest, @@ -60,12 +60,14 @@ fun SuwikiDialog( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - Text( - modifier = Modifier.suwikiClickable(rippleEnabled = false, onClick = onClickDismiss), - text = dismissButtonText, - style = SuwikiTheme.typography.body4, - color = Gray95, - ) + if (dismissButtonText != null) { + Text( + modifier = Modifier.suwikiClickable(rippleEnabled = false, onClick = onClickDismiss), + text = dismissButtonText, + style = SuwikiTheme.typography.body4, + color = Gray95, + ) + } Spacer(modifier = Modifier.width(30.dp)) diff --git a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/loading/LoadingScreen.kt b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/loading/LoadingScreen.kt new file mode 100644 index 000000000..bc4b030e0 --- /dev/null +++ b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/loading/LoadingScreen.kt @@ -0,0 +1,28 @@ +package com.suwiki.core.designsystem.component.loading + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.suwiki.core.designsystem.theme.Primary +import com.suwiki.core.ui.extension.suwikiClickable + +@Composable +fun LoadingScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize().suwikiClickable(rippleEnabled = false, onClick = {}), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + strokeWidth = 4.dp, + color = Primary, + trackColor = Color.Transparent, + ) + } +} diff --git a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldRegular.kt b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiRegularTextField.kt similarity index 79% rename from core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldRegular.kt rename to core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiRegularTextField.kt index 3e7e55018..cde20cc7d 100644 --- a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldRegular.kt +++ b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiRegularTextField.kt @@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -24,9 +26,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.suwiki.core.designsystem.R import com.suwiki.core.designsystem.component.button.TextFieldClearButton import com.suwiki.core.designsystem.theme.Black import com.suwiki.core.designsystem.theme.Error @@ -36,9 +43,10 @@ import com.suwiki.core.designsystem.theme.GrayF6 import com.suwiki.core.designsystem.theme.Primary import com.suwiki.core.designsystem.theme.SuwikiTheme import com.suwiki.core.designsystem.theme.White +import com.suwiki.core.ui.extension.suwikiClickable @Composable -fun SuwikiTextFieldRegular( +fun SuwikiRegularTextField( modifier: Modifier = Modifier, label: String? = "", placeholder: String = "", @@ -51,6 +59,9 @@ fun SuwikiTextFieldRegular( minLines: Int = 1, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, + showEyeIcon: Boolean = false, + onClickEyeIcon: () -> Unit = {}, + showValue: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val isFocused by interactionSource.collectIsFocusedAsState() @@ -78,6 +89,7 @@ fun SuwikiTextFieldRegular( textStyle = SuwikiTheme.typography.header4.copy(color = Black), interactionSource = interactionSource, cursorBrush = SolidColor(Primary), + visualTransformation = if (showValue) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, decorationBox = { innerText -> @@ -116,9 +128,25 @@ fun SuwikiTextFieldRegular( } if (value.isNotEmpty()) { - TextFieldClearButton( - onClick = onClickClearButton, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (showEyeIcon) { + Icon( + modifier = modifier + .size(24.dp) + .clip(CircleShape) + .suwikiClickable(onClick = onClickEyeIcon), + painter = painterResource(id = if (showValue) R.drawable.ic_eye_off else R.drawable.ic_eye_on), + tint = Gray95, + contentDescription = "", + ) + } + + TextFieldClearButton( + onClick = onClickClearButton, + ) + } } } @@ -145,7 +173,7 @@ fun SuwikiTextFieldRegular( @Preview(showBackground = true, backgroundColor = 0xFFFFFF) @Composable -fun SuwikiTextFieldRegularPreview() { +fun SuwikiRegularTextFieldPreview() { SuwikiTheme { var normalValue by remember { mutableStateOf("") @@ -159,7 +187,7 @@ fun SuwikiTextFieldRegularPreview() { modifier = Modifier.background(White), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - SuwikiTextFieldRegular( + SuwikiRegularTextField( label = "라벨", placeholder = "플레이스 홀더", value = normalValue, @@ -168,7 +196,7 @@ fun SuwikiTextFieldRegularPreview() { helperText = "도움말 메세지", ) - SuwikiTextFieldRegular( + SuwikiRegularTextField( label = "라벨", placeholder = "플레이스 홀더", value = errorValue, diff --git a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldReview.kt b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiReviewInputBox.kt similarity index 96% rename from core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldReview.kt rename to core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiReviewInputBox.kt index bda5e7825..75e6252ab 100644 --- a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldReview.kt +++ b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiReviewInputBox.kt @@ -31,7 +31,7 @@ import com.suwiki.core.designsystem.theme.Primary import com.suwiki.core.designsystem.theme.SuwikiTheme @Composable -fun SuwikiTextFieldReview( +fun SuwikiReviewInputBox( modifier: Modifier = Modifier, hint: String = "", value: String = "", @@ -74,7 +74,7 @@ fun SuwikiTextFieldReview( @Preview(showBackground = true, backgroundColor = 0xFFFFFF) @Composable -fun SuwikiTextFieldReviewPreview() { +fun SuwikiReviewInputBoxPreview() { SuwikiTheme { var normalValue by remember { mutableStateOf("") @@ -84,13 +84,13 @@ fun SuwikiTextFieldReviewPreview() { modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - SuwikiTextFieldReview( + SuwikiReviewInputBox( hint = "강의평가를 작성해주세요", value = normalValue, onValueChange = { normalValue = it }, ) - SuwikiTextFieldReview( + SuwikiReviewInputBox( hint = "강의평가를 작성해주세요", value = normalValue, isError = true, diff --git a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldSmall.kt b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiSmallTextField.kt similarity index 93% rename from core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldSmall.kt rename to core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiSmallTextField.kt index a377296ca..ba63b0368 100644 --- a/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiTextFieldSmall.kt +++ b/core/designsystem/src/main/java/com/suwiki/core/designsystem/component/textfield/SuwikiSmallTextField.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.suwiki.core.designsystem.component.button.TextFieldClearButton @@ -38,7 +39,7 @@ import com.suwiki.core.designsystem.theme.SuwikiTheme import com.suwiki.core.designsystem.theme.White @Composable -fun SuwikiTextFieldSmall( +fun SuwikiSmallTextField( modifier: Modifier = Modifier, placeholder: String = "", value: String = "", @@ -48,6 +49,7 @@ fun SuwikiTextFieldSmall( minLines: Int = 1, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val isFocused by interactionSource.collectIsFocusedAsState() @@ -70,6 +72,7 @@ fun SuwikiTextFieldSmall( cursorBrush = SolidColor(Primary), keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, + visualTransformation = visualTransformation, decorationBox = { innerText -> Column { Row( @@ -117,7 +120,7 @@ fun SuwikiTextFieldSmall( @Preview(showBackground = true, backgroundColor = 0xFFFFFF) @Composable -fun SuwikiTextFieldSmallPreview() { +fun SuwikiSmallTextFieldPreview() { SuwikiTheme { var normalValue by remember { mutableStateOf("") @@ -129,14 +132,14 @@ fun SuwikiTextFieldSmallPreview() { .padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - SuwikiTextFieldSmall( + SuwikiSmallTextField( placeholder = "플레이스 홀더", value = normalValue, onValueChange = { normalValue = it }, onClickClearButton = { normalValue = "" }, ) - SuwikiTextFieldSmall( + SuwikiSmallTextField( placeholder = "플레이스 홀더", value = normalValue, onValueChange = { normalValue = it }, diff --git a/core/designsystem/src/main/java/com/suwiki/core/designsystem/theme/Typography.kt b/core/designsystem/src/main/java/com/suwiki/core/designsystem/theme/Typography.kt index d036f25e9..2cf938b1b 100644 --- a/core/designsystem/src/main/java/com/suwiki/core/designsystem/theme/Typography.kt +++ b/core/designsystem/src/main/java/com/suwiki/core/designsystem/theme/Typography.kt @@ -29,6 +29,7 @@ private val notoSansStyle = TextStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), + color = Black, ) internal val Typography = SuwikiTypography( diff --git a/core/designsystem/src/main/res/drawable/ic_eye_off.xml b/core/designsystem/src/main/res/drawable/ic_eye_off.xml new file mode 100644 index 000000000..7454717d8 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_eye_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_eye_on.xml b/core/designsystem/src/main/res/drawable/ic_eye_on.xml new file mode 100644 index 000000000..eedc96fb9 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_eye_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_logo.xml b/core/designsystem/src/main/res/drawable/ic_logo.xml new file mode 100644 index 000000000..371e4c772 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/mipmap-anydpi-v26/ic_logo.xml b/core/designsystem/src/main/res/mipmap-anydpi-v26/ic_logo.xml new file mode 100644 index 000000000..38d66407d --- /dev/null +++ b/core/designsystem/src/main/res/mipmap-anydpi-v26/ic_logo.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/designsystem/src/main/res/mipmap-anydpi-v26/ic_logo_round.xml b/core/designsystem/src/main/res/mipmap-anydpi-v26/ic_logo_round.xml new file mode 100644 index 000000000..38d66407d --- /dev/null +++ b/core/designsystem/src/main/res/mipmap-anydpi-v26/ic_logo_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/designsystem/src/main/res/mipmap-hdpi/ic_logo.webp b/core/designsystem/src/main/res/mipmap-hdpi/ic_logo.webp new file mode 100644 index 000000000..ecca702eb Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-hdpi/ic_logo.webp differ diff --git a/core/designsystem/src/main/res/mipmap-hdpi/ic_logo_foreground.webp b/core/designsystem/src/main/res/mipmap-hdpi/ic_logo_foreground.webp new file mode 100644 index 000000000..ea3a92f2d Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-hdpi/ic_logo_foreground.webp differ diff --git a/core/designsystem/src/main/res/mipmap-hdpi/ic_logo_round.webp b/core/designsystem/src/main/res/mipmap-hdpi/ic_logo_round.webp new file mode 100644 index 000000000..137e84308 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-hdpi/ic_logo_round.webp differ diff --git a/core/designsystem/src/main/res/mipmap-mdpi/ic_logo.webp b/core/designsystem/src/main/res/mipmap-mdpi/ic_logo.webp new file mode 100644 index 000000000..313103c02 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-mdpi/ic_logo.webp differ diff --git a/core/designsystem/src/main/res/mipmap-mdpi/ic_logo_foreground.webp b/core/designsystem/src/main/res/mipmap-mdpi/ic_logo_foreground.webp new file mode 100644 index 000000000..e1b113f49 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-mdpi/ic_logo_foreground.webp differ diff --git a/core/designsystem/src/main/res/mipmap-mdpi/ic_logo_round.webp b/core/designsystem/src/main/res/mipmap-mdpi/ic_logo_round.webp new file mode 100644 index 000000000..b8ff17b65 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-mdpi/ic_logo_round.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo.webp b/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo.webp new file mode 100644 index 000000000..8a65328a3 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo_foreground.webp b/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo_foreground.webp new file mode 100644 index 000000000..47635e9e4 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo_foreground.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo_round.webp b/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo_round.webp new file mode 100644 index 000000000..2d718794b Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xhdpi/ic_logo_round.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo.webp b/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo.webp new file mode 100644 index 000000000..7c70ef7bf Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo_foreground.webp b/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo_foreground.webp new file mode 100644 index 000000000..657c61637 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo_foreground.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo_round.webp b/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo_round.webp new file mode 100644 index 000000000..1659df483 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xxhdpi/ic_logo_round.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo.webp b/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo.webp new file mode 100644 index 000000000..7107c9d69 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo_foreground.webp b/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo_foreground.webp new file mode 100644 index 000000000..7d48eb0f9 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo_foreground.webp differ diff --git a/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo_round.webp b/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo_round.webp new file mode 100644 index 000000000..cd58834e6 Binary files /dev/null and b/core/designsystem/src/main/res/mipmap-xxxhdpi/ic_logo_round.webp differ diff --git a/core/designsystem/src/main/res/values/ic_logo_background.xml b/core/designsystem/src/main/res/values/ic_logo_background.xml new file mode 100644 index 000000000..67b6ee5de --- /dev/null +++ b/core/designsystem/src/main/res/values/ic_logo_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 10d937df4..994f30836 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -1,3 +1,5 @@ + + @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.suwiki.android.library) @@ -7,6 +9,16 @@ plugins { android { namespace = "com.suwiki.core.network" + + buildTypes { + getByName("debug") { + buildConfigField("String", "BASE_URL", "\"http://54.180.72.97:8080\"") + } + + getByName("release") { + buildConfigField("String", "BASE_URL", "\"https://api.suwiki.kr\"") + } + } } dependencies { diff --git a/core/network/src/main/java/com/suwiki/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/suwiki/core/network/di/NetworkModule.kt index 3a69ea7f7..48474493c 100644 --- a/core/network/src/main/java/com/suwiki/core/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/suwiki/core/network/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.suwiki.core.network.di import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.suwiki.core.network.BuildConfig import com.suwiki.core.network.authenticator.TokenAuthenticator import com.suwiki.core.network.interceptor.AuthenticationInterceptor import com.suwiki.core.network.retrofit.ResultCallAdapterFactory @@ -22,8 +23,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { - private const val BASE_URL: String = "https://api.suwiki.kr" - @Singleton @Provides @NormalOkHttpClient @@ -79,7 +78,7 @@ object NetworkModule { json: Json, ): Retrofit { return Retrofit.Builder() - .baseUrl(BASE_URL) + .baseUrl(BuildConfig.BASE_URL) .client(okHttpClient) .addCallAdapterFactory(ResultCallAdapterFactory()) .addConverterFactory(json.asConverterFactory("application/json".toMediaTypeOrNull()!!)) @@ -112,7 +111,7 @@ object NetworkModule { json: Json, ): Retrofit { return Retrofit.Builder() - .baseUrl(BASE_URL) + .baseUrl(BuildConfig.BASE_URL) .client(okHttpClient) .addCallAdapterFactory(ResultCallAdapterFactory()) .addConverterFactory(json.asConverterFactory("application/json".toMediaTypeOrNull()!!)) diff --git a/data/user/src/main/java/com/suwiki/data/user/repository/UserRepositoryImpl.kt b/data/user/src/main/java/com/suwiki/data/user/repository/UserRepositoryImpl.kt index 8f51c78ee..7e072786c 100644 --- a/data/user/src/main/java/com/suwiki/data/user/repository/UserRepositoryImpl.kt +++ b/data/user/src/main/java/com/suwiki/data/user/repository/UserRepositoryImpl.kt @@ -8,6 +8,7 @@ import com.suwiki.domain.user.repository.UserRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.zip import javax.inject.Inject class UserRepositoryImpl @Inject constructor( @@ -39,8 +40,22 @@ class UserRepositoryImpl @Inject constructor( emit(localUserInfo) val remoteUserInfo = remoteUserDataSource.getUserInfo() - emit(remoteUserInfo) + val isTokenExpired = with(securityPreferences) { + val (accessToken, refreshToken) = flowAccessToken().zip(flowRefreshToken()) { accessToken, refreshToken -> + (accessToken to refreshToken) + }.first() + + accessToken.isEmpty() && refreshToken.isEmpty() + } + + if (isTokenExpired) { + logout() + emit(User()) + return@flow + } + + emit(remoteUserInfo) localUserDataSource.setUserInfo(remoteUserInfo) } } diff --git a/feature/lectureevaluation/viewerreporter/build.gradle.kts b/feature/lectureevaluation/viewerreporter/build.gradle.kts index f0e430ad2..44bdf64f1 100644 --- a/feature/lectureevaluation/viewerreporter/build.gradle.kts +++ b/feature/lectureevaluation/viewerreporter/build.gradle.kts @@ -8,5 +8,6 @@ android { } dependencies { + implementation(projects.domain.user) implementation(projects.domain.lectureevaluation.viewerreporter) } diff --git a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationContract.kt b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationContract.kt new file mode 100644 index 000000000..a41ee50df --- /dev/null +++ b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationContract.kt @@ -0,0 +1,10 @@ +package com.suwiki.feature.lectureevaluation.viewerreporter + +data class LectureEvaluationState( + val showOnboardingBottomSheet: Boolean = false, +) + +sealed interface LectureEvaluationSideEffect { + data object NavigateLogin : LectureEvaluationSideEffect + data object NavigateSignUp : LectureEvaluationSideEffect +} diff --git a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationScreen.kt b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationScreen.kt index 57d4c189b..203f10ead 100644 --- a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationScreen.kt +++ b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationScreen.kt @@ -1,28 +1,86 @@ package com.suwiki.feature.lectureevaluation.viewerreporter +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.suwiki.core.designsystem.theme.SuwikiTheme +import com.suwiki.feature.lectureevaluation.viewerreporter.component.ONBOARDING_PAGE_COUNT +import com.suwiki.feature.lectureevaluation.viewerreporter.component.OnboardingBottomSheet +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect +@OptIn(ExperimentalFoundationApi::class) @Composable -fun LectureEvaluationScreen( +fun LectureEvaluationRoute( padding: PaddingValues, + viewModel: LectureEvaluationViewModel = hiltViewModel(), + navigateLogin: () -> Unit, ) { - Text( - modifier = Modifier.padding(padding), - text = "강의평가", + val uiState = viewModel.collectAsState().value + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + LectureEvaluationSideEffect.NavigateLogin -> navigateLogin() + LectureEvaluationSideEffect.NavigateSignUp -> TODO() + } + } + + LaunchedEffect(key1 = viewModel) { + viewModel.checkLoggedInShowBottomSheetIfNeed() + } + + val pagerState = rememberPagerState(pageCount = { ONBOARDING_PAGE_COUNT }) + + LectureEvaluationScreen( + padding = padding, + uiState = uiState, + pagerState = pagerState, + hideOnboardingBottomSheet = viewModel::hideOnboardingBottomSheet, + onClickLoginButton = { + viewModel.hideOnboardingBottomSheet() + viewModel.navigateLogin() + }, ) } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LectureEvaluationScreen( + padding: PaddingValues, + uiState: LectureEvaluationState, + pagerState: PagerState = rememberPagerState(pageCount = { ONBOARDING_PAGE_COUNT }), + hideOnboardingBottomSheet: () -> Unit = {}, + onClickLoginButton: () -> Unit = {}, + onClickSignupButton: () -> Unit = {}, +) { + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + OnboardingBottomSheet( + uiState = uiState, + hideOnboardingBottomSheet = hideOnboardingBottomSheet, + pagerState = pagerState, + onClickLoginButton = onClickLoginButton, + onClickSignupButton = onClickSignupButton, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) @Preview @Composable fun LectureEvaluationScreenPreview() { SuwikiTheme { - LectureEvaluationScreen(padding = PaddingValues(0.dp)) + LectureEvaluationScreen( + padding = PaddingValues(0.dp), + uiState = LectureEvaluationState(), + ) } } diff --git a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationViewModel.kt b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationViewModel.kt new file mode 100644 index 000000000..850f290f8 --- /dev/null +++ b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/LectureEvaluationViewModel.kt @@ -0,0 +1,44 @@ +package com.suwiki.feature.lectureevaluation.viewerreporter + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.suwiki.domain.user.usecase.GetUserInfoUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.lastOrNull +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 +import javax.inject.Inject + +@HiltViewModel +class LectureEvaluationViewModel @Inject constructor( + private val getUserInfoUseCase: GetUserInfoUseCase, +) : ContainerHost, ViewModel() { + override val container: Container = + container(LectureEvaluationState()) + + private var isLoggedIn: Boolean = false + private var isFirstVisit: Boolean = true + + private suspend fun checkLoggedIn() { + isLoggedIn = getUserInfoUseCase().catch { }.lastOrNull()?.isLoggedIn == true + } + + fun checkLoggedInShowBottomSheetIfNeed() = viewModelScope.launch { + checkLoggedIn() + if (isLoggedIn.not() && isFirstVisit) { + isFirstVisit = false + showOnboardingBottomSheet() + } + } + + fun navigateLogin() = intent { postSideEffect(LectureEvaluationSideEffect.NavigateLogin) } + + private fun showOnboardingBottomSheet() = intent { reduce { state.copy(showOnboardingBottomSheet = true) } } + fun hideOnboardingBottomSheet() = intent { reduce { state.copy(showOnboardingBottomSheet = false) } } +} diff --git a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/component/OnboardingBottomSheet.kt b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/component/OnboardingBottomSheet.kt new file mode 100644 index 000000000..1dbb80038 --- /dev/null +++ b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/component/OnboardingBottomSheet.kt @@ -0,0 +1,94 @@ +package com.suwiki.feature.lectureevaluation.viewerreporter.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.suwiki.core.designsystem.component.bottomsheet.SuwikiBottomSheet +import com.suwiki.core.designsystem.component.button.SuwikiContainedLargeButton +import com.suwiki.core.designsystem.component.button.SuwikiOutlinedButton +import com.suwiki.core.designsystem.theme.SuwikiTheme +import com.suwiki.feature.lectureevaluation.viewerreporter.LectureEvaluationState +import com.suwiki.feature.lectureevaluation.viewerreporter.R + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +fun OnboardingBottomSheet( + uiState: LectureEvaluationState, + hideOnboardingBottomSheet: () -> Unit, + pagerState: PagerState, + onClickLoginButton: () -> Unit = {}, + onClickSignupButton: () -> Unit = {}, +) { + SuwikiBottomSheet( + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ), + isSheetOpen = uiState.showOnboardingBottomSheet, + onDismissRequest = hideOnboardingBottomSheet, + ) { + OnboardingBottomSheetContent( + pagerState = pagerState, + onClickLoginButton = onClickLoginButton, + onClickSignupButton = onClickSignupButton, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun OnboardingBottomSheetContent( + pagerState: PagerState = rememberPagerState(pageCount = { ONBOARDING_PAGE_COUNT }), + onClickLoginButton: () -> Unit = {}, + onClickSignupButton: () -> Unit = {}, +) { + Column( + modifier = Modifier.padding(top = 62.dp, bottom = 50.dp, start = 24.dp, end = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier.align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.ic_logo), + contentDescription = stringResource(R.string.content_description_logo), + ) + + Spacer(modifier = Modifier.size(107.dp)) + + OnboardingPager(pagerState) + + Spacer(modifier = Modifier.size(34.dp)) + + OnboardingPagerIndicator( + pageCount = ONBOARDING_PAGE_COUNT, + pagerState = pagerState, + ) + + Spacer(modifier = Modifier.size(50.dp)) + + SuwikiContainedLargeButton(text = stringResource(R.string.onboarding_button_signup), onClick = onClickSignupButton) + Spacer(modifier = Modifier.size(12.dp)) + SuwikiOutlinedButton(text = stringResource(R.string.word_login), onClick = onClickLoginButton) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview(showBackground = true) +@Composable +fun OnboardingBottomSheetContentPreview() { + SuwikiTheme { + OnboardingBottomSheetContent() + } +} diff --git a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/component/OnboardingPager.kt b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/component/OnboardingPager.kt new file mode 100644 index 000000000..327303240 --- /dev/null +++ b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/component/OnboardingPager.kt @@ -0,0 +1,107 @@ +package com.suwiki.feature.lectureevaluation.viewerreporter.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.suwiki.core.designsystem.theme.Gray6A +import com.suwiki.core.designsystem.theme.GrayDA +import com.suwiki.core.designsystem.theme.Primary +import com.suwiki.core.designsystem.theme.SuwikiTheme +import com.suwiki.feature.lectureevaluation.viewerreporter.R + +const val ONBOARDING_PAGE_COUNT = 2 + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun OnboardingPager(pagerState: PagerState) { + HorizontalPager( + state = pagerState, + ) { page -> + when (page) { + 0 -> { + OnboardingPageContent( + title = stringResource(R.string.onboarding_title), + description = stringResource(R.string.onboarding1_description), + icon = R.drawable.ic_onboarding1, + ) + } + + 1 -> { + OnboardingPageContent( + title = stringResource(R.string.onboarding_title), + description = stringResource(R.string.onboarding2_description), + icon = R.drawable.ic_onboarding2, + ) + } + } + } +} + +@Composable +private fun OnboardingPageContent( + title: String, + description: String, + @DrawableRes icon: Int, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier.size(160.dp), + painter = painterResource(id = icon), + contentDescription = "", + ) + + Spacer(modifier = Modifier.size(42.dp)) + + Text(text = title, style = SuwikiTheme.typography.header2, textAlign = TextAlign.Center) + + Spacer(modifier = Modifier.size(7.dp)) + + Text(text = description, style = SuwikiTheme.typography.body5, color = Gray6A) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OnboardingPagerIndicator( + pageCount: Int, + pagerState: PagerState, +) { + Row( + horizontalArrangement = Arrangement.Center, + ) { + repeat(pageCount) { iteration -> + val color = if (pagerState.currentPage == iteration) Primary else GrayDA + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .clip(CircleShape) + .background(color) + .size(5.dp), + ) + } + } +} diff --git a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/navigation/LectureEvaluationNavigation.kt b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/navigation/LectureEvaluationNavigation.kt index 28d1bf054..591c7e837 100644 --- a/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/navigation/LectureEvaluationNavigation.kt +++ b/feature/lectureevaluation/viewerreporter/src/main/java/com/suwiki/feature/lectureevaluation/viewerreporter/navigation/LectureEvaluationNavigation.kt @@ -5,7 +5,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import com.suwiki.feature.lectureevaluation.viewerreporter.LectureEvaluationScreen +import com.suwiki.feature.lectureevaluation.viewerreporter.LectureEvaluationRoute fun NavController.navigateLectureEvaluation(navOptions: NavOptions) { navigate(LectureEvaluationRoute.route, navOptions) @@ -13,9 +13,13 @@ fun NavController.navigateLectureEvaluation(navOptions: NavOptions) { fun NavGraphBuilder.lectureEvaluationNavGraph( padding: PaddingValues, + navigateLogin: () -> Unit, ) { composable(route = LectureEvaluationRoute.route) { - LectureEvaluationScreen(padding) + LectureEvaluationRoute( + padding = padding, + navigateLogin = navigateLogin, + ) } } diff --git a/feature/lectureevaluation/viewerreporter/src/main/res/drawable/ic_onboarding1.png b/feature/lectureevaluation/viewerreporter/src/main/res/drawable/ic_onboarding1.png new file mode 100644 index 000000000..b35a6a779 Binary files /dev/null and b/feature/lectureevaluation/viewerreporter/src/main/res/drawable/ic_onboarding1.png differ diff --git a/feature/lectureevaluation/viewerreporter/src/main/res/drawable/ic_onboarding2.png b/feature/lectureevaluation/viewerreporter/src/main/res/drawable/ic_onboarding2.png new file mode 100644 index 000000000..8fe8800c7 Binary files /dev/null and b/feature/lectureevaluation/viewerreporter/src/main/res/drawable/ic_onboarding2.png differ diff --git a/feature/lectureevaluation/viewerreporter/src/main/res/values/strings.xml b/feature/lectureevaluation/viewerreporter/src/main/res/values/strings.xml new file mode 100644 index 000000000..42a81021c --- /dev/null +++ b/feature/lectureevaluation/viewerreporter/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + 로고 + 로그인 + 가입하기 + 수원대 학생이라면\n수위키로 한 번에 해결! + 재학생들의 솔직한 강의평가 확인하기 + 재학생들의 솔직한 강의평가 확인하기 + diff --git a/feature/login/src/main/java/com/suwiki/feature/login/LoginContract.kt b/feature/login/src/main/java/com/suwiki/feature/login/LoginContract.kt new file mode 100644 index 000000000..59a464a02 --- /dev/null +++ b/feature/login/src/main/java/com/suwiki/feature/login/LoginContract.kt @@ -0,0 +1,20 @@ +package com.suwiki.feature.login + +data class LoginState( + val showEmailNotAuthDialog: Boolean = false, + val showLoginFailDialog: Boolean = false, + val id: String = "", + val password: String = "", + val showPassword: Boolean = false, + val isLoading: Boolean = false, +) { + val loginButtonEnable = id.isNotBlank() && password.isNotBlank() +} + +sealed interface LoginSideEffect { + data class HandleException(val throwable: Throwable) : LoginSideEffect + data object PopBackStack : LoginSideEffect + data object NavigateFindId : LoginSideEffect + data object NavigateFindPassword : LoginSideEffect + data object NavigateSignUp : LoginSideEffect +} diff --git a/feature/login/src/main/java/com/suwiki/feature/login/LoginScreen.kt b/feature/login/src/main/java/com/suwiki/feature/login/LoginScreen.kt new file mode 100644 index 000000000..7101b27e2 --- /dev/null +++ b/feature/login/src/main/java/com/suwiki/feature/login/LoginScreen.kt @@ -0,0 +1,199 @@ +package com.suwiki.feature.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.suwiki.core.designsystem.component.button.SuwikiContainedLargeButton +import com.suwiki.core.designsystem.component.dialog.SuwikiDialog +import com.suwiki.core.designsystem.component.loading.LoadingScreen +import com.suwiki.core.designsystem.component.textfield.SuwikiRegularTextField +import com.suwiki.core.designsystem.theme.Gray6A +import com.suwiki.core.designsystem.theme.GrayF6 +import com.suwiki.core.designsystem.theme.Primary +import com.suwiki.core.designsystem.theme.SuwikiTheme +import com.suwiki.core.ui.extension.suwikiClickable +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun LoginRoute( + viewModel: LoginViewModel = hiltViewModel(), + popBackStack: () -> Unit, + navigateFindId: () -> Unit, + navigateFindPassword: () -> Unit, + navigateSignup: () -> Unit, + handleException: (Throwable) -> Unit, +) { + val uiState = viewModel.collectAsState().value + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is LoginSideEffect.HandleException -> handleException(sideEffect.throwable) + LoginSideEffect.NavigateFindId -> navigateFindId() + LoginSideEffect.NavigateFindPassword -> navigateFindPassword() + LoginSideEffect.NavigateSignUp -> navigateSignup() + LoginSideEffect.PopBackStack -> popBackStack() + } + } + + LoginScreen( + uiState = uiState, + onIdTextFieldValueChange = viewModel::updateId, + onPasswordTextFieldValueChange = viewModel::updatePassword, + onClickIdClearButton = { viewModel.updateId("") }, + onClickPasswordClearButton = { viewModel.updatePassword("") }, + onClickPasswordEyeIcon = viewModel::toggleShowPassword, + onClickFindIdText = { /* TODO */ }, + onClickFindPasswordText = { /* TODO */ }, + onClickSignupText = { /* TODO */ }, + onClickLoginButton = viewModel::login, + onClickLoginFailDialogButton = viewModel::hideLoginFailDialog, + onClickEmailNotAuthDialogButton = viewModel::hideEmailNotAuthDialog, + ) +} + +@Composable +fun LoginScreen( + uiState: LoginState = LoginState(), + onIdTextFieldValueChange: (String) -> Unit = {}, + onPasswordTextFieldValueChange: (String) -> Unit = {}, + onClickIdClearButton: () -> Unit = {}, + onClickPasswordClearButton: () -> Unit = {}, + onClickPasswordEyeIcon: () -> Unit = {}, + onClickFindIdText: () -> Unit = {}, + onClickFindPasswordText: () -> Unit = {}, + onClickSignupText: () -> Unit = {}, + onClickLoginButton: () -> Unit = {}, + onClickLoginFailDialogButton: () -> Unit = {}, + onClickEmailNotAuthDialogButton: () -> Unit = {}, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 63.dp, bottom = 30.dp, start = 24.dp, end = 24.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(R.string.word_login), style = SuwikiTheme.typography.header1) + + Spacer(modifier = Modifier.size(26.dp)) + + SuwikiRegularTextField( + value = uiState.id, + onValueChange = onIdTextFieldValueChange, + onClickClearButton = onClickIdClearButton, + label = stringResource(R.string.word_id), + placeholder = stringResource(R.string.login_screen_id_textfield_placeholder), + ) + + Spacer(modifier = Modifier.size(20.dp)) + + SuwikiRegularTextField( + value = uiState.password, + onValueChange = onPasswordTextFieldValueChange, + onClickClearButton = onClickPasswordClearButton, + showEyeIcon = true, + showValue = uiState.showPassword, + onClickEyeIcon = onClickPasswordEyeIcon, + label = stringResource(R.string.word_password), + placeholder = stringResource(R.string.login_screen_password_textfield_placeholder), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.suwikiClickable(onClick = onClickFindIdText), + text = stringResource(R.string.login_screen_find_id), + style = SuwikiTheme.typography.body5, + color = Gray6A, + ) + VerticalDivider( + modifier = Modifier.padding(vertical = 4.5.dp), + thickness = 1.dp, + color = GrayF6, + ) + Text( + modifier = Modifier.suwikiClickable(onClick = onClickFindPasswordText), + text = stringResource(R.string.login_screen_find_password), + style = SuwikiTheme.typography.body5, + color = Gray6A, + ) + VerticalDivider( + modifier = Modifier.padding(vertical = 4.5.dp), + thickness = 1.dp, + color = GrayF6, + ) + Text( + modifier = Modifier.suwikiClickable(onClick = onClickSignupText), + text = stringResource(R.string.word_signup), + style = SuwikiTheme.typography.body4, + color = Primary, + ) + } + + Spacer(modifier = Modifier.size(20.dp)) + + SuwikiContainedLargeButton( + modifier = Modifier.imePadding(), + clickable = uiState.loginButtonEnable, + enabled = uiState.loginButtonEnable, + text = stringResource(R.string.word_login), + onClick = onClickLoginButton, + ) + } + + if (uiState.showLoginFailDialog) { + SuwikiDialog( + headerText = stringResource(R.string.login_screen_dialog_login_fail_title), + bodyText = stringResource(R.string.login_screen_dialog_login_fail_body), + confirmButtonText = stringResource(R.string.word_confirm), + onDismissRequest = onClickLoginFailDialogButton, + onClickConfirm = onClickLoginFailDialogButton, + ) + } + + if (uiState.showEmailNotAuthDialog) { + SuwikiDialog( + headerText = stringResource(R.string.login_screen_dialog_email_not_auth_title), + bodyText = stringResource(R.string.login_screen_dialog_email_not_auth_body), + confirmButtonText = stringResource(R.string.word_confirm), + onDismissRequest = onClickEmailNotAuthDialogButton, + onClickConfirm = onClickEmailNotAuthDialogButton, + ) + } + + if (uiState.isLoading) { + LoadingScreen() + } + } +} + +@Preview(showBackground = true) +@Composable +fun LectureEvaluationScreenPreview() { + SuwikiTheme { + LoginScreen() + } +} diff --git a/feature/login/src/main/java/com/suwiki/feature/login/LoginViewModel.kt b/feature/login/src/main/java/com/suwiki/feature/login/LoginViewModel.kt new file mode 100644 index 000000000..89c54886c --- /dev/null +++ b/feature/login/src/main/java/com/suwiki/feature/login/LoginViewModel.kt @@ -0,0 +1,58 @@ +package com.suwiki.feature.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.suwiki.core.model.exception.EmailNotAuthedException +import com.suwiki.core.model.exception.LoginFailedException +import com.suwiki.core.model.exception.PasswordErrorException +import com.suwiki.domain.login.usecase.LoginUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.annotation.OrbitExperimental +import org.orbitmvi.orbit.syntax.simple.blockingIntent +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 +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val loginUseCase: LoginUseCase, +) : ContainerHost, ViewModel() { + override val container: Container = container(LoginState()) + + fun login() = intent { + viewModelScope.launch { + reduce { state.copy(isLoading = true) } + loginUseCase(loginId = state.id, password = state.password) + .onSuccess { + postSideEffect(LoginSideEffect.PopBackStack) + } + .onFailure { + when (it) { + is LoginFailedException -> reduce { state.copy(showLoginFailDialog = true) } + is PasswordErrorException -> reduce { state.copy(showLoginFailDialog = true) } + is EmailNotAuthedException -> reduce { state.copy(showEmailNotAuthDialog = true) } + else -> postSideEffect(LoginSideEffect.HandleException(it)) + } + } + + reduce { state.copy(isLoading = false) } + } + } + + fun toggleShowPassword() = intent { reduce { state.copy(showPassword = !state.showPassword) } } + + fun hideLoginFailDialog() = intent { reduce { state.copy(showLoginFailDialog = false) } } + + fun hideEmailNotAuthDialog() = intent { reduce { state.copy(showEmailNotAuthDialog = false) } } + + @OptIn(OrbitExperimental::class) + fun updateId(id: String) = blockingIntent { reduce { state.copy(id = id) } } + + @OptIn(OrbitExperimental::class) + fun updatePassword(password: String) = blockingIntent { reduce { state.copy(password = password) } } +} diff --git a/feature/login/src/main/java/com/suwiki/feature/login/navigation/LoginNavigation.kt b/feature/login/src/main/java/com/suwiki/feature/login/navigation/LoginNavigation.kt new file mode 100644 index 000000000..a876c82b8 --- /dev/null +++ b/feature/login/src/main/java/com/suwiki/feature/login/navigation/LoginNavigation.kt @@ -0,0 +1,32 @@ +package com.suwiki.feature.login.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.suwiki.feature.login.LoginRoute + +fun NavController.navigateLogin() { + navigate(LoginRoute.route) +} + +fun NavGraphBuilder.loginNavGraph( + popBackStack: () -> Unit, + navigateFindId: () -> Unit, + navigateFindPassword: () -> Unit, + navigateSignup: () -> Unit, + handleException: (Throwable) -> Unit, +) { + composable(route = LoginRoute.route) { + LoginRoute( + popBackStack = popBackStack, + navigateFindId = navigateFindId, + navigateFindPassword = navigateFindPassword, + navigateSignup = navigateSignup, + handleException = handleException, + ) + } +} + +object LoginRoute { + const val route = "login" +} diff --git a/feature/login/src/main/res/values/strings.xml b/feature/login/src/main/res/values/strings.xml new file mode 100644 index 000000000..5de9774e4 --- /dev/null +++ b/feature/login/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + 로그인 + 아이디 + 비밀번호 + 아이디를 입력하세요 + 비밀번호를 입력하세요 + 아이디 찾기 + 비밀번호 찾기 + 회원가입 + 잘못된 아이디 혹은 비밀번호입니다. + 입력한 내용을 다시 확인해주세요. + 확인 + 웹메일 인증 절차가 필요합니다. + 학교 웹미일 인증 진행 후 로그인 가능합니다. + diff --git a/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainContract.kt b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainContract.kt new file mode 100644 index 000000000..375ef831d --- /dev/null +++ b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainContract.kt @@ -0,0 +1,9 @@ +package com.suwiki.feature.navigator + +data class MainState( + val toastMessage: String = "", + val toastVisible: Boolean = false, + val showNetworkErrorDialog: Boolean = false, +) + +sealed interface MainSideEffect diff --git a/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainNavigator.kt b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainNavigator.kt index 291833e9d..2873a2f22 100644 --- a/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainNavigator.kt +++ b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainNavigator.kt @@ -9,6 +9,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions import com.suwiki.feature.lectureevaluation.viewerreporter.navigation.navigateLectureEvaluation +import com.suwiki.feature.login.navigation.navigateLogin import com.suwiki.feature.myinfo.navigation.navigateMyInfo import com.suwiki.feature.timetable.navigation.TimetableRoute import com.suwiki.feature.timetable.navigation.navigateTimetable @@ -43,6 +44,10 @@ internal class MainNavigator( } } + fun navigateLogin() { + navController.navigateLogin() + } + fun popBackStackIfNotHome() { if (!isSameCurrentDestination(TimetableRoute.route)) { navController.popBackStack() diff --git a/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainScreen.kt b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainScreen.kt index 189ba451a..17fad572f 100644 --- a/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainScreen.kt +++ b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainScreen.kt @@ -13,71 +13,45 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost +import com.suwiki.core.designsystem.component.dialog.SuwikiDialog import com.suwiki.core.designsystem.component.toast.SuwikiToast import com.suwiki.core.designsystem.shadow.bottomNavigationShadow import com.suwiki.core.designsystem.theme.GrayDA import com.suwiki.core.designsystem.theme.Primary -import com.suwiki.core.designsystem.theme.SuwikiTheme import com.suwiki.core.designsystem.theme.White import com.suwiki.core.ui.extension.suwikiClickable import com.suwiki.feature.lectureevaluation.viewerreporter.navigation.lectureEvaluationNavGraph +import com.suwiki.feature.login.R +import com.suwiki.feature.login.navigation.loginNavGraph +import com.suwiki.feature.login.navigation.navigateLogin import com.suwiki.feature.myinfo.navigation.myInfoNavGraph import com.suwiki.feature.timetable.navigation.timetableNavGraph import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex - -private const val SHOW_TOAST_LENGTH = 2000L -val mutex = Mutex() +import org.orbitmvi.orbit.compose.collectAsState @Composable internal fun MainScreen( modifier: Modifier = Modifier, + viewModel: MainViewModel = hiltViewModel(), navigator: MainNavigator = rememberMainNavigator(), ) { - val coroutineScope = rememberCoroutineScope() - - var toastMessage: String? by remember { mutableStateOf(null) } - var toastVisible by remember { mutableStateOf(false) } - - // TODO REMOVE - @Suppress("detekt:UnusedPrivateProperty") - val onShowToast: (message: String) -> Unit = { message -> - coroutineScope.launch { - mutex.lock() - toastMessage = message - } - } - - LaunchedEffect(key1 = toastMessage) { - if (toastMessage == null) return@LaunchedEffect - - toastVisible = true - delay(SHOW_TOAST_LENGTH) - toastVisible = false - if (mutex.isLocked) mutex.unlock() - } + val uiState = viewModel.collectAsState().value Scaffold( modifier = modifier, @@ -86,12 +60,21 @@ internal fun MainScreen( navController = navigator.navController, startDestination = navigator.startDestination, ) { + loginNavGraph( + popBackStack = navigator::popBackStackIfNotHome, + navigateFindId = { /* TODO */ }, + navigateFindPassword = { /* TODO */ }, + navigateSignup = { /* TODO */ }, + handleException = viewModel::handleException, + ) + timetableNavGraph( padding = innerPadding, ) lectureEvaluationNavGraph( padding = innerPadding, + navigateLogin = navigator::navigateLogin, ) myInfoNavGraph( @@ -99,9 +82,19 @@ internal fun MainScreen( ) } + if (uiState.showNetworkErrorDialog) { + SuwikiDialog( + headerText = stringResource(com.suwiki.feature.navigator.R.string.dialog_network_header), + bodyText = stringResource(com.suwiki.feature.navigator.R.string.dialog_network_body), + confirmButtonText = stringResource(id = R.string.word_confirm), + onDismissRequest = viewModel::hideNetworkErrorDialog, + onClickConfirm = viewModel::hideNetworkErrorDialog, + ) + } + SuwikiToast( - visible = toastVisible, - message = toastMessage ?: "", + visible = uiState.toastVisible, + message = uiState.toastMessage, ) }, bottomBar = { @@ -115,14 +108,6 @@ internal fun MainScreen( ) } -@Preview -@Composable -fun MainScreenPreview() { - SuwikiTheme { - MainScreen() - } -} - @Composable private fun MainBottomBar( visible: Boolean, diff --git a/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainViewModel.kt b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainViewModel.kt new file mode 100644 index 000000000..49520fbb0 --- /dev/null +++ b/feature/navigator/src/main/java/com/suwiki/feature/navigator/MainViewModel.kt @@ -0,0 +1,53 @@ +package com.suwiki.feature.navigator + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.suwiki.core.android.recordException +import com.suwiki.core.model.exception.NetworkException +import com.suwiki.core.model.exception.UnknownException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container +import java.net.ConnectException +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor() : ContainerHost, ViewModel() { + override val container: Container = container(MainState()) + + private val mutex = Mutex() + + fun onShowToast(msg: String) = intent { + viewModelScope.launch { + mutex.withLock { + reduce { state.copy(toastMessage = msg, toastVisible = true) } + delay(SHOW_TOAST_LENGTH) + reduce { state.copy(toastVisible = false) } + } + } + } + + fun handleException(throwable: Throwable) = intent { + when (throwable) { + is NetworkException -> reduce { state.copy(showNetworkErrorDialog = true) } + is ConnectException -> reduce { state.copy(showNetworkErrorDialog = true) } + else -> { + onShowToast(throwable.message ?: UnknownException().message) + recordException(throwable) + } + } + } + + fun hideNetworkErrorDialog() = intent { reduce { state.copy(showNetworkErrorDialog = false) } } + + companion object { + private const val SHOW_TOAST_LENGTH = 2000L + } +} diff --git a/feature/navigator/src/main/res/values/strings.xml b/feature/navigator/src/main/res/values/strings.xml index b4075cad7..9a477b064 100644 --- a/feature/navigator/src/main/res/values/strings.xml +++ b/feature/navigator/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ SUWIKI + 네트워크에 문제가 있어요. + 네트워크 연결 상태 또는 수위키 서버에 문제가 있어요. 문제가 지속된다면 내 정보 -> 문의하기를 통해 연락 주세요. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f34bb570..23399d2a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -189,7 +189,7 @@ firebase = ["firebase-analytics", "firebase-database"] androidx-lifecycle = ["androidx-lifecycle-runtime-ktx", "androidx-lifecycle-viewmodel-ktx"] androidx-navigation = ["navigation-fragment-ktx", "navigation-ui-ktx"] coroutine = ["kotlinx-coroutines-android", "kotlinx-coroutines-core"] -compose = ["ui", "ui-graphics", "ui-tooling-preview", "material3-compose", "coil-compose", "ui-foundation", "activity-compose", "lifecycle-compose", "navigation-compose"] +compose = ["ui", "ui-graphics", "ui-tooling-preview", "material3-compose", "coil-compose", "ui-foundation", "activity-compose", "lifecycle-compose", "navigation-compose", "hilt-navigation-compose"] compose-debug = ["ui-tooling", "ui-test-manifest"] orbit = ["orbit-core", "orbit-viewmodel", "orbit-compose"]