diff --git a/android/Staccato_AN/.editorconfig b/android/Staccato_AN/.editorconfig index 931bfa6b8..5facbdfb4 100644 --- a/android/Staccato_AN/.editorconfig +++ b/android/Staccato_AN/.editorconfig @@ -1,2 +1,2 @@ [*.{kt,kts}] -ktlint_function_naming_ignore_when_annotated_with=Composable \ No newline at end of file +ktlint_function_naming_ignore_when_annotated_with=Composable diff --git a/android/Staccato_AN/app/build.gradle.kts b/android/Staccato_AN/app/build.gradle.kts index 5c7f5b058..47b76d953 100644 --- a/android/Staccato_AN/app/build.gradle.kts +++ b/android/Staccato_AN/app/build.gradle.kts @@ -237,7 +237,7 @@ dependencies { implementation(libs.hilt.navigation.compose) implementation(libs.androidx.navigation.compose) - // coil + // Compose Coil implementation(libs.coil.compose) // Compose UI Test @@ -245,6 +245,9 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.test.manifest) + // Compose ViewModel + implementation(libs.lifecycle.viewmodel.compose) + // Compose ConstraintLayout implementation(libs.androidx.constraintlayout.compose) } diff --git a/android/Staccato_AN/app/src/main/AndroidManifest.xml b/android/Staccato_AN/app/src/main/AndroidManifest.xml index 5b00183b1..067a72df0 100644 --- a/android/Staccato_AN/app/src/main/AndroidManifest.xml +++ b/android/Staccato_AN/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - @@ -7,7 +6,9 @@ - + @@ -66,8 +67,8 @@ + android:screenOrientation="portrait" + android:windowSoftInputMode="adjustPan" /> + + , +) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/invitation/SentInvitationDto.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/invitation/SentInvitationDto.kt new file mode 100644 index 000000000..18e00a1bb --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/invitation/SentInvitationDto.kt @@ -0,0 +1,14 @@ +package com.on.staccato.data.dto.invitation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SentInvitationDto( + @SerialName("invitationId") val invitationId: Long, + @SerialName("inviteeId") val inviteeId: Long, + @SerialName("inviteeNickname") val inviteeNickname: String, + @SerialName("inviteeProfileImageUrl") val inviteeProfileImageUrl: String? = null, + @SerialName("categoryId") val categoryId: Long, + @SerialName("categoryTitle") val categoryTitle: String, +) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/invitation/SentInvitationsResponse.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/invitation/SentInvitationsResponse.kt new file mode 100644 index 000000000..908b5b06f --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/invitation/SentInvitationsResponse.kt @@ -0,0 +1,9 @@ +package com.on.staccato.data.dto.invitation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SentInvitationsResponse( + @SerialName("invitations") val invitations: List, +) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/mapper/InvitationMapper.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/mapper/InvitationMapper.kt new file mode 100644 index 000000000..b3321fd60 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/mapper/InvitationMapper.kt @@ -0,0 +1,39 @@ +package com.on.staccato.data.dto.mapper + +import com.on.staccato.data.dto.invitation.ReceivedInvitationDto +import com.on.staccato.data.dto.invitation.ReceivedInvitationsResponse +import com.on.staccato.data.dto.invitation.SentInvitationDto +import com.on.staccato.data.dto.invitation.SentInvitationsResponse +import com.on.staccato.domain.model.Member +import com.on.staccato.domain.model.invitation.ReceivedInvitation +import com.on.staccato.domain.model.invitation.SentInvitation + +fun ReceivedInvitationsResponse.toDomain(): List = invitations.map { it.toDomain() } + +fun ReceivedInvitationDto.toDomain(): ReceivedInvitation = + ReceivedInvitation( + invitationId = invitationId, + inviter = + Member( + memberId = inviterId, + nickname = inviterNickname, + memberImage = inviterProfile, + ), + categoryId = categoryId, + categoryTitle = categoryTitle, + ) + +fun SentInvitationsResponse.toDomain(): List = invitations.map { it.toDomain() } + +fun SentInvitationDto.toDomain(): SentInvitation = + SentInvitation( + invitationId = invitationId, + invitee = + Member( + memberId = inviteeId, + nickname = inviteeNickname, + memberImage = inviteeProfileImageUrl, + ), + categoryId = categoryId, + categoryTitle = categoryTitle, + ) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationApiService.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationApiService.kt index 9aa7151d8..ac20fa79c 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationApiService.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationApiService.kt @@ -2,9 +2,13 @@ package com.on.staccato.data.invitation import com.on.staccato.data.dto.invitation.InvitationRequest import com.on.staccato.data.dto.invitation.InvitationResponse +import com.on.staccato.data.dto.invitation.ReceivedInvitationsResponse +import com.on.staccato.data.dto.invitation.SentInvitationsResponse import com.on.staccato.data.network.ApiResult import retrofit2.http.Body +import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Path interface InvitationApiService { @POST(INVITATION_PATH) @@ -12,7 +16,35 @@ interface InvitationApiService { @Body invitation: InvitationRequest, ): ApiResult + @GET(RECEIVED_INVITATION_PATH) + suspend fun getReceivedInvitations(): ApiResult + + @POST(INVITATION_ACCEPT_PATH) + suspend fun postInvitationAccept( + @Path(INVITATION_ID) invitationId: Long, + ): ApiResult + + @POST(INVITATION_REJECT_PATH) + suspend fun postInvitationReject( + @Path(INVITATION_ID) invitationId: Long, + ): ApiResult + + @GET(SENT_INVITATION_PATH) + suspend fun getSentInvitations(): ApiResult + + @POST(INVITATION_CANCEL_PATH) + suspend fun postInvitationCancel( + @Path(INVITATION_ID) invitationId: Long, + ): ApiResult + companion object { const val INVITATION_PATH = "/invitations" + private const val INVITATION_ID = "invitationId" + private const val RECEIVED_INVITATION_PATH = "$INVITATION_PATH/received" + private const val SENT_INVITATION_PATH = "$INVITATION_PATH/sent" + private const val INVITATION_PATH_WITH_ID = "$INVITATION_PATH/{$INVITATION_ID}" + private const val INVITATION_ACCEPT_PATH = "$INVITATION_PATH_WITH_ID/accept" + private const val INVITATION_REJECT_PATH = "$INVITATION_PATH_WITH_ID/reject" + private const val INVITATION_CANCEL_PATH = "$INVITATION_PATH_WITH_ID/cancel" } } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationDefaultRepository.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationDefaultRepository.kt index cbe68c481..6bbf2ed86 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationDefaultRepository.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/invitation/InvitationDefaultRepository.kt @@ -1,8 +1,11 @@ package com.on.staccato.data.invitation import com.on.staccato.data.dto.invitation.InvitationRequest +import com.on.staccato.data.dto.mapper.toDomain import com.on.staccato.data.network.ApiResult import com.on.staccato.data.network.handle +import com.on.staccato.domain.model.invitation.ReceivedInvitation +import com.on.staccato.domain.model.invitation.SentInvitation import com.on.staccato.domain.repository.InvitationRepository import javax.inject.Inject @@ -17,4 +20,19 @@ class InvitationDefaultRepository ): ApiResult> = invitationApiService.postInvitation(InvitationRequest(categoryId, inviteeIds)) .handle { it.invitationIds } + + override suspend fun getReceivedInvitations(): ApiResult> = + invitationApiService.getReceivedInvitations().handle { it.toDomain() } + + override suspend fun acceptInvitation(invitationId: Long): ApiResult = + invitationApiService.postInvitationAccept(invitationId = invitationId).handle() + + override suspend fun rejectInvitation(invitationId: Long): ApiResult = + invitationApiService.postInvitationReject(invitationId = invitationId).handle() + + override suspend fun getSentInvitations(): ApiResult> = + invitationApiService.getSentInvitations().handle { it.toDomain() } + + override suspend fun cancelInvitation(invitationId: Long): ApiResult = + invitationApiService.postInvitationCancel(invitationId = invitationId).handle() } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/di/module/ApiServiceModule.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/di/module/ApiServiceModule.kt index a43eccd38..e3600f7d5 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/di/module/ApiServiceModule.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/di/module/ApiServiceModule.kt @@ -41,17 +41,17 @@ object ApiServiceModule { @Singleton @Provides - fun memberApiService(retrofit: Retrofit): MemberApiService = retrofit.create(MemberApiService::class.java) + fun providesMemberApiService(retrofit: Retrofit): MemberApiService = retrofit.create(MemberApiService::class.java) @Singleton @Provides - fun myPageApiService(retrofit: Retrofit): MyPageApiService = retrofit.create(MyPageApiService::class.java) + fun providesMyPageApiService(retrofit: Retrofit): MyPageApiService = retrofit.create(MyPageApiService::class.java) @Singleton @Provides - fun invitationApiService(retrofit: Retrofit): InvitationApiService = retrofit.create(InvitationApiService::class.java) + fun providesInvitationApiService(retrofit: Retrofit): InvitationApiService = retrofit.create(InvitationApiService::class.java) @Singleton @Provides - fun provideNotificationService(retrofit: Retrofit): NotificationApiService = retrofit.create(NotificationApiService::class.java) + fun providesNotificationApiService(retrofit: Retrofit): NotificationApiService = retrofit.create(NotificationApiService::class.java) } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/model/invitation/ReceivedInvitation.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/model/invitation/ReceivedInvitation.kt new file mode 100644 index 000000000..d75eeb1bf --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/model/invitation/ReceivedInvitation.kt @@ -0,0 +1,10 @@ +package com.on.staccato.domain.model.invitation + +import com.on.staccato.domain.model.Member + +data class ReceivedInvitation( + val invitationId: Long, + val inviter: Member, + val categoryId: Long, + val categoryTitle: String, +) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/model/invitation/SentInvitation.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/model/invitation/SentInvitation.kt new file mode 100644 index 000000000..68a74e731 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/model/invitation/SentInvitation.kt @@ -0,0 +1,10 @@ +package com.on.staccato.domain.model.invitation + +import com.on.staccato.domain.model.Member + +data class SentInvitation( + val invitationId: Long, + val invitee: Member, + val categoryId: Long, + val categoryTitle: String, +) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/repository/InvitationRepository.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/repository/InvitationRepository.kt index 7a5f1218a..529d6e228 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/repository/InvitationRepository.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/domain/repository/InvitationRepository.kt @@ -1,10 +1,22 @@ package com.on.staccato.domain.repository import com.on.staccato.data.network.ApiResult +import com.on.staccato.domain.model.invitation.ReceivedInvitation +import com.on.staccato.domain.model.invitation.SentInvitation interface InvitationRepository { suspend fun invite( categoryId: Long, inviteeIds: List, ): ApiResult> + + suspend fun getReceivedInvitations(): ApiResult> + + suspend fun acceptInvitation(invitationId: Long): ApiResult + + suspend fun rejectInvitation(invitationId: Long): ApiResult + + suspend fun getSentInvitations(): ApiResult> + + suspend fun cancelInvitation(invitationId: Long): ApiResult } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultAlertDialog.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultAlertDialog.kt new file mode 100644 index 000000000..f46ef6632 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultAlertDialog.kt @@ -0,0 +1,106 @@ +package com.on.staccato.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.on.staccato.theme.Body2 +import com.on.staccato.theme.Gray1 +import com.on.staccato.theme.Gray3 +import com.on.staccato.theme.Gray4 +import com.on.staccato.theme.StaccatoBlack +import com.on.staccato.theme.StaccatoBlue +import com.on.staccato.theme.Title2 +import com.on.staccato.theme.White + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DefaultAlertDialog( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + dismissButton: @Composable (() -> Unit)? = null, + properties: DialogProperties = DialogProperties(), +) { + BasicAlertDialog( + modifier = modifier.fillMaxWidth(), + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(10.dp), + color = White, + ) { + Column( + modifier = modifier.padding(horizontal = 24.dp, vertical = 20.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = title, + style = Title2, + color = StaccatoBlack, + ) + if (description != null) { + Text( + text = description, + style = Body2, + color = Gray3, + ) + } + Spacer(modifier = modifier.height(36.dp)) + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + dismissButton?.invoke() + Spacer(modifier = modifier.width(6.dp)) + confirmButton() + } + } + } + } +} + +@Composable +@Preview +private fun DefaultAlertDialogPreview() { + DefaultAlertDialog( + title = "제목제목제목", + description = "내용내용내용.\n내용내용, 내용내용내용??", + onDismissRequest = {}, + confirmButton = { + DefaultTextButton( + text = "확인", + onClick = {}, + backgroundColor = StaccatoBlue, + textColor = White, + ) + }, + dismissButton = { + DefaultTextButton( + text = "취소", + onClick = {}, + backgroundColor = Gray1, + textColor = Gray4, + ) + }, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultEmptyView.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultEmptyView.kt new file mode 100644 index 000000000..34651578e --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultEmptyView.kt @@ -0,0 +1,52 @@ +package com.on.staccato.presentation.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.on.staccato.R +import com.on.staccato.theme.Body4 +import com.on.staccato.theme.Gray3 + +@Composable +fun DefaultEmptyView( + modifier: Modifier = Modifier, + description: String, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(R.drawable.staccato_character_gray), + contentDescription = null, + modifier = Modifier.size(110.dp), + ) + Spacer(Modifier.height(10.dp)) + Text( + text = description, + style = Body4, + color = Gray3, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + } +} + +@Composable +@Preview(showBackground = true, backgroundColor = 0xFFFFFF) +private fun DefaultEmptyViewPreview() { + DefaultEmptyView(description = "목록의 아이템이 비어있는 경우\n나타내는 화면입니다.\n글자는 가운데 정렬입니다.") +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultTextButton.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultTextButton.kt new file mode 100644 index 000000000..17a9ab13d --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/DefaultTextButton.kt @@ -0,0 +1,95 @@ +package com.on.staccato.presentation.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +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.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.on.staccato.theme.Body4 +import com.on.staccato.theme.Gray1 +import com.on.staccato.theme.Gray2 +import com.on.staccato.theme.Gray4 +import com.on.staccato.theme.StaccatoBlack +import com.on.staccato.theme.StaccatoBlue +import com.on.staccato.theme.White + +@Composable +fun DefaultTextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + backgroundColor: Color, + textColor: Color, + contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + border: BorderStroke = BorderStroke(width = 0.dp, color = Color.Transparent), + textStyle: TextStyle = Body4, +) { + Box( + modifier = + modifier + .padding(vertical = 4.dp) + .background( + color = if (enabled) backgroundColor else Gray1, + shape = RoundedCornerShape(5.dp), + ) + .clickableWithoutRipple { onClick() } + .border( + border = border, + shape = RoundedCornerShape(5.dp), + ), + ) { + Text( + modifier = + modifier.padding(contentPadding), + text = text, + style = textStyle, + color = if (enabled) textColor else Gray4, + ) + } +} + +@Preview(name = "활성화 상태", showBackground = true) +@Composable +private fun EnabledDefaultTextButtonPreview() { + DefaultTextButton( + text = "활성화 버튼", + onClick = {}, + backgroundColor = StaccatoBlue, + textColor = White, + ) +} + +@Preview(name = "비활성화 상태") +@Composable +private fun DisabledDefaultTextButtonPreview() { + DefaultTextButton( + text = "비활성화 버튼", + onClick = {}, + enabled = false, + backgroundColor = StaccatoBlue, + textColor = White, + ) +} + +@Preview(name = "테두리 설정", showBackground = true) +@Composable +private fun BorderedDefaultTextButtonPreview() { + DefaultTextButton( + text = "거절", + onClick = {}, + enabled = true, + backgroundColor = White, + textColor = StaccatoBlack, + border = BorderStroke(0.5.dp, Gray2), + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/topbar/DefaultNavigationTopBar.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/topbar/DefaultNavigationTopBar.kt new file mode 100644 index 000000000..9ac67305e --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/component/topbar/DefaultNavigationTopBar.kt @@ -0,0 +1,177 @@ +package com.on.staccato.presentation.component.topbar + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.on.staccato.R +import com.on.staccato.presentation.component.clickableWithoutRipple +import com.on.staccato.theme.Body4 +import com.on.staccato.theme.Gray3 +import com.on.staccato.theme.Gray5 +import com.on.staccato.theme.StaccatoBlack +import com.on.staccato.theme.Title2 +import com.on.staccato.theme.White + +@OptIn(ExperimentalMaterial3Api::class) +private val TopAppBarColors = + TopAppBarColors( + containerColor = White, + scrolledContainerColor = White, + navigationIconContentColor = Gray3, + titleContentColor = StaccatoBlack, + actionIconContentColor = Gray3, + ) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DefaultNavigationTopBar( + title: String? = null, + subtitle: String? = null, + isTitleCentered: Boolean = true, + @DrawableRes vectorResource: Int = R.drawable.icon_arrow_left, + onNavigationClick: () -> Unit, + colors: TopAppBarColors = TopAppBarColors, +) { + if (isTitleCentered) { + CenterAlignedTopAppBar( + title = { + if (title != null) TopBarTitleText(title, subtitle, isTitleCentered) + }, + navigationIcon = { + NavigationIconButton(vectorResource, onNavigationClick) + }, + colors = colors, + ) + } else { + TopAppBar( + title = { + if (title != null) TopBarTitleText(title, subtitle, isTitleCentered) + }, + navigationIcon = { + NavigationIconButton(vectorResource, onNavigationClick) + }, + colors = colors, + ) + } +} + +@Composable +private fun TopBarTitleText( + title: String, + subtitle: String?, + isTitleCentered: Boolean, +) { + val titleAlignment = + if (isTitleCentered) { + Alignment.CenterHorizontally + } else { + Alignment.Start + } + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = titleAlignment, + ) { + Text( + text = title, + style = Title2, + color = Gray5, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = Body4, + color = Gray5, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun NavigationIconButton( + @DrawableRes vectorResource: Int, + onNavigationClick: () -> Unit, +) { + Icon( + modifier = + Modifier + .padding(5.dp) + .clickableWithoutRipple { onNavigationClick() }, + imageVector = ImageVector.vectorResource(id = vectorResource), + contentDescription = stringResource(id = R.string.top_bar_navigation_back_icon), + tint = Gray3, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "제목이 없는 경우") +@Composable +private fun DefaultNavigationTopBarPreview() { + DefaultNavigationTopBar( + onNavigationClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "제목이 중앙에 위치") +@Composable +private fun CenteredTitleTopBarPreview() { + DefaultNavigationTopBar( + title = "상단 앱 바 제목", + onNavigationClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "제목이 왼쪽에 위치") +@Composable +private fun LeftSideTitleTopBarPreview() { + DefaultNavigationTopBar( + title = "상단 앱 바 제목", + isTitleCentered = false, + onNavigationClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "제목과 부제목이 중앙에 위치") +@Composable +private fun CenteredTitleWithSubTitleTopBarPreview() { + DefaultNavigationTopBar( + title = "상단 앱 바 제목", + subtitle = "상단 앱 바 부제목입니다", + onNavigationClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "제목과 부제목이 왼쪽에 위치") +@Composable +private fun LeftSideTitleWithSubTitleTopBarPreview() { + DefaultNavigationTopBar( + title = "상단 앱 바 제목", + subtitle = "상단 앱 바 부제목입니다", + isTitleCentered = false, + onNavigationClick = {}, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/InvitationManagementActivity.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/InvitationManagementActivity.kt new file mode 100644 index 000000000..8fbd9dacb --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/InvitationManagementActivity.kt @@ -0,0 +1,26 @@ +package com.on.staccato.presentation.invitation + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class InvitationManagementActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + InvitationManagementScreen( + onNavigationClick = { finish() }, + ) + } + } + + companion object { + fun launch(context: Context) { + context.startActivity(Intent(context, InvitationManagementActivity::class.java)) + } + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/InvitationManagementScreen.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/InvitationManagementScreen.kt new file mode 100644 index 000000000..284f173c6 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/InvitationManagementScreen.kt @@ -0,0 +1,82 @@ +package com.on.staccato.presentation.invitation + +import androidx.compose.foundation.layout.padding +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.on.staccato.presentation.invitation.component.InvitationDialogs +import com.on.staccato.presentation.invitation.component.InvitationManagement +import com.on.staccato.presentation.invitation.component.InvitationManagementTopBar +import com.on.staccato.presentation.invitation.model.InvitationTabMenu +import com.on.staccato.presentation.invitation.model.InvitationTabMenu.RECEIVED_INVITATION +import com.on.staccato.presentation.invitation.model.InvitationTabMenu.SENT_INVITATION +import com.on.staccato.presentation.invitation.model.ToastMessage +import com.on.staccato.presentation.invitation.viewmodel.InvitationViewModel +import com.on.staccato.presentation.util.showToast +import com.on.staccato.theme.White + +@Composable +fun InvitationManagementScreen( + modifier: Modifier = Modifier, + invitationViewModel: InvitationViewModel = hiltViewModel(), + onNavigationClick: () -> Unit, + defaultSelectedMenu: InvitationTabMenu = RECEIVED_INVITATION, +) { + val receivedInvitations by invitationViewModel.receivedInvitations.collectAsStateWithLifecycle() + val sentInvitations by invitationViewModel.sentInvitations.collectAsStateWithLifecycle() + val dialogState by invitationViewModel.dialogState + val context = LocalContext.current + var selectedMenu by remember { mutableStateOf(defaultSelectedMenu) } + + LaunchedEffect(Unit) { + invitationViewModel.toastMessage.collect { + val message = + when (it) { + is ToastMessage.FromResource -> context.getString(it.messageId) + is ToastMessage.Plain -> it.errorMessage + } + context.showToast(message) + } + } + + LaunchedEffect(Unit) { + invitationViewModel.exceptionState.collect { state -> + context.showToast(context.getString(state.messageId)) + } + } + + Scaffold( + containerColor = White, + topBar = { InvitationManagementTopBar(onNavigationClick) }, + ) { contentPadding -> + InvitationManagement( + modifier = modifier.padding(contentPadding), + selectedMenu = selectedMenu, + onMenuClick = { + selectedMenu = it + when (it) { + RECEIVED_INVITATION -> invitationViewModel.fetchReceivedInvitations() + SENT_INVITATION -> invitationViewModel.fetchSentInvitations() + } + }, + receivedInvitations = receivedInvitations, + onRejectClick = { invitationViewModel.showRejectDialog(it) }, + onAcceptClick = { invitationViewModel.acceptInvitation(it) }, + sentInvitations = sentInvitations, + onCancelClick = { invitationViewModel.showCancelDialog(it) }, + ) + + InvitationDialogs( + state = dialogState, + onDismiss = { invitationViewModel.dismissDialog() }, + ) + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/CategoryTitle.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/CategoryTitle.kt new file mode 100644 index 000000000..217d94bfe --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/CategoryTitle.kt @@ -0,0 +1,29 @@ +package com.on.staccato.presentation.invitation.component + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.on.staccato.theme.StaccatoBlack +import com.on.staccato.theme.Title3 + +@Composable +fun CategoryTitle( + title: String, + modifier: Modifier = Modifier, + style: TextStyle = Title3, + color: Color = StaccatoBlack, +) { + Text( + text = title, + modifier = modifier, + style = style, + color = color, + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationDialogs.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationDialogs.kt new file mode 100644 index 000000000..35889a846 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationDialogs.kt @@ -0,0 +1,79 @@ +package com.on.staccato.presentation.invitation.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.on.staccato.R +import com.on.staccato.presentation.component.DefaultAlertDialog +import com.on.staccato.presentation.component.DefaultTextButton +import com.on.staccato.presentation.invitation.model.InvitationDialogState +import com.on.staccato.presentation.invitation.model.InvitationDialogState.Cancel +import com.on.staccato.presentation.invitation.model.InvitationDialogState.None +import com.on.staccato.presentation.invitation.model.InvitationDialogState.Reject +import com.on.staccato.theme.Gray1 +import com.on.staccato.theme.Gray4 +import com.on.staccato.theme.StaccatoBlue +import com.on.staccato.theme.White + +private val ButtonPaddingValues = PaddingValues(horizontal = 20.dp, vertical = 12.dp) + +@Composable +fun InvitationDialogs( + state: InvitationDialogState, + onDismiss: () -> Unit, +) { + when (state) { + is None -> Unit + + is Reject -> { + DefaultAlertDialog( + title = stringResource(id = R.string.invitation_management_dialog_reject_title), + onDismissRequest = onDismiss, + confirmButton = { + DefaultTextButton( + text = stringResource(id = R.string.all_confirm), + onClick = state.onConfirm, + backgroundColor = StaccatoBlue, + textColor = White, + contentPadding = ButtonPaddingValues, + ) + }, + dismissButton = { + DefaultTextButton( + text = stringResource(id = R.string.all_cancel), + onClick = onDismiss, + backgroundColor = Gray1, + textColor = Gray4, + contentPadding = ButtonPaddingValues, + ) + }, + ) + } + + is Cancel -> { + DefaultAlertDialog( + title = stringResource(id = R.string.invitation_management_dialog_cancel_title), + onDismissRequest = onDismiss, + confirmButton = { + DefaultTextButton( + text = stringResource(id = R.string.all_confirm), + onClick = state.onConfirm, + backgroundColor = StaccatoBlue, + textColor = White, + contentPadding = ButtonPaddingValues, + ) + }, + dismissButton = { + DefaultTextButton( + text = stringResource(id = R.string.all_cancel), + onClick = onDismiss, + backgroundColor = Gray1, + textColor = Gray4, + contentPadding = ButtonPaddingValues, + ) + }, + ) + } + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationManagement.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationManagement.kt new file mode 100644 index 000000000..1cc451d49 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationManagement.kt @@ -0,0 +1,81 @@ +package com.on.staccato.presentation.invitation.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.on.staccato.presentation.component.DefaultDivider +import com.on.staccato.presentation.invitation.menu.InvitationSelectionMenu +import com.on.staccato.presentation.invitation.model.InvitationTabMenu +import com.on.staccato.presentation.invitation.model.InvitationTabMenu.RECEIVED_INVITATION +import com.on.staccato.presentation.invitation.model.InvitationTabMenu.SENT_INVITATION +import com.on.staccato.presentation.invitation.model.ReceivedInvitationUiModel +import com.on.staccato.presentation.invitation.model.SentInvitationUiModel +import com.on.staccato.presentation.invitation.model.dummyReceivedInvitationUiModels +import com.on.staccato.presentation.invitation.model.dummySentInvitationUiModels +import com.on.staccato.presentation.invitation.received.ReceivedInvitations +import com.on.staccato.presentation.invitation.sent.SentInvitations + +@Composable +fun InvitationManagement( + modifier: Modifier = Modifier, + selectedMenu: InvitationTabMenu, + onMenuClick: (InvitationTabMenu) -> Unit, + receivedInvitations: List, + onRejectClick: (invitationId: Long) -> Unit, + onAcceptClick: (invitationId: Long) -> Unit, + sentInvitations: List, + onCancelClick: (invitationId: Long) -> Unit, +) { + Column(modifier = modifier) { + DefaultDivider() + + InvitationSelectionMenu( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + selectedMenu = selectedMenu, + onClick = onMenuClick, + ) + + when (selectedMenu) { + RECEIVED_INVITATION -> { + ReceivedInvitations( + receivedInvitations = receivedInvitations, + onRejectClick = onRejectClick, + onAcceptClick = onAcceptClick, + ) + } + + SENT_INVITATION -> { + SentInvitations( + sentInvitations = sentInvitations, + onCancelClick = onCancelClick, + ) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFF) +@Composable +private fun InvitationManagementPreview( + @PreviewParameter(InvitationSelectionMenuProvider::class) menu: InvitationTabMenu, +) { + InvitationManagement( + selectedMenu = menu, + onMenuClick = { }, + receivedInvitations = dummyReceivedInvitationUiModels, + onRejectClick = { }, + onAcceptClick = { }, + sentInvitations = dummySentInvitationUiModels, + onCancelClick = { }, + ) +} + +private class InvitationSelectionMenuProvider( + override val values: Sequence = + sequenceOf(RECEIVED_INVITATION, SENT_INVITATION), +) : PreviewParameterProvider diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationManagementTopBar.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationManagementTopBar.kt new file mode 100644 index 000000000..0c304ccd3 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/InvitationManagementTopBar.kt @@ -0,0 +1,14 @@ +package com.on.staccato.presentation.invitation.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import com.on.staccato.presentation.component.topbar.DefaultNavigationTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InvitationManagementTopBar(onNavigationClick: () -> Unit) { + DefaultNavigationTopBar( + title = "카테고리 초대 관리", + onNavigationClick = onNavigationClick, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/NicknameText.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/NicknameText.kt new file mode 100644 index 000000000..383e59c2e --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/NicknameText.kt @@ -0,0 +1,26 @@ +package com.on.staccato.presentation.invitation.component + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import com.on.staccato.theme.Body4 +import com.on.staccato.theme.StaccatoBlack + +@Composable +fun NicknameText( + nickname: String, + modifier: Modifier = Modifier, + style: TextStyle = Body4, + color: Color = StaccatoBlack, +) { + Text( + text = nickname, + modifier = modifier, + style = style, + color = color, + fontWeight = FontWeight.SemiBold, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/ProfileImage.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/ProfileImage.kt new file mode 100644 index 000000000..a92bc1b20 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/component/ProfileImage.kt @@ -0,0 +1,34 @@ +package com.on.staccato.presentation.invitation.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.on.staccato.R +import com.on.staccato.presentation.component.DefaultAsyncImage + +@Composable +fun ProfileImage( + modifier: Modifier = Modifier, + url: String?, +) { + DefaultAsyncImage( + modifier = modifier.clip(shape = CircleShape), + bitmapPixelSize = 150, + url = url, + placeHolder = R.drawable.icon_member, + contentDescription = R.string.all_member_profile_image_description, + ) +} + +@Preview(showBackground = true) +@Composable +private fun ProfileImagePreview() { + Box(modifier = Modifier.padding(10.dp)) { + ProfileImage(url = null) + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/menu/InvitationSelectionMenu.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/menu/InvitationSelectionMenu.kt new file mode 100644 index 000000000..27565a965 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/menu/InvitationSelectionMenu.kt @@ -0,0 +1,66 @@ +package com.on.staccato.presentation.invitation.menu + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.on.staccato.presentation.invitation.model.InvitationTabMenu +import com.on.staccato.theme.Gray1 +import com.on.staccato.theme.StaccatoBlue + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InvitationSelectionMenu( + modifier: Modifier = Modifier, + selectedMenu: InvitationTabMenu, + onClick: (InvitationTabMenu) -> Unit, +) { + PrimaryTabRow( + selectedTabIndex = selectedMenu.menuId, + modifier = + modifier + .background( + color = Gray1, + shape = RoundedCornerShape(7.dp), + ) + .padding(5.dp), + containerColor = Gray1, + contentColor = StaccatoBlue, + indicator = {}, + divider = {}, + ) { + InvitationTabMenu.entries.forEach { menu -> + MenuTab( + menu = menu, + selected = menu.menuId == selectedMenu.menuId, + onClick = { onClick(menu) }, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun InvitationSelectionMenuPreview() { + Column( + modifier = Modifier.padding(10.dp), + ) { + InvitationSelectionMenu( + selectedMenu = InvitationTabMenu.SENT_INVITATION, + onClick = {}, + ) + Box(modifier = Modifier.height(10.dp)) + InvitationSelectionMenu( + selectedMenu = InvitationTabMenu.RECEIVED_INVITATION, + onClick = {}, + ) + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/menu/MenuTab.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/menu/MenuTab.kt new file mode 100644 index 000000000..41024ac76 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/menu/MenuTab.kt @@ -0,0 +1,94 @@ +package com.on.staccato.presentation.invitation.menu + +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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.on.staccato.presentation.component.clickableWithoutRipple +import com.on.staccato.presentation.invitation.model.InvitationTabMenu +import com.on.staccato.theme.Gray1 +import com.on.staccato.theme.Gray3 +import com.on.staccato.theme.StaccatoBlue +import com.on.staccato.theme.Title3 +import com.on.staccato.theme.White + +@Composable +fun MenuTab( + menu: InvitationTabMenu, + selected: Boolean, + onClick: () -> Unit, +) { + val backgroundColor = if (selected) White else Gray1 + val contentColor = if (selected) StaccatoBlue else Gray3 + + Box( + modifier = + Modifier + .fillMaxWidth() + .clickableWithoutRipple(onClick) + .background(color = backgroundColor, shape = RoundedCornerShape(5.dp)) + .padding(1.dp), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(menu.titleId), + style = Title3, + color = contentColor, + ) + Spacer(modifier = Modifier.padding(5.dp)) + Icon( + imageVector = ImageVector.vectorResource(menu.iconResId), + tint = contentColor, + contentDescription = stringResource(menu.iconContentDescriptionId), + ) + } + } +} + +@Preview +@Composable +private fun SelectedMenuTapPreview() { + Column(modifier = Modifier.fillMaxWidth()) { + InvitationTabMenu.entries.forEach { menu -> + MenuTab( + menu = menu, + selected = true, + onClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun UnselectedMenuTapPreview() { + Column(modifier = Modifier.fillMaxWidth()) { + InvitationTabMenu.entries.forEach { menu -> + MenuTab( + menu = menu, + selected = false, + onClick = {}, + ) + } + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/InvitationDialogState.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/InvitationDialogState.kt new file mode 100644 index 000000000..58316efa8 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/InvitationDialogState.kt @@ -0,0 +1,15 @@ +package com.on.staccato.presentation.invitation.model + +sealed class InvitationDialogState { + data object None : InvitationDialogState() + + data class Reject( + val invitationId: Long, + val onConfirm: () -> Unit, + ) : InvitationDialogState() + + data class Cancel( + val invitationId: Long, + val onConfirm: () -> Unit, + ) : InvitationDialogState() +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/InvitationTabMenu.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/InvitationTabMenu.kt new file mode 100644 index 000000000..1d82bf9fd --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/InvitationTabMenu.kt @@ -0,0 +1,25 @@ +package com.on.staccato.presentation.invitation.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.on.staccato.R + +enum class InvitationTabMenu( + val menuId: Int, + @StringRes val titleId: Int, + @DrawableRes val iconResId: Int, + @StringRes val iconContentDescriptionId: Int, +) { + RECEIVED_INVITATION( + menuId = 0, + titleId = R.string.invitation_management_received, + iconResId = R.drawable.icon_receive, + iconContentDescriptionId = R.string.invitation_management_received_description, + ), + SENT_INVITATION( + menuId = 1, + titleId = R.string.invitation_management_sent, + iconResId = R.drawable.icon_send, + iconContentDescriptionId = R.string.invitation_management_sent_description, + ), +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/ReceivedInvitationUiModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/ReceivedInvitationUiModel.kt new file mode 100644 index 000000000..227cca5f2 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/ReceivedInvitationUiModel.kt @@ -0,0 +1,51 @@ +package com.on.staccato.presentation.invitation.model + +data class ReceivedInvitationUiModel( + val invitationId: Long, + val inviterId: Long, + val inviterNickname: String, + val inviterProfileImageUrl: String? = null, + val categoryId: Long, + val categoryTitle: String, +) + +val dummyReceivedInvitationUiModels = + listOf( + ReceivedInvitationUiModel( + invitationId = 0L, + inviterId = 0L, + inviterNickname = "호두", + categoryId = 0L, + categoryTitle = "여름 부산 여행", + ), + ReceivedInvitationUiModel( + invitationId = 1L, + inviterId = 1L, + inviterNickname = "해나", + categoryId = 1L, + categoryTitle = "서울 존맛탱구리집 탐방", + ), + ReceivedInvitationUiModel( + invitationId = 2L, + inviterId = 2L, + inviterNickname = "리니", + categoryId = 2L, + categoryTitle = "클라이밍하러 성수 꼬고~~~~~~", + ), + ReceivedInvitationUiModel( + invitationId = 3L, + inviterId = 3L, + inviterNickname = "빙티", + categoryId = 3L, + categoryTitle = "클라이밍 도전기 클라이밍 도전기 클라이밍 도전기", + ), + ) + +val dummyReceivedInvitationLongTitle = + ReceivedInvitationUiModel( + invitationId = 3L, + inviterId = 3L, + inviterNickname = "유다빈밴드", + categoryId = 3L, + categoryTitle = "이제 마주한 눈에 함께한 하루들이 흘러내린다 언제나 우리기에 돌아볼만한 그런 날들이었다", + ) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/SentInvitationUiModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/SentInvitationUiModel.kt new file mode 100644 index 000000000..94d58b118 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/SentInvitationUiModel.kt @@ -0,0 +1,54 @@ +package com.on.staccato.presentation.invitation.model + +data class SentInvitationUiModel( + val invitationId: Long, + val inviteeId: Long, + val inviteeNickname: String, + val inviteeProfileImageUrl: String? = null, + val categoryId: Long, + val categoryTitle: String, +) + +private val dummySentInvitationUiModel = + SentInvitationUiModel( + invitationId = 0L, + inviteeId = 0L, + inviteeNickname = "초대 받은 사용자", + categoryId = 1L, + categoryTitle = "우당탕탕 스타카토 안드 개발기", + ) + +val dummySentInvitationUiModels = + listOf( + SentInvitationUiModel( + invitationId = 0L, + inviteeId = 0L, + inviteeNickname = "호두", + categoryId = 0L, + categoryTitle = "우아한테크코스", + ), + dummySentInvitationUiModel.copy( + invitationId = 1L, + inviteeId = 0L, + inviteeNickname = "호두", + ), + dummySentInvitationUiModel.copy( + invitationId = 2L, + inviteeId = 1L, + inviteeNickname = "빙티", + ), + dummySentInvitationUiModel.copy( + invitationId = 3L, + inviteeId = 2L, + inviteeNickname = "해나", + ), + ) + +val dummySentInvitationLongTitle = + SentInvitationUiModel( + invitationId = 4L, + inviteeId = 100L, + inviteeNickname = "초대받은사람", + categoryId = 2L, + categoryTitle = "무지무지무지무지무지무지무지무지무지무지 기이이이이인 제목", + ) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/ToastMessage.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/ToastMessage.kt new file mode 100644 index 000000000..3b5c82a80 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/model/ToastMessage.kt @@ -0,0 +1,11 @@ +package com.on.staccato.presentation.invitation.model + +import androidx.annotation.StringRes + +sealed interface ToastMessage { + data class FromResource( + @StringRes val messageId: Int, + ) : ToastMessage + + data class Plain(val errorMessage: String) : ToastMessage +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/ReceivedInvitations.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/ReceivedInvitations.kt new file mode 100644 index 000000000..e646bc120 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/ReceivedInvitations.kt @@ -0,0 +1,59 @@ +package com.on.staccato.presentation.invitation.received + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.on.staccato.R +import com.on.staccato.presentation.component.DefaultDivider +import com.on.staccato.presentation.component.DefaultEmptyView +import com.on.staccato.presentation.invitation.model.ReceivedInvitationUiModel +import com.on.staccato.presentation.invitation.model.dummyReceivedInvitationUiModels +import com.on.staccato.presentation.invitation.received.component.ReceivedInvitationItem + +@Composable +fun ReceivedInvitations( + modifier: Modifier = Modifier, + receivedInvitations: List, + onRejectClick: (invitationId: Long) -> Unit, + onAcceptClick: (invitationId: Long) -> Unit, +) { + if (receivedInvitations.isEmpty()) { + DefaultEmptyView(description = stringResource(id = R.string.invitation_management_received_empty)) + } else { + LazyColumn(modifier = modifier) { + itemsIndexed(receivedInvitations) { index, invitation -> + ReceivedInvitationItem( + categoryInvitation = invitation, + onRejectClick = onRejectClick, + onAcceptClick = onAcceptClick, + ) + if (index != receivedInvitations.lastIndex) { + DefaultDivider() + } + } + } + } +} + +@Preview +@Composable +private fun ReceivedInvitationPreview() { + ReceivedInvitations( + receivedInvitations = dummyReceivedInvitationUiModels, + onAcceptClick = {}, + onRejectClick = {}, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFF) +@Composable +private fun EmptyReceivedInvitationPreview() { + ReceivedInvitations( + receivedInvitations = emptyList(), + onAcceptClick = {}, + onRejectClick = {}, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/AcceptButton.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/AcceptButton.kt new file mode 100644 index 000000000..7afef483c --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/AcceptButton.kt @@ -0,0 +1,35 @@ +package com.on.staccato.presentation.invitation.received.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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 com.on.staccato.R +import com.on.staccato.presentation.component.DefaultTextButton +import com.on.staccato.theme.StaccatoBlue +import com.on.staccato.theme.White + +@Composable +fun AcceptButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + DefaultTextButton( + modifier = modifier, + text = stringResource(id = R.string.invitation_management_accept), + onClick = onClick, + backgroundColor = StaccatoBlue, + textColor = White, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AcceptButtonPreview() { + Box(modifier = Modifier.padding(10.dp)) { + AcceptButton(onClick = {}) + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/ReceivedInvitationItem.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/ReceivedInvitationItem.kt new file mode 100644 index 000000000..44523eec9 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/ReceivedInvitationItem.kt @@ -0,0 +1,135 @@ +package com.on.staccato.presentation.invitation.received.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import com.on.staccato.R +import com.on.staccato.presentation.invitation.component.CategoryTitle +import com.on.staccato.presentation.invitation.component.NicknameText +import com.on.staccato.presentation.invitation.component.ProfileImage +import com.on.staccato.presentation.invitation.model.ReceivedInvitationUiModel +import com.on.staccato.presentation.invitation.model.dummyReceivedInvitationLongTitle +import com.on.staccato.presentation.invitation.model.dummyReceivedInvitationUiModels +import com.on.staccato.theme.Body4 +import com.on.staccato.theme.Gray5 +import com.on.staccato.theme.White + +@Composable +fun ReceivedInvitationItem( + modifier: Modifier = Modifier, + categoryInvitation: ReceivedInvitationUiModel, + onRejectClick: (invitationId: Long) -> Unit, + onAcceptClick: (invitationId: Long) -> Unit, +) { + ConstraintLayout( + modifier = + modifier + .fillMaxWidth() + .background( + color = White, + ), + ) { + val (profileImage, inviterNickname, guideText, categoryTitle, rejectButton, acceptButton) = createRefs() + + ProfileImage( + modifier = + modifier + .size(16.dp) + .constrainAs(profileImage) { + start.linkTo(parent.start, margin = 22.dp) + centerVerticallyTo(inviterNickname) + }, + url = categoryInvitation.inviterProfileImageUrl, + ) + + NicknameText( + nickname = categoryInvitation.inviterNickname, + modifier = + modifier.constrainAs(inviterNickname) { + start.linkTo(profileImage.end, margin = 4.dp) + top.linkTo(parent.top, margin = 16.dp) + }, + ) + + Text( + text = stringResource(id = R.string.invitation_management_guide_text_nickname), + modifier = + modifier.constrainAs(guideText) { + start.linkTo(inviterNickname.end) + centerVerticallyTo(inviterNickname) + width = Dimension.wrapContent + }, + style = Body4, + color = Gray5, + ) + + CategoryTitle( + modifier = + modifier.constrainAs(categoryTitle) { + top.linkTo(profileImage.bottom, margin = 8.dp) + start.linkTo(profileImage.start) + end.linkTo(parent.end, margin = 32.dp) + width = Dimension.fillToConstraints + }, + title = categoryInvitation.categoryTitle, + ) + + RejectButton( + modifier = + modifier.constrainAs(rejectButton) { + bottom.linkTo(parent.bottom, margin = 16.dp) + end.linkTo(acceptButton.start, margin = 4.dp) + }, + onClick = { onRejectClick(categoryInvitation.invitationId) }, + ) + + AcceptButton( + modifier = + modifier.constrainAs(acceptButton) { + top.linkTo(categoryTitle.bottom, margin = 8.dp) + bottom.linkTo(parent.bottom, margin = 16.dp) + end.linkTo(parent.end, margin = 22.dp) + }, + onClick = { onAcceptClick(categoryInvitation.invitationId) }, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0L) +@Composable +private fun ReceivedInvitationItemPreview( + @PreviewParameter( + provider = CategoryPreviewProvider::class, + ) categoryInvitation: ReceivedInvitationUiModel, +) { + ReceivedInvitationItem( + categoryInvitation = categoryInvitation, + onRejectClick = {}, + onAcceptClick = {}, + ) +} + +private class CategoryPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = dummyReceivedInvitationUiModels.asSequence() +} + +@Preview(name = "제목이 긴 경우", showBackground = true, backgroundColor = 0L) +@Composable +private fun LongTitleItemPreview() { + ReceivedInvitationItem( + categoryInvitation = dummyReceivedInvitationLongTitle, + onRejectClick = {}, + onAcceptClick = {}, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/RejectButton.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/RejectButton.kt new file mode 100644 index 000000000..b2a84385d --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/received/component/RejectButton.kt @@ -0,0 +1,38 @@ +package com.on.staccato.presentation.invitation.received.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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 com.on.staccato.R +import com.on.staccato.presentation.component.DefaultTextButton +import com.on.staccato.theme.Gray2 +import com.on.staccato.theme.StaccatoBlack +import com.on.staccato.theme.White + +@Composable +fun RejectButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + DefaultTextButton( + modifier = modifier, + text = stringResource(id = R.string.invitation_management_reject), + onClick = onClick, + backgroundColor = White, + textColor = StaccatoBlack, + border = BorderStroke(width = 0.5.dp, color = Gray2), + ) +} + +@Preview(showBackground = true) +@Composable +private fun RejectButtonPreview() { + Box(modifier = Modifier.padding(10.dp)) { + RejectButton(onClick = {}) + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/SentInvitations.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/SentInvitations.kt new file mode 100644 index 000000000..50ac3bd96 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/SentInvitations.kt @@ -0,0 +1,55 @@ +package com.on.staccato.presentation.invitation.sent + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.on.staccato.R +import com.on.staccato.presentation.component.DefaultDivider +import com.on.staccato.presentation.component.DefaultEmptyView +import com.on.staccato.presentation.invitation.model.SentInvitationUiModel +import com.on.staccato.presentation.invitation.model.dummySentInvitationUiModels +import com.on.staccato.presentation.invitation.sent.component.SentInvitationItem + +@Composable +fun SentInvitations( + sentInvitations: List, + onCancelClick: (invitationId: Long) -> Unit, + modifier: Modifier = Modifier, +) { + if (sentInvitations.isEmpty()) { + DefaultEmptyView(description = stringResource(id = R.string.invitation_management_sent_empty)) + } else { + LazyColumn(modifier = modifier) { + itemsIndexed(sentInvitations) { index, invitation -> + SentInvitationItem( + categoryInvitation = invitation, + onCancelClick = onCancelClick, + ) + if (index != sentInvitations.lastIndex) { + DefaultDivider() + } + } + } + } +} + +@Preview +@Composable +private fun SentInvitationPreview() { + SentInvitations( + sentInvitations = dummySentInvitationUiModels, + onCancelClick = {}, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFF) +@Composable +private fun EmptySentInvitationPreview() { + SentInvitations( + sentInvitations = emptyList(), + onCancelClick = {}, + ) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/component/CancelButton.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/component/CancelButton.kt new file mode 100644 index 000000000..9af5ba319 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/component/CancelButton.kt @@ -0,0 +1,35 @@ +package com.on.staccato.presentation.invitation.sent.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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 com.on.staccato.R +import com.on.staccato.presentation.component.DefaultTextButton +import com.on.staccato.theme.Accents4 +import com.on.staccato.theme.White + +@Composable +fun CancelButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + DefaultTextButton( + modifier = modifier, + text = stringResource(id = R.string.invitation_management_cancel), + onClick = onClick, + backgroundColor = Accents4, + textColor = White, + ) +} + +@Preview(showBackground = true) +@Composable +private fun CancelButtonPreview() { + Box(modifier = Modifier.padding(10.dp)) { + CancelButton {} + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/component/SentInvitationItem.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/component/SentInvitationItem.kt new file mode 100644 index 000000000..543a146b7 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/sent/component/SentInvitationItem.kt @@ -0,0 +1,142 @@ +package com.on.staccato.presentation.invitation.sent.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ChainStyle +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import com.on.staccato.R +import com.on.staccato.presentation.invitation.component.CategoryTitle +import com.on.staccato.presentation.invitation.component.NicknameText +import com.on.staccato.presentation.invitation.component.ProfileImage +import com.on.staccato.presentation.invitation.model.SentInvitationUiModel +import com.on.staccato.presentation.invitation.model.dummySentInvitationLongTitle +import com.on.staccato.presentation.invitation.model.dummySentInvitationUiModels +import com.on.staccato.theme.Body4 +import com.on.staccato.theme.Gray3 +import com.on.staccato.theme.StaccatoBlack +import com.on.staccato.theme.Title3 +import com.on.staccato.theme.White + +@Composable +fun SentInvitationItem( + modifier: Modifier = Modifier, + categoryInvitation: SentInvitationUiModel, + onCancelClick: (invitationId: Long) -> Unit, +) { + ConstraintLayout( + modifier = + modifier + .fillMaxWidth() + .background( + color = White, + ).padding(20.dp), + ) { + val (profileImageRef, nicknameRef, titleRef, suffixRef, cancelButtonRef) = createRefs() + val titleChain = createHorizontalChain(titleRef, suffixRef, chainStyle = ChainStyle.Packed(bias = 0f)) + constrain(titleChain) { + start.linkTo(profileImageRef.end, margin = 10.dp) + end.linkTo(cancelButtonRef.start, margin = 22.dp) + } + + ProfileImage( + modifier = + modifier + .size(40.dp) + .constrainAs(profileImageRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + url = categoryInvitation.inviteeProfileImageUrl, + ) + + NicknameText( + modifier = + modifier.constrainAs(nicknameRef) { + start.linkTo(profileImageRef.end, margin = 10.dp) + top.linkTo(profileImageRef.top) + bottom.linkTo(titleRef.top) + }, + nickname = categoryInvitation.inviteeNickname, + style = Title3, + color = StaccatoBlack, + ) + + CategoryTitle( + modifier = + modifier.constrainAs(titleRef) { + top.linkTo(nicknameRef.bottom) + bottom.linkTo(profileImageRef.bottom) + end.linkTo(suffixRef.start) + width = Dimension.preferredWrapContent + }, + title = categoryInvitation.categoryTitle, + style = Body4, + color = Gray3, + ) + + Text( + modifier = + modifier.constrainAs(suffixRef) { + centerVerticallyTo(titleRef) + start.linkTo(titleRef.end) + width = Dimension.wrapContent + }, + text = stringResource(id = R.string.invitation_management_guide_text_category), + style = Body4, + color = Gray3, + maxLines = 1, + ) + + CancelButton( + modifier = + modifier.constrainAs(cancelButtonRef) { + end.linkTo(parent.end) + centerVerticallyTo(parent) + }, + onClick = { onCancelClick(categoryInvitation.invitationId) }, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 1L) +@Composable +private fun SentInvitationItemPreview( + @PreviewParameter( + provider = SentInvitationPreviewProvider::class, + ) categoryInvitation: SentInvitationUiModel, +) { + Box(modifier = Modifier.padding(10.dp)) { + SentInvitationItem( + categoryInvitation = categoryInvitation, + onCancelClick = {}, + ) + } +} + +private class SentInvitationPreviewProvider( + override val values: Sequence = dummySentInvitationUiModels.asSequence(), +) : PreviewParameterProvider + +@Preview(name = "제목이 긴 경우", showBackground = true, backgroundColor = 1L) +@Composable +private fun LongTitleItemPreview() { + Box(modifier = Modifier.padding(10.dp)) { + SentInvitationItem( + categoryInvitation = dummySentInvitationLongTitle, + onCancelClick = {}, + ) + } +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/viewmodel/InvitationViewModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/viewmodel/InvitationViewModel.kt new file mode 100644 index 000000000..d5aad9389 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/invitation/viewmodel/InvitationViewModel.kt @@ -0,0 +1,151 @@ +package com.on.staccato.presentation.invitation.viewmodel + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.on.staccato.R +import com.on.staccato.data.network.onException2 +import com.on.staccato.data.network.onServerError +import com.on.staccato.data.network.onSuccess +import com.on.staccato.domain.model.invitation.ReceivedInvitation +import com.on.staccato.domain.model.invitation.SentInvitation +import com.on.staccato.domain.repository.InvitationRepository +import com.on.staccato.presentation.invitation.model.InvitationDialogState +import com.on.staccato.presentation.invitation.model.InvitationDialogState.Cancel +import com.on.staccato.presentation.invitation.model.InvitationDialogState.None +import com.on.staccato.presentation.invitation.model.InvitationDialogState.Reject +import com.on.staccato.presentation.invitation.model.ReceivedInvitationUiModel +import com.on.staccato.presentation.invitation.model.SentInvitationUiModel +import com.on.staccato.presentation.invitation.model.ToastMessage +import com.on.staccato.presentation.invitation.model.ToastMessage.FromResource +import com.on.staccato.presentation.invitation.model.ToastMessage.Plain +import com.on.staccato.presentation.mapper.toUiModel +import com.on.staccato.presentation.util.ExceptionState2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class InvitationViewModel + @Inject + constructor( + private val invitationRepository: InvitationRepository, + ) : ViewModel() { + private val _receivedInvitations: MutableStateFlow> = MutableStateFlow(emptyList()) + val receivedInvitations: StateFlow> = _receivedInvitations.asStateFlow() + + private val _sentInvitations: MutableStateFlow> = MutableStateFlow(emptyList()) + val sentInvitations: StateFlow> = _sentInvitations.asStateFlow() + + private val _dialogState: MutableState = mutableStateOf(None) + val dialogState: State = _dialogState + + private val _toastMessage = MutableSharedFlow() + val toastMessage: SharedFlow get() = _toastMessage + + private val _exceptionState = MutableSharedFlow() + val exceptionState: SharedFlow get() = _exceptionState + + init { + fetchReceivedInvitations() + fetchSentInvitations() + } + + fun fetchReceivedInvitations() { + viewModelScope.launch { + val result = invitationRepository.getReceivedInvitations() + result + .onSuccess(::updateReceivedInvitations) + .onServerError { handleServerError(it) } + .onException2 { handelException(it) } + } + } + + fun acceptInvitation(invitationId: Long) { + viewModelScope.launch { + val result = invitationRepository.acceptInvitation(invitationId) + result + .onSuccess { + _toastMessage.emit(FromResource(R.string.invitation_management_accept_success)) + fetchReceivedInvitations() + } + .onServerError { handleServerError(it) } + .onException2 { handelException(it) } + } + } + + fun showRejectDialog(invitationId: Long) { + _dialogState.value = + Reject( + invitationId = invitationId, + onConfirm = { rejectInvitation(invitationId) }, + ) + } + + fun fetchSentInvitations() { + viewModelScope.launch { + val result = invitationRepository.getSentInvitations() + result + .onSuccess(::updateSentInvitations) + .onServerError { handleServerError(it) } + .onException2 { handelException(it) } + } + } + + fun showCancelDialog(invitationId: Long) { + _dialogState.value = + Cancel( + invitationId = invitationId, + onConfirm = { cancelInvitation(invitationId) }, + ) + } + + fun dismissDialog() { + _dialogState.value = None + } + + private fun updateReceivedInvitations(invitations: List) { + _receivedInvitations.value = invitations.map { it.toUiModel() } + } + + private fun rejectInvitation(invitationId: Long) { + dismissDialog() + viewModelScope.launch { + val result = invitationRepository.rejectInvitation(invitationId) + result + .onSuccess { fetchReceivedInvitations() } + .onServerError { handleServerError(it) } + .onException2 { handelException(it) } + } + } + + private fun updateSentInvitations(invitations: List) { + _sentInvitations.value = invitations.map { it.toUiModel() } + } + + private fun cancelInvitation(invitationId: Long) { + dismissDialog() + viewModelScope.launch { + val result = invitationRepository.cancelInvitation(invitationId) + result + .onSuccess { fetchSentInvitations() } + .onServerError { handleServerError(it) } + .onException2 { handelException(it) } + } + } + + private suspend fun handleServerError(message: String) { + _toastMessage.emit(Plain(message)) + } + + private suspend fun handelException(state: ExceptionState2) { + _exceptionState.emit(state) + } + } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mapper/InvitationMapper.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mapper/InvitationMapper.kt new file mode 100644 index 000000000..e9c2cdf82 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mapper/InvitationMapper.kt @@ -0,0 +1,26 @@ +package com.on.staccato.presentation.mapper + +import com.on.staccato.domain.model.invitation.ReceivedInvitation +import com.on.staccato.domain.model.invitation.SentInvitation +import com.on.staccato.presentation.invitation.model.ReceivedInvitationUiModel +import com.on.staccato.presentation.invitation.model.SentInvitationUiModel + +fun ReceivedInvitation.toUiModel(): ReceivedInvitationUiModel = + ReceivedInvitationUiModel( + invitationId = invitationId, + inviterId = inviter.memberId, + inviterNickname = inviter.nickname, + inviterProfileImageUrl = inviter.memberImage, + categoryId = categoryId, + categoryTitle = categoryTitle, + ) + +fun SentInvitation.toUiModel(): SentInvitationUiModel = + SentInvitationUiModel( + invitationId = invitationId, + inviteeId = invitee.memberId, + inviteeNickname = invitee.nickname, + inviteeProfileImageUrl = invitee.memberImage, + categoryId = categoryId, + categoryTitle = categoryTitle, + ) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/MyPageActivity.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/MyPageActivity.kt index 68360740c..c5ff54245 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/MyPageActivity.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/MyPageActivity.kt @@ -7,12 +7,21 @@ import android.net.Uri import android.os.Bundle import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.on.staccato.R import com.on.staccato.databinding.ActivityMypageBinding import com.on.staccato.presentation.base.BindingActivity import com.on.staccato.presentation.common.clipboard.ClipboardHelper import com.on.staccato.presentation.common.photo.PhotoAttachFragment +import com.on.staccato.presentation.component.DefaultDivider +import com.on.staccato.presentation.invitation.InvitationManagementActivity +import com.on.staccato.presentation.mypage.component.MyPageMenuButton import com.on.staccato.presentation.mypage.viewmodel.MyPageViewModel import com.on.staccato.presentation.staccatocreation.OnUrisSelectedListener import com.on.staccato.presentation.util.IMAGE_FORM_DATA_NAME @@ -20,6 +29,7 @@ import com.on.staccato.presentation.util.convertMyPageUriToFile import com.on.staccato.presentation.util.showToast import com.on.staccato.presentation.webview.WebViewActivity import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -36,12 +46,15 @@ class MyPageActivity : lateinit var clipboardHelper: ClipboardHelper override fun initStartView(savedInstanceState: Bundle?) { + setContents() initToolbar() initBindings() loadMemberProfile() observeMemberProfile() observeCopyingUuidCode() observeErrorMessage() + observeException() + fetchNotifications() } override fun onProfileImageChangeClicked() { @@ -77,6 +90,29 @@ class MyPageActivity : } } + private fun setContents() { + setCategoryInvitationManagementButtonContent() + setDividerContent() + } + + private fun setCategoryInvitationManagementButtonContent() { + binding.btnMypageMenuCategoryInvitationManagement.setContent { + val hasNotification by myPageViewModel.hasNotification.collectAsStateWithLifecycle() + + MyPageMenuButton( + menuTitle = getString(R.string.mypage_invitation_management), + onClick = { InvitationManagementActivity.launch(this) }, + hasNotification = hasNotification, + ) + } + } + + private fun setDividerContent() { + binding.dividerMypageMiddle.setContent { + DefaultDivider(thickness = 10.dp) + } + } + private fun initToolbar() { binding.toolbarMypage.setNavigationOnClickListener { finish() @@ -117,6 +153,20 @@ class MyPageActivity : } } + private fun observeException() { + myPageViewModel.exceptionState.observe(this) { state -> + showToast(getString(state.messageId)) + } + } + + private fun fetchNotifications() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + myPageViewModel.fetchNotificationExistence() + } + } + } + companion object { private const val UUID_CODE_LABEL = "uuidCode" private const val PRIVACY_POLICY_URL = diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/component/MyPageMenuButton.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/component/MyPageMenuButton.kt new file mode 100644 index 000000000..59157243c --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/component/MyPageMenuButton.kt @@ -0,0 +1,100 @@ +package com.on.staccato.presentation.mypage.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.on.staccato.R +import com.on.staccato.presentation.component.clickableWithoutRipple +import com.on.staccato.theme.Accents4 +import com.on.staccato.theme.Gray2 +import com.on.staccato.theme.StaccatoBlack +import com.on.staccato.theme.Title3 +import com.on.staccato.theme.White + +private val MenuPaddingValues = + PaddingValues( + top = 18.dp, + bottom = 18.dp, + start = 24.dp, + end = 12.dp, + ) + +@Composable +fun MyPageMenuButton( + menuTitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = MenuPaddingValues, + hasNotification: Boolean = false, +) { + Box( + modifier = + modifier + .background(color = White) + .padding(contentPadding) + .clickableWithoutRipple { onClick() }, + ) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + BadgedBox( + badge = { if (hasNotification) Badge(containerColor = Accents4) }, + ) { + Text( + text = menuTitle, + style = Title3, + color = StaccatoBlack, + ) + } + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.icon_arrow_right), + contentDescription = stringResource(id = R.string.mypage_menu_navigation_icon_description), + modifier = modifier, + tint = Gray2, + ) + } + } +} + +@Preview(name = "마이페이지 메뉴 버튼") +@Composable +private fun MyPageMenuButtonPreview( + @PreviewParameter(provider = MenuTitlePreviewParameterProvider::class) title: String, +) { + MyPageMenuButton(menuTitle = title, onClick = {}) +} + +private class MenuTitlePreviewParameterProvider( + override val values: Sequence = + sequenceOf( + "카테고리 초대 관리", + "개인정보처리방침", + "피드백으로 혼내주기", + ), +) : PreviewParameterProvider + +@Preview(name = "알림이 있는 경우") +@Composable +private fun MyPageMenuButtonWithBadgePreview() { + MyPageMenuButton(menuTitle = "알림이 있는 경우", onClick = {}, hasNotification = true) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/viewmodel/MyPageViewModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/viewmodel/MyPageViewModel.kt index dc17eea87..6c5377695 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/viewmodel/MyPageViewModel.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/mypage/viewmodel/MyPageViewModel.kt @@ -4,16 +4,20 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.on.staccato.data.network.onException +import com.on.staccato.data.network.onException2 import com.on.staccato.data.network.onServerError import com.on.staccato.data.network.onSuccess import com.on.staccato.domain.model.MemberProfile import com.on.staccato.domain.repository.MyPageRepository +import com.on.staccato.domain.repository.NotificationRepository import com.on.staccato.presentation.common.MutableSingleLiveData import com.on.staccato.presentation.common.SingleLiveData import com.on.staccato.presentation.mypage.MemberProfileHandler -import com.on.staccato.presentation.util.ExceptionState +import com.on.staccato.presentation.util.ExceptionState2 import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import okhttp3.MultipartBody import javax.inject.Inject @@ -21,8 +25,10 @@ import javax.inject.Inject @HiltViewModel class MyPageViewModel @Inject - constructor(private val repository: MyPageRepository) : - ViewModel(), MemberProfileHandler { + constructor( + private val myPageRepository: MyPageRepository, + private val notificationRepository: NotificationRepository, + ) : ViewModel(), MemberProfileHandler { private val _memberProfile = MutableLiveData() val memberProfile: LiveData get() = _memberProfile @@ -31,10 +37,17 @@ class MyPageViewModel val uuidCode: SingleLiveData get() = _uuidCode + private val _hasNotification = MutableStateFlow(false) + val hasNotification: StateFlow = _hasNotification.asStateFlow() + private val _errorMessage = MutableSingleLiveData() val errorMessage: SingleLiveData get() = _errorMessage + private val _exceptionState = MutableSingleLiveData() + val exceptionState: SingleLiveData + get() = _exceptionState + override fun onCodeCopyClicked() { val memberProfile = memberProfile.value if (memberProfile != null) { @@ -46,23 +59,30 @@ class MyPageViewModel fun changeProfileImage(multipart: MultipartBody.Part) { viewModelScope.launch { - repository.changeProfileImage(multipart) + myPageRepository.changeProfileImage(multipart) .onSuccess { _memberProfile.value = memberProfile.value?.copy(profileImageUrl = it) - }.onException(::handleException) - .onServerError(::handleError) + }.onServerError(::handleError) + .onException2(::handleException2) } } fun fetchMemberProfile() { viewModelScope.launch { - repository.getMemberProfile() + myPageRepository.getMemberProfile() + .onSuccess { _memberProfile.value = it } .onServerError(::handleError) - .onException(::handleException) - .onSuccess { - _memberProfile.value = it - } + .onException2(::handleException2) + } + } + + fun fetchNotificationExistence() { + viewModelScope.launch { + notificationRepository.getNotificationExistence() + .onSuccess { _hasNotification.value = it.isExist } + .onServerError(::handleError) + .onException2(::handleException2) } } @@ -70,8 +90,8 @@ class MyPageViewModel _errorMessage.postValue(errorMessage) } - private fun handleException(state: ExceptionState) { - _errorMessage.postValue(state.message) + private fun handleException2(state: ExceptionState2) { + _exceptionState.postValue(state) } companion object { diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/util/MessageUtils.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/util/MessageUtils.kt index 87bc011e8..eaa8a4c1b 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/util/MessageUtils.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/util/MessageUtils.kt @@ -25,11 +25,7 @@ fun View.showSnackBarWithAction( onAction: () -> Unit, length: Int, ) { - val snackBar = - Snackbar.make(this, message, length) - .setAction(actionLabel) { - onAction() - } + val snackBar = getSnackBarWithAction(message, actionLabel, onAction, length) snackBar.show() } diff --git a/android/Staccato_AN/app/src/main/res/drawable/icon_receive.xml b/android/Staccato_AN/app/src/main/res/drawable/icon_receive.xml new file mode 100644 index 000000000..a43c0d4c6 --- /dev/null +++ b/android/Staccato_AN/app/src/main/res/drawable/icon_receive.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/android/Staccato_AN/app/src/main/res/drawable/icon_send.xml b/android/Staccato_AN/app/src/main/res/drawable/icon_send.xml new file mode 100644 index 000000000..8877c8b51 --- /dev/null +++ b/android/Staccato_AN/app/src/main/res/drawable/icon_send.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/Staccato_AN/app/src/main/res/layout/activity_mypage.xml b/android/Staccato_AN/app/src/main/res/layout/activity_mypage.xml index f45aabf85..7cc436bff 100644 --- a/android/Staccato_AN/app/src/main/res/layout/activity_mypage.xml +++ b/android/Staccato_AN/app/src/main/res/layout/activity_mypage.xml @@ -55,12 +55,12 @@ android:layout_height="wrap_content" android:layout_marginStart="50dp" android:layout_marginTop="50dp" - android:padding="18dp" android:contentDescription="@string/mypage_update_profile_image_description" android:onClick="@{()->myPageHandler.onProfileImageChangeClicked()}" + android:padding="18dp" android:src="@drawable/icon_edit_circle" - app:layout_constraintTop_toTopOf="@id/iv_mypage_profile_image" - app:layout_constraintStart_toStartOf="@id/iv_mypage_profile_image" /> + app:layout_constraintStart_toStartOf="@id/iv_mypage_profile_image" + app:layout_constraintTop_toTopOf="@id/iv_mypage_profile_image" /> + + + + + app:layout_constraintTop_toBottomOf="@id/divider_mypage_middle" /> + android:title="@string/comment_copy" /> + android:title="@string/comment_delete" /> diff --git a/android/Staccato_AN/app/src/main/res/values-night/colors.xml b/android/Staccato_AN/app/src/main/res/values-night/colors.xml index 3f65ac4b7..fa9dc57f8 100644 --- a/android/Staccato_AN/app/src/main/res/values-night/colors.xml +++ b/android/Staccato_AN/app/src/main/res/values-night/colors.xml @@ -1,20 +1,20 @@ - #FF000000 - #FFFFFFFF - #FFFFFF - #33000000 + + + + - #FF222222 - #CC000000 + + - #DDDDDD - #ABB3FF - #4DABB3FF> + + + - #FF3E3E3E - #FF777777 - #FF949494 - #FFD2D2D2 - #FFF4F4F4 - \ No newline at end of file + + + + + + diff --git a/android/Staccato_AN/app/src/main/res/values-night/themes.xml b/android/Staccato_AN/app/src/main/res/values-night/themes.xml index 43865ea48..f1c2c17a7 100644 --- a/android/Staccato_AN/app/src/main/res/values-night/themes.xml +++ b/android/Staccato_AN/app/src/main/res/values-night/themes.xml @@ -1,10 +1,10 @@ - @@ -12,7 +12,7 @@ + +