From 262b4b7bb2d6770876f9e3e79c45e81977fb8523 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 19 May 2026 16:11:11 +0900 Subject: [PATCH 1/6] =?UTF-8?q?style:=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 속도(`ic_speed`), 왕관(`ic_crown`), 우상단 화살표(`ic_arrow_top_right`) 아이콘 추가 - 시작 서클(`ic_start_circle`), 별 외곽선(`ic_star_outlined`), 채워진 별(`ic_star_filled`) 아이콘 추가 - `core:designsystem` 모듈의 drawable 리소스 구성 --- .../core_designsystem_ic_arrow_top_right.xml | 9 +++++++++ .../res/drawable/core_designsystem_ic_crown.xml | 12 ++++++++++++ .../res/drawable/core_designsystem_ic_speed.xml | 9 +++++++++ .../drawable/core_designsystem_ic_star_filled.xml | 9 +++++++++ .../drawable/core_designsystem_ic_star_outlined.xml | 9 +++++++++ .../drawable/core_designsystem_ic_start_circle.xml | 13 +++++++++++++ 6 files changed, 61 insertions(+) create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_arrow_top_right.xml create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_crown.xml create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_speed.xml create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_filled.xml create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_star_outlined.xml create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_start_circle.xml 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 @@ + + + + From 97a4b8b8ac1bb83f96a0781c6327eef11ad7ca94 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 19 May 2026 16:38:11 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20FileUploader=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 업로드 상태(LOADING, UPLOADED, PAUSED, PLAYING) 및 타입(SCRIPT, AUDIO) 정의 - 업로드 진행률 표시를 위한 `PrezelProgressBar` 연동 - 오디오 타입일 경우 재생/일시정지 제어 및 탐색(Seek) 바 기능 구현 - `FileUploader` 관련 접근성 설명 및 문자열 리소스 추가 - 디자인 시스템 테마 및 아이콘 적용 내역 반영 --- .../prezel/core/ui/component/FileUploader.kt | 376 ++++++++++++++++++ .../core/ui/src/main/res/values/strings.xml | 9 + 2 files changed, 385 insertions(+) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/FileUploader.kt 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..53073feb --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/FileUploader.kt @@ -0,0 +1,376 @@ +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.clickable +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.getValue +import androidx.compose.runtime.mutableIntStateOf +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.style.TextOverflow +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 kotlin.math.roundToInt + +@Immutable +enum class FileUploaderType { + SCRIPT, + AUDIO, +} + +@Immutable +enum class FileUploaderState { + LOADING, + UPLOADED, + PAUSED, + PLAYING, +} + +@Composable +fun FileUploader( + fileName: String, + type: FileUploaderType, + 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 showLoadingProgress = state == FileUploaderState.LOADING + val showAudioControl = type == FileUploaderType.AUDIO && + (state == FileUploaderState.PAUSED || state == FileUploaderState.PLAYING) + + 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, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + if (showAudioControl) { + AudioFileHeader( + fileName = fileName, + playing = state == FileUploaderState.PLAYING, + onPlayClick = onPlayClick, + onPauseClick = onPauseClick, + ) + AudioProgressRow( + currentTimeText = currentTimeText, + durationTimeText = durationTimeText, + progress = coercedProgress, + onSeek = onSeek, + ) + } else { + FileNameText(fileName = fileName) + + if (showLoadingProgress) { + UploadProgressRow(progress = coercedProgress) + } + } + } + + CancelButton(onClick = onCancelClick) + } +} + +@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) + .clickable(onClick = if (playing) onPauseClick else onPlayClick), + ) + FileNameText(fileName = fileName) + } +} + +@Composable +private fun AudioProgressRow( + currentTimeText: String, + durationTimeText: String, + progress: Float, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource( + id = R.string.core_ui_file_uploader_time_range, + currentTimeText, + durationTimeText, + ), + style = PrezelTheme.typography.caption2Regular, + color = PrezelTheme.colors.textSmall, + ) + + AudioSeekBar( + progress = progress, + onSeek = onSeek, + modifier = Modifier.weight(1f), + ) + } +} + +@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) { + Box( + modifier = Modifier + .offset { + val handleX = ((maxWidth.toPx() - 14.dp.toPx()) * progress).roundToInt() + IntOffset(x = handleX, y = 0) + }.size(12.dp) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.interactiveRegular), + ) +} + +@Composable +private fun CancelButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(PrezelTheme.shapes.V1000) + .clickable(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).roundToInt() + +@BasicPreview +@Composable +private fun FileUploaderPreview() { + PrezelTheme { + Column( + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + modifier = Modifier.padding(PrezelTheme.spacing.V16), + ) { + FileUploader( + fileName = "title.txt", + type = FileUploaderType.SCRIPT, + state = FileUploaderState.LOADING, + progress = 0.42f, + modifier = Modifier.width(420.dp), + ) + FileUploader( + fileName = "title.txt", + type = FileUploaderType.SCRIPT, + state = FileUploaderState.UPLOADED, + modifier = Modifier.width(420.dp), + ) + FileUploader( + fileName = "title.mp3", + type = FileUploaderType.AUDIO, + state = FileUploaderState.PAUSED, + modifier = Modifier.width(420.dp), + ) + FileUploader( + fileName = "title.mp3", + type = FileUploaderType.AUDIO, + state = FileUploaderState.PLAYING, + progress = 0.64f, + modifier = Modifier.width(420.dp), + ) + } + } +} diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index 32b5a672..82fd2ea2 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%% + %1$s/%2$s From 3afc8cacb10c1dd9f17e6adc8329b3d652f31a11 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 19 May 2026 16:44:58 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20FileUploader=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=EC=9E=AC=EC=83=9D=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `FileUploader` 내의 복잡한 조건부 렌더링 로직을 `FileUploaderContent` 컴포넌트로 분리 - 오디오 재생 중일 때 현재 재생 시간의 텍스트 색상을 강조(interactiveRegular)하도록 변경 - `buildAnnotatedString`을 사용하여 현재 시간과 전체 시간을 하나의 `Text` 컴포넌트 내에서 서로 다른 스타일로 표시 - `strings.xml`에서 `time_range` 포맷을 `time_separator`(/)로 변경하여 유연한 텍스트 구성 지원 - `AudioProgressRow` 및 관련 컴포넌트의 파라미터 구조 정리 및 미리보기 데이터 업데이트 --- .../prezel/core/ui/component/FileUploader.kt | 129 +++++++++++++----- .../core/ui/src/main/res/values/strings.xml | 2 +- 2 files changed, 95 insertions(+), 36 deletions(-) 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 index 53073feb..38c4df86 100644 --- 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 @@ -40,7 +40,10 @@ 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 @@ -79,9 +82,7 @@ fun FileUploader( onSeek: (Float) -> Unit = {}, ) { val coercedProgress = progress.coerceIn(0f, 1f) - val showLoadingProgress = state == FileUploaderState.LOADING - val showAudioControl = type == FileUploaderType.AUDIO && - (state == FileUploaderState.PAUSED || state == FileUploaderState.PLAYING) + val playing = state == FileUploaderState.PLAYING Row( modifier = modifier @@ -100,33 +101,66 @@ fun FileUploader( horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12), verticalAlignment = Alignment.CenterVertically, ) { - Column( + FileUploaderContent( + fileName = fileName, + type = type, + state = state, + progress = coercedProgress, + currentTimeText = currentTimeText, + durationTimeText = durationTimeText, + playing = playing, + onPlayClick = onPlayClick, + onPauseClick = onPauseClick, + onSeek = onSeek, modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), - ) { - if (showAudioControl) { - AudioFileHeader( - fileName = fileName, - playing = state == FileUploaderState.PLAYING, - onPlayClick = onPlayClick, - onPauseClick = onPauseClick, - ) - AudioProgressRow( - currentTimeText = currentTimeText, - durationTimeText = durationTimeText, - progress = coercedProgress, - onSeek = onSeek, - ) - } else { - FileNameText(fileName = fileName) + ) - if (showLoadingProgress) { - UploadProgressRow(progress = coercedProgress) - } + CancelButton(onClick = onCancelClick) + } +} + +@Composable +private fun FileUploaderContent( + fileName: String, + type: FileUploaderType, + state: FileUploaderState, + progress: Float, + currentTimeText: String, + durationTimeText: String, + playing: Boolean, + onPlayClick: () -> Unit, + onPauseClick: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + val showAudioControl = type == FileUploaderType.AUDIO && + (state == FileUploaderState.PAUSED || state == FileUploaderState.PLAYING) + + 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 (state == FileUploaderState.LOADING) { + UploadProgressRow(progress = progress) } } - - CancelButton(onClick = onCancelClick) } } @@ -213,6 +247,7 @@ private fun AudioProgressRow( currentTimeText: String, durationTimeText: String, progress: Float, + playing: Boolean, onSeek: (Float) -> Unit, modifier: Modifier = Modifier, ) { @@ -221,14 +256,10 @@ private fun AudioProgressRow( horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource( - id = R.string.core_ui_file_uploader_time_range, - currentTimeText, - durationTimeText, - ), - style = PrezelTheme.typography.caption2Regular, - color = PrezelTheme.colors.textSmall, + AudioTimeText( + currentTimeText = currentTimeText, + durationTimeText = durationTimeText, + playing = playing, ) AudioSeekBar( @@ -239,6 +270,32 @@ private fun AudioProgressRow( } } +@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, @@ -368,7 +425,9 @@ private fun FileUploaderPreview() { fileName = "title.mp3", type = FileUploaderType.AUDIO, state = FileUploaderState.PLAYING, - progress = 0.64f, + progress = 0.275f, + currentTimeText = "00:11", + durationTimeText = "00:40", modifier = Modifier.width(420.dp), ) } diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index 82fd2ea2..7618e19f 100644 --- a/Prezel/core/ui/src/main/res/values/strings.xml +++ b/Prezel/core/ui/src/main/res/values/strings.xml @@ -30,5 +30,5 @@ 파일 업로더 진행률 파일 업로더 재생 위치 %1$02d%% - %1$s/%2$s + / From 460e54fd0289fc17a775cbcfc38bdf215b286984 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 19 May 2026 17:13:48 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20FileUploader=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=83=81=ED=83=9C=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `FileUploaderType` 및 `FileUploaderState`를 `sealed interface` 구조의 `FileUploaderState`로 통합하여 타입 안정성 강화 - `ScriptStatus`(LOADING, UPLOADED)와 `AudioStatus`(LOADING, PAUSED, PLAYING)로 세부 상태 분리 - 오디오 재생 바의 `AudioSeekHandle` 위치가 진행률에 따라 정확하게 중앙에 위치하도록 오프셋 계산 로직 수정 - 클릭 가능한 요소들에 `noRippleClickable`을 적용하여 불필요한 리플 효과 제거 - 대화형 프리뷰(`FileUploaderInteractivePreview`) 추가하여 재생 및 탐색 동작 검증 가능하도록 개선 --- .../prezel/core/ui/component/FileUploader.kt | 115 ++++++++++++++---- 1 file changed, 88 insertions(+), 27 deletions(-) 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 index 38c4df86..5ecf1745 100644 --- 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 @@ -3,7 +3,6 @@ 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.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -23,8 +22,10 @@ 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 @@ -51,18 +52,32 @@ 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 -enum class FileUploaderType { - SCRIPT, - AUDIO, +sealed interface FileUploaderState { + @Immutable + data class Script( + val status: ScriptStatus, + ) : FileUploaderState + + @Immutable + data class Audio( + val status: AudioStatus, + ) : FileUploaderState } @Immutable -enum class FileUploaderState { +enum class ScriptStatus { LOADING, UPLOADED, +} + +@Immutable +enum class AudioStatus { + LOADING, PAUSED, PLAYING, } @@ -70,7 +85,6 @@ enum class FileUploaderState { @Composable fun FileUploader( fileName: String, - type: FileUploaderType, state: FileUploaderState, modifier: Modifier = Modifier, @FloatRange(from = 0.0, to = 1.0) progress: Float = 0f, @@ -82,7 +96,12 @@ fun FileUploader( onSeek: (Float) -> Unit = {}, ) { val coercedProgress = progress.coerceIn(0f, 1f) - val playing = state == FileUploaderState.PLAYING + val playing = state is FileUploaderState.Audio && state.status == AudioStatus.PLAYING + val showAudioControl = state is FileUploaderState.Audio && state.status != AudioStatus.LOADING + val isLoading = when (state) { + is FileUploaderState.Script -> state.status == ScriptStatus.LOADING + is FileUploaderState.Audio -> state.status == AudioStatus.LOADING + } Row( modifier = modifier @@ -103,11 +122,11 @@ fun FileUploader( ) { FileUploaderContent( fileName = fileName, - type = type, - state = state, progress = coercedProgress, currentTimeText = currentTimeText, durationTimeText = durationTimeText, + showAudioControl = showAudioControl, + isLoading = isLoading, playing = playing, onPlayClick = onPlayClick, onPauseClick = onPauseClick, @@ -122,20 +141,17 @@ fun FileUploader( @Composable private fun FileUploaderContent( fileName: String, - type: FileUploaderType, - state: FileUploaderState, progress: Float, currentTimeText: String, durationTimeText: String, + showAudioControl: Boolean, + isLoading: Boolean, playing: Boolean, onPlayClick: () -> Unit, onPauseClick: () -> Unit, onSeek: (Float) -> Unit, modifier: Modifier = Modifier, ) { - val showAudioControl = type == FileUploaderType.AUDIO && - (state == FileUploaderState.PAUSED || state == FileUploaderState.PLAYING) - Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), @@ -157,7 +173,7 @@ private fun FileUploaderContent( } else { FileNameText(fileName = fileName) - if (state == FileUploaderState.LOADING) { + if (isLoading) { UploadProgressRow(progress = progress) } } @@ -236,7 +252,7 @@ private fun AudioFileHeader( tint = PrezelTheme.colors.iconRegular, modifier = Modifier .size(20.dp) - .clickable(onClick = if (playing) onPauseClick else onPlayClick), + .noRippleClickable(onClick = if (playing) onPauseClick else onPlayClick), ) FileNameText(fileName = fileName) } @@ -361,12 +377,15 @@ private fun AudioSeekTrack(progress: Float) { @Composable private fun BoxWithConstraintsScope.AudioSeekHandle(progress: Float) { + val handleSize = 12.dp + Box( modifier = Modifier .offset { - val handleX = ((maxWidth.toPx() - 14.dp.toPx()) * progress).roundToInt() + val handleRadiusPx = handleSize.toPx() / 2 + val handleX = ((maxWidth.toPx() * progress) - handleRadiusPx).roundToInt() IntOffset(x = handleX, y = 0) - }.size(12.dp) + }.size(handleSize) .clip(PrezelTheme.shapes.V1000) .background(PrezelTheme.colors.interactiveRegular), ) @@ -380,7 +399,7 @@ private fun CancelButton( Box( modifier = modifier .clip(PrezelTheme.shapes.V1000) - .clickable(onClick = onClick), + .noRippleClickable(onClick = onClick), contentAlignment = Alignment.Center, ) { Icon( @@ -404,27 +423,23 @@ private fun FileUploaderPreview() { ) { FileUploader( fileName = "title.txt", - type = FileUploaderType.SCRIPT, - state = FileUploaderState.LOADING, + state = FileUploaderState.Script(ScriptStatus.LOADING), progress = 0.42f, modifier = Modifier.width(420.dp), ) FileUploader( fileName = "title.txt", - type = FileUploaderType.SCRIPT, - state = FileUploaderState.UPLOADED, + state = FileUploaderState.Script(ScriptStatus.UPLOADED), modifier = Modifier.width(420.dp), ) FileUploader( fileName = "title.mp3", - type = FileUploaderType.AUDIO, - state = FileUploaderState.PAUSED, + state = FileUploaderState.Audio(AudioStatus.PAUSED), modifier = Modifier.width(420.dp), ) FileUploader( fileName = "title.mp3", - type = FileUploaderType.AUDIO, - state = FileUploaderState.PLAYING, + state = FileUploaderState.Audio(AudioStatus.PLAYING), progress = 0.275f, currentTimeText = "00:11", durationTimeText = "00:40", @@ -433,3 +448,49 @@ private fun FileUploaderPreview() { } } } + +@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 = FileUploaderState.Audio( + status = if (playing) AudioStatus.PLAYING else AudioStatus.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')}" +} From 5bcfbfb71bbc907d0eeba77c9d53c41b775d55d7 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 20 May 2026 13:58:19 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20FileUploaderState=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20sealed=20interface?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileUploaderState 하위 상태를 enum 기반에서 sealed interface 및 data object 기반으로 변경 - FileUploaderState.Script와 FileUploaderState.Audio의 세부 상태를 계층 구조로 재정의 - 더 이상 사용되지 않는 ScriptStatus 및 AudioStatus 열거형 제거 - 상태 구조 변경에 맞춰 FileUploader 컴포저블의 로직 및 프리뷰 코드 업데이트 --- .../prezel/core/ui/component/FileUploader.kt | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) 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 index 5ecf1745..ca9b5c3e 100644 --- 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 @@ -59,27 +59,25 @@ import kotlin.math.roundToInt @Immutable sealed interface FileUploaderState { @Immutable - data class Script( - val status: ScriptStatus, - ) : FileUploaderState + sealed interface Script : FileUploaderState { + @Immutable + data object Loading : Script + + @Immutable + data object Uploaded : Script + } @Immutable - data class Audio( - val status: AudioStatus, - ) : FileUploaderState -} + sealed interface Audio : FileUploaderState { + @Immutable + data object Loading : Audio -@Immutable -enum class ScriptStatus { - LOADING, - UPLOADED, -} + @Immutable + data object Paused : Audio -@Immutable -enum class AudioStatus { - LOADING, - PAUSED, - PLAYING, + @Immutable + data object Playing : Audio + } } @Composable @@ -96,11 +94,14 @@ fun FileUploader( onSeek: (Float) -> Unit = {}, ) { val coercedProgress = progress.coerceIn(0f, 1f) - val playing = state is FileUploaderState.Audio && state.status == AudioStatus.PLAYING - val showAudioControl = state is FileUploaderState.Audio && state.status != AudioStatus.LOADING + val playing = state is FileUploaderState.Audio.Playing + val showAudioControl = state is FileUploaderState.Audio && state !is FileUploaderState.Audio.Loading val isLoading = when (state) { - is FileUploaderState.Script -> state.status == ScriptStatus.LOADING - is FileUploaderState.Audio -> state.status == AudioStatus.LOADING + FileUploaderState.Script.Loading, + FileUploaderState.Audio.Loading -> true + FileUploaderState.Script.Uploaded, + FileUploaderState.Audio.Paused, + FileUploaderState.Audio.Playing -> false } Row( @@ -423,23 +424,23 @@ private fun FileUploaderPreview() { ) { FileUploader( fileName = "title.txt", - state = FileUploaderState.Script(ScriptStatus.LOADING), + state = FileUploaderState.Script.Loading, progress = 0.42f, modifier = Modifier.width(420.dp), ) FileUploader( fileName = "title.txt", - state = FileUploaderState.Script(ScriptStatus.UPLOADED), + state = FileUploaderState.Script.Uploaded, modifier = Modifier.width(420.dp), ) FileUploader( fileName = "title.mp3", - state = FileUploaderState.Audio(AudioStatus.PAUSED), + state = FileUploaderState.Audio.Paused, modifier = Modifier.width(420.dp), ) FileUploader( fileName = "title.mp3", - state = FileUploaderState.Audio(AudioStatus.PLAYING), + state = FileUploaderState.Audio.Playing, progress = 0.275f, currentTimeText = "00:11", durationTimeText = "00:40", @@ -471,9 +472,11 @@ private fun FileUploaderInteractivePreview() { Box(modifier = Modifier.padding(PrezelTheme.spacing.V16)) { FileUploader( fileName = "title.mp3", - state = FileUploaderState.Audio( - status = if (playing) AudioStatus.PLAYING else AudioStatus.PAUSED, - ), + state = if (playing) { + FileUploaderState.Audio.Playing + } else { + FileUploaderState.Audio.Paused + }, progress = elapsedSeconds.toFloat() / durationSeconds, currentTimeText = elapsedSeconds.toPreviewTimeText(), durationTimeText = durationSeconds.toPreviewTimeText(), From 7f465aee315679a97d9dfb8726663a0a20e777d1 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Wed, 20 May 2026 14:28:35 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20FileUploader=20=ED=8D=BC?= =?UTF-8?q?=EC=84=BC=ED=8A=B8=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `toPercentValue` 확장 함수에서 `roundToInt()` 대신 `toInt()`를 사용하여 소수점 버림 처리 - `isLoading` 변수의 `when` 문 분기 처리 로직에 trailing comma를 적용하여 가독성 개선 --- .../com/team/prezel/core/ui/component/FileUploader.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index ca9b5c3e..6cf475a4 100644 --- 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 @@ -98,10 +98,12 @@ fun FileUploader( val showAudioControl = state is FileUploaderState.Audio && state !is FileUploaderState.Audio.Loading val isLoading = when (state) { FileUploaderState.Script.Loading, - FileUploaderState.Audio.Loading -> true + FileUploaderState.Audio.Loading, + -> true FileUploaderState.Script.Uploaded, FileUploaderState.Audio.Paused, - FileUploaderState.Audio.Playing -> false + FileUploaderState.Audio.Playing, + -> false } Row( @@ -412,7 +414,7 @@ private fun CancelButton( } } -private fun Float.toPercentValue(): Int = (coerceIn(0f, 1f) * 100).roundToInt() +private fun Float.toPercentValue(): Int = (coerceIn(0f, 1f) * 100).toInt() @BasicPreview @Composable