Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ private fun AuthorEditorForUpdate(
onNavigateToSelectReceiver = {},
onThumbnailBytesReady = {},
onThumbnailExtractionFailed = {},
onCaptureFailed = {},
onThumbnailUploadErrorConsumed = viewModel::onThumbnailUploadErrorConsumed,
onValidationErrorConsumed = viewModel::onValidationErrorConsumed,
state = state,
Expand Down
8 changes: 8 additions & 0 deletions feature/afternote/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ android {
buildFeatures {
buildConfig = true
}
testOptions {
unitTests {
// Robolectric 이 이 모듈의 *병합된* 매니페스트를 읽게 한다. 끄면 패키지가
// `org.robolectric.default` 로 떨어져 FileProvider authority 가 매니페스트와 어긋난다 (#369).
isIncludeAndroidResources = true
}
}
}

dependencies {
Expand All @@ -33,6 +40,7 @@ dependencies {
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.core.ktx)

testImplementation(libs.coroutines.test)
testImplementation(libs.robolectric)
Expand Down
16 changes: 14 additions & 2 deletions feature/afternote/presentation/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.afternote.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/afternote_file_paths" />
</provider>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package com.afternote.feature.afternote.presentation.author.editor

import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
Expand Down Expand Up @@ -33,7 +29,10 @@ import com.afternote.core.ui.popup.PopupType
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.core.ui.topbar.DetailTopBar
import com.afternote.feature.afternote.presentation.R
import com.afternote.feature.afternote.presentation.author.editor.memorial.MemorialMediaSourceSheet
import com.afternote.feature.afternote.presentation.author.editor.memorial.MemorialMediaTarget
import com.afternote.feature.afternote.presentation.author.editor.memorial.playlist.Song
import com.afternote.feature.afternote.presentation.author.editor.memorial.rememberMemorialMediaSourceState
import com.afternote.feature.afternote.presentation.author.editor.message.EditorMessageTextBlock
import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteEditorState
import com.afternote.feature.afternote.presentation.author.editor.state.CategoryForm
Expand Down Expand Up @@ -70,6 +69,7 @@ fun AfternoteEditorScreen(
onNavigateToSelectReceiver: () -> Unit,
onThumbnailBytesReady: (ByteArray?) -> Unit,
onThumbnailExtractionFailed: (Throwable) -> Unit,
onCaptureFailed: (Throwable) -> Unit,
onThumbnailUploadErrorConsumed: () -> Unit,
onValidationErrorConsumed: () -> Unit,
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -154,14 +154,14 @@ fun AfternoteEditorScreen(
state.setMemorialPlaylistSongs(liveSongs)
}

val memorialPhotoPickerLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
state.setMemorialPhoto(uri?.toString())
}
val memorialVideoPickerLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
state.setMemorialVideo(uri?.toString())
}
// 슬롯을 누르면 곧장 갤러리가 뜨는 대신 "갤러리에서 선택 / 촬영" 시트를 한 단계 끼운다 (#369).
val mediaSourceState =
rememberMemorialMediaSourceState(
snackbarHostState = snackbarHostState,
onPhotoSelected = state.setMemorialPhoto,
onVideoSelected = state.setMemorialVideo,
onCaptureFailed = onCaptureFailed,
)

// 작성 도중 이탈 가드: 진입 시점 스냅샷 대비 변경이 있으면 뒤로가기 시 이탈 확인 팝업을 띄운다.
// 입력이 debounce 로 휘발성 폼 상태에만 반영되어 pop 시 소실되기 때문. '내용 존재'가 아니라 '변경' 기준인
Expand Down Expand Up @@ -231,22 +231,16 @@ fun AfternoteEditorScreen(
isPrefillLoading = isPrefillLoading,
onNavigateToMemorialPlaylist = onNavigateToMemorialPlaylist,
onNavigateToSelectReceiver = onNavigateToSelectReceiver,
onPhotoAddClick = {
memorialPhotoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onVideoAddClick = {
memorialVideoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly),
)
},
onPhotoAddClick = { mediaSourceState.open(MemorialMediaTarget.PHOTO) },
onVideoAddClick = { mediaSourceState.open(MemorialMediaTarget.VIDEO) },
onThumbnailBytesReady = onThumbnailBytesReady,
onThumbnailExtractionFailed = onThumbnailExtractionFailed,
)

AfternoteEditorDialogs(state = state)

MemorialMediaSourceSheet(state = mediaSourceState)

if (showExitConfirm) {
Popup(
type = PopupType.Variant2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,16 @@ class AfternoteEditorViewModel
errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_THUMBNAIL_EXTRACT, throwable)
}

/**
* 즉석 촬영 인텐트를 띄우지 못한 실패를 기록한다.
*
* 화면에는 "카메라를 사용할 수 없습니다" 한 줄만 나가고 사유가 지워지므로, UI 가 예외를
* 넘겨주지 않으면 저장공간 문제인지 카메라 앱 부재인지 콘솔에서 가를 수 없다.
*/
fun onMemorialCaptureLaunchFailed(throwable: Throwable) {
errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_CAPTURE_LAUNCH, throwable)
}

fun saveAfternote(
editingId: Long?,
category: EditorCategory,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.afternote.feature.afternote.presentation.author.editor.memorial

import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File

/**
* 촬영 결과가 떨어질 캐시 하위 디렉터리. `afternote_file_paths.xml` 의 `<cache-path path="captured_media/">`
* 와 같은 값이어야 [FileProvider] 가 URI 를 만들어 준다 — 다르면 `IllegalArgumentException` 으로 터진다.
*/
private const val CAPTURE_DIR = "captured_media"

/** 매니페스트의 `android:authorities="${applicationId}.afternote.fileprovider"` 와 짝. */
private const val AUTHORITY_SUFFIX = ".afternote.fileprovider"

/**
* 카메라 앱이 결과를 써 넣을 빈 파일을 캐시에 만들고, 그 파일을 가리키는 `content://` URI 를 돌려준다.
*
* 카메라 앱은 다른 프로세스라 `file://` 경로를 넘기면 Android 7+ 에서 `FileUriExposedException` 이 난다.
* [FileProvider] 로 감싸 URI 하나에만 쓰기 권한을 위임한다(`grantUriPermission`).
*
* 확장자를 파일명에 박아 두는 이유: 업로드 경로가 `ContentResolver.getType()` 으로 MIME 을 정하는데,
* [FileProvider] 는 그 MIME 을 파일 확장자에서 역산한다. 확장자가 없으면 `application/octet-stream` 이
* 되어 presigned 발급 확장자가 기본값으로 떨어진다.
*/
internal fun createMemorialCaptureUri(
context: Context,
extension: String,
): Uri {
val dir = File(context.cacheDir, CAPTURE_DIR)
dir.mkdirs()
val file = File.createTempFile("capture_", ".$extension", dir)
return FileProvider.getUriForFile(context, context.packageName + AUTHORITY_SUFFIX, file)
}

/**
* 촬영이 취소·실패해 쓰이지 않은 빈 파일을 지운다.
*
* 캐시라 방치해도 OS 가 언젠가 회수하지만, 취소를 반복하면 0바이트 파일이 계속 쌓인다.
* 삭제 실패는 무시한다 — 지우지 못해도 사용자에게 알릴 것이 없다.
*
* "지울 것이 없다" 는 호출부가 아는 사실이라 여기서 받지 않는다 — [uri] 가 non-null 인 덕에
* 이 함수는 "지운다" 하나만 한다.
*/
internal fun discardMemorialCapture(
context: Context,
uri: Uri,
) {
runCatching { context.contentResolver.delete(uri, null, null) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package com.afternote.feature.afternote.presentation.author.editor.memorial

import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.afternote.core.ui.modifierextention.bottomBorder
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.feature.afternote.presentation.R
import com.afternote.core.ui.R as CoreUiR

/** 추억 노트 미디어 슬롯 종류. 시트 문구와 촬영 인텐트를 함께 가른다. */
internal enum class MemorialMediaTarget {
/** 영정 사진 — 갤러리 이미지 선택 / 사진 촬영. */
PHOTO,

/** 장례식에 남길 영상 — 갤러리 영상 선택 / 영상 촬영. */
VIDEO,
}

/**
* 추억 노트 미디어 소스 선택 시트 (#369) — "갤러리에서 선택" / "촬영" 두 갈래.
*
* 이전에는 슬롯을 누르면 곧장 `PickVisualMedia` 가 떠서 *이미 찍어 둔* 사진·영상만 붙일 수 있었다.
* 그 사이에 이 시트를 끼워 즉석 촬영 경로를 연다.
*
* 시각 규격은 수신자 모듈의 `DocumentSourceBottomSheet` 와 같은 패턴을 따른다 — 디자이너가 같은
* "미디어 추가하기" 시트를 다른 화면에 이미 그려 두었고, 이 슬롯용 시안은 따로 없다.
*/
@Composable
internal fun MemorialMediaSourceBottomSheet(
target: MemorialMediaTarget,
onPickFromGallery: () -> Unit,
onCapture: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(8.dp))
Box(
modifier =
Modifier
.size(width = 40.dp, height = 4.dp)
.clip(RoundedCornerShape(10.dp))
.background(AfternoteDesign.colors.gray3),
)
Spacer(Modifier.height(14.dp))
Text(
text = stringResource(R.string.afternote_editor_media_source_header),
style = AfternoteDesign.typography.bodyBase,
color = AfternoteDesign.colors.gray6,
)
Spacer(Modifier.height(18.dp))
MemorialMediaSourceOption(
iconRes = CoreUiR.drawable.core_ui_ic_image,
label = stringResource(R.string.afternote_editor_media_source_gallery),
onClick = onPickFromGallery,
)
MemorialMediaSourceOption(
iconRes =
when (target) {
MemorialMediaTarget.PHOTO -> R.drawable.feature_afternote_ic_camera
MemorialMediaTarget.VIDEO -> R.drawable.feature_afternote_ic_videocam
},
label =
when (target) {
MemorialMediaTarget.PHOTO -> stringResource(R.string.afternote_editor_media_source_take_photo)
MemorialMediaTarget.VIDEO -> stringResource(R.string.afternote_editor_media_source_take_video)
},
onClick = onCapture,
)
Spacer(Modifier.height(32.dp))
}
}

@Composable
private fun MemorialMediaSourceOption(
@DrawableRes iconRes: Int,
label: String,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.bottomBorder(color = AfternoteDesign.colors.gray3, width = 1.dp)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = AfternoteDesign.colors.iconBk,
modifier = Modifier.size(24.dp),
)
Text(
text = label,
style = AfternoteDesign.typography.bodyBase,
color = AfternoteDesign.colors.gray9,
)
}
}

@Preview(showBackground = true)
@Composable
private fun MemorialMediaSourceBottomSheetPhotoPreview() {
AfternoteTheme {
MemorialMediaSourceBottomSheet(
target = MemorialMediaTarget.PHOTO,
onPickFromGallery = {},
onCapture = {},
)
}
}

@Preview(showBackground = true)
@Composable
private fun MemorialMediaSourceBottomSheetVideoPreview() {
AfternoteTheme {
MemorialMediaSourceBottomSheet(
target = MemorialMediaTarget.VIDEO,
onPickFromGallery = {},
onCapture = {},
)
}
}
Loading
Loading