diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_arrow_top_right.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_arrow_top_right.xml new file mode 100644 index 00000000..29d6e221 --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_arrow_top_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_crown.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_crown.xml new file mode 100644 index 00000000..b2544386 --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_crown.xml @@ -0,0 +1,12 @@ + + + + diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_speed.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_speed.xml new file mode 100644 index 00000000..cf8b4450 --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_speed.xml @@ -0,0 +1,9 @@ + + + diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_filled.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_filled.xml new file mode 100644 index 00000000..20b8a650 --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_outlined.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_outlined.xml new file mode 100644 index 00000000..d03569a4 --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_outlined.xml @@ -0,0 +1,9 @@ + + + diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_start_circle.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_start_circle.xml new file mode 100644 index 00000000..317351ac --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_start_circle.xml @@ -0,0 +1,13 @@ + + + + diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/FileUploader.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/FileUploader.kt new file mode 100644 index 00000000..6cf475a4 --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/FileUploader.kt @@ -0,0 +1,501 @@ +package com.team.prezel.core.ui.component + +import androidx.annotation.FloatRange +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.feedback.progress.PrezelProgressBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.R +import com.team.prezel.core.ui.util.noRippleClickable +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +@Immutable +sealed interface FileUploaderState { + @Immutable + sealed interface Script : FileUploaderState { + @Immutable + data object Loading : Script + + @Immutable + data object Uploaded : Script + } + + @Immutable + sealed interface Audio : FileUploaderState { + @Immutable + data object Loading : Audio + + @Immutable + data object Paused : Audio + + @Immutable + data object Playing : Audio + } +} + +@Composable +fun FileUploader( + fileName: String, + state: FileUploaderState, + modifier: Modifier = Modifier, + @FloatRange(from = 0.0, to = 1.0) progress: Float = 0f, + currentTimeText: String = "00:00", + durationTimeText: String = "00:00", + onCancelClick: () -> Unit = {}, + onPlayClick: () -> Unit = {}, + onPauseClick: () -> Unit = {}, + onSeek: (Float) -> Unit = {}, +) { + val coercedProgress = progress.coerceIn(0f, 1f) + val playing = state is FileUploaderState.Audio.Playing + val showAudioControl = state is FileUploaderState.Audio && state !is FileUploaderState.Audio.Loading + val isLoading = when (state) { + FileUploaderState.Script.Loading, + FileUploaderState.Audio.Loading, + -> true + FileUploaderState.Script.Uploaded, + FileUploaderState.Audio.Paused, + FileUploaderState.Audio.Playing, + -> false + } + + Row( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 68.dp) + .clip(PrezelTheme.shapes.V8) + .background(color = PrezelTheme.colors.bgRegular) + .border( + width = PrezelTheme.stroke.V1, + color = PrezelTheme.colors.borderRegular, + shape = PrezelTheme.shapes.V8, + ).padding( + horizontal = PrezelTheme.spacing.V16, + vertical = PrezelTheme.spacing.V12, + ), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12), + verticalAlignment = Alignment.CenterVertically, + ) { + FileUploaderContent( + fileName = fileName, + progress = coercedProgress, + currentTimeText = currentTimeText, + durationTimeText = durationTimeText, + showAudioControl = showAudioControl, + isLoading = isLoading, + playing = playing, + onPlayClick = onPlayClick, + onPauseClick = onPauseClick, + onSeek = onSeek, + modifier = Modifier.weight(1f), + ) + + CancelButton(onClick = onCancelClick) + } +} + +@Composable +private fun FileUploaderContent( + fileName: String, + progress: Float, + currentTimeText: String, + durationTimeText: String, + showAudioControl: Boolean, + isLoading: Boolean, + playing: Boolean, + onPlayClick: () -> Unit, + onPauseClick: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + if (showAudioControl) { + AudioFileHeader( + fileName = fileName, + playing = playing, + onPlayClick = onPlayClick, + onPauseClick = onPauseClick, + ) + AudioProgressRow( + currentTimeText = currentTimeText, + durationTimeText = durationTimeText, + progress = progress, + playing = playing, + onSeek = onSeek, + ) + } else { + FileNameText(fileName = fileName) + + if (isLoading) { + UploadProgressRow(progress = progress) + } + } + } +} + +@Composable +private fun FileNameText( + fileName: String, + modifier: Modifier = Modifier, +) { + Text( + text = fileName, + modifier = modifier, + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun UploadProgressRow( + progress: Float, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12), + verticalAlignment = Alignment.CenterVertically, + ) { + val progressDescription = stringResource(R.string.core_ui_file_uploader_progress_desc) + + PrezelProgressBar( + progress = progress, + modifier = Modifier + .weight(1f) + .semantics { + contentDescription = progressDescription + }, + ) + + Text( + text = stringResource( + id = R.string.core_ui_file_uploader_progress_percent, + progress.toPercentValue(), + ), + style = PrezelTheme.typography.caption2Regular, + color = PrezelTheme.colors.textSmall, + ) + } +} + +@Composable +private fun AudioFileHeader( + fileName: String, + playing: Boolean, + onPlayClick: () -> Unit, + onPauseClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(if (playing) PrezelIcons.Pause else PrezelIcons.Play), + contentDescription = stringResource( + if (playing) { + R.string.core_ui_file_uploader_pause_desc + } else { + R.string.core_ui_file_uploader_play_desc + }, + ), + tint = PrezelTheme.colors.iconRegular, + modifier = Modifier + .size(20.dp) + .noRippleClickable(onClick = if (playing) onPauseClick else onPlayClick), + ) + FileNameText(fileName = fileName) + } +} + +@Composable +private fun AudioProgressRow( + currentTimeText: String, + durationTimeText: String, + progress: Float, + playing: Boolean, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12), + verticalAlignment = Alignment.CenterVertically, + ) { + AudioTimeText( + currentTimeText = currentTimeText, + durationTimeText = durationTimeText, + playing = playing, + ) + + AudioSeekBar( + progress = progress, + onSeek = onSeek, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun AudioTimeText( + currentTimeText: String, + durationTimeText: String, + playing: Boolean, + modifier: Modifier = Modifier, +) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = if (playing) PrezelTheme.colors.interactiveRegular else PrezelTheme.colors.textSmall, + ), + ) { + append(currentTimeText) + } + withStyle(style = SpanStyle(color = PrezelTheme.colors.textSmall)) { + append(stringResource(R.string.core_ui_file_uploader_time_separator)) + append(durationTimeText) + } + }, + modifier = modifier, + style = PrezelTheme.typography.caption2Regular, + ) +} + +@Composable +private fun AudioSeekBar( + progress: Float, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + var widthPx by remember { mutableIntStateOf(0) } + val currentOnSeek by rememberUpdatedState(onSeek) + val seekDescription = stringResource(R.string.core_ui_file_uploader_seek_desc) + val coercedProgress = progress.coerceIn(0f, 1f) + + fun seekTo(offsetX: Float) { + if (widthPx > 0) currentOnSeek((offsetX / widthPx).coerceIn(0f, 1f)) + } + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(16.dp) + .onSizeChanged { widthPx = it.width } + .pointerInput(Unit) { + detectTapGestures { offset -> seekTo(offset.x) } + }.pointerInput(Unit) { + detectHorizontalDragGestures { change, _ -> + seekTo(change.position.x) + change.consume() + } + }.semantics { + contentDescription = seekDescription + progressBarRangeInfo = ProgressBarRangeInfo( + current = coercedProgress, + range = 0f..1f, + ) + setProgress { targetProgress -> + currentOnSeek(targetProgress.coerceIn(0f, 1f)) + true + } + }, + contentAlignment = Alignment.CenterStart, + ) { + AudioSeekTrack(progress = coercedProgress) + AudioSeekHandle(progress = coercedProgress) + } +} + +@Composable +private fun AudioSeekTrack(progress: Float) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.bgLarge), + ) + Box( + modifier = Modifier + .fillMaxWidth(progress) + .height(4.dp) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.interactiveRegular), + ) +} + +@Composable +private fun BoxWithConstraintsScope.AudioSeekHandle(progress: Float) { + val handleSize = 12.dp + + Box( + modifier = Modifier + .offset { + val handleRadiusPx = handleSize.toPx() / 2 + val handleX = ((maxWidth.toPx() * progress) - handleRadiusPx).roundToInt() + IntOffset(x = handleX, y = 0) + }.size(handleSize) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.interactiveRegular), + ) +} + +@Composable +private fun CancelButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(PrezelTheme.shapes.V1000) + .noRippleClickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(PrezelIcons.CancelCircleFilled), + contentDescription = stringResource(R.string.core_ui_file_uploader_cancel_desc), + tint = PrezelTheme.colors.iconRegular, + modifier = Modifier.size(24.dp), + ) + } +} + +private fun Float.toPercentValue(): Int = (coerceIn(0f, 1f) * 100).toInt() + +@BasicPreview +@Composable +private fun FileUploaderPreview() { + PrezelTheme { + Column( + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + modifier = Modifier.padding(PrezelTheme.spacing.V16), + ) { + FileUploader( + fileName = "title.txt", + state = FileUploaderState.Script.Loading, + progress = 0.42f, + modifier = Modifier.width(420.dp), + ) + FileUploader( + fileName = "title.txt", + state = FileUploaderState.Script.Uploaded, + modifier = Modifier.width(420.dp), + ) + FileUploader( + fileName = "title.mp3", + state = FileUploaderState.Audio.Paused, + modifier = Modifier.width(420.dp), + ) + FileUploader( + fileName = "title.mp3", + state = FileUploaderState.Audio.Playing, + progress = 0.275f, + currentTimeText = "00:11", + durationTimeText = "00:40", + modifier = Modifier.width(420.dp), + ) + } + } +} + +@BasicPreview +@Composable +private fun FileUploaderInteractivePreview() { + var playing by remember { mutableStateOf(false) } + var elapsedSeconds by remember { mutableIntStateOf(0) } + val durationSeconds = 40 + + LaunchedEffect(playing) { + while (playing && elapsedSeconds < durationSeconds) { + delay(1_000L) + elapsedSeconds = (elapsedSeconds + 1).coerceAtMost(durationSeconds) + } + + if (elapsedSeconds == durationSeconds) { + playing = false + } + } + + PrezelTheme { + Box(modifier = Modifier.padding(PrezelTheme.spacing.V16)) { + FileUploader( + fileName = "title.mp3", + state = if (playing) { + FileUploaderState.Audio.Playing + } else { + FileUploaderState.Audio.Paused + }, + progress = elapsedSeconds.toFloat() / durationSeconds, + currentTimeText = elapsedSeconds.toPreviewTimeText(), + durationTimeText = durationSeconds.toPreviewTimeText(), + onPlayClick = { playing = true }, + onPauseClick = { playing = false }, + onSeek = { seekProgress -> + elapsedSeconds = (durationSeconds * seekProgress).roundToInt() + }, + modifier = Modifier.width(420.dp), + ) + } + } +} + +private fun Int.toPreviewTimeText(): String { + val minutes = this / 60 + val seconds = this % 60 + + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" +} diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index 32b5a672..7618e19f 100644 --- a/Prezel/core/ui/src/main/res/values/strings.xml +++ b/Prezel/core/ui/src/main/res/values/strings.xml @@ -22,4 +22,13 @@ 이전 페이지 다음 페이지 연습한 횟수 + + + 파일 업로더 취소 + 파일 업로더 재생 + 파일 업로더 일시정지 + 파일 업로더 진행률 + 파일 업로더 재생 위치 + %1$02d%% + /