diff --git a/.cursor/plans/mind-record-detail-api.plan.md b/.cursor/plans/mind-record-detail-api.plan.md deleted file mode 100644 index 57e91888..00000000 --- a/.cursor/plans/mind-record-detail-api.plan.md +++ /dev/null @@ -1,33 +0,0 @@ -# MindRecordDetailScreen 리스트를 API mindRecords로 채우기 (수정) - -## API 스펙: 기존 그대로 사용 - -**DTO/Entity 변경 없음.** [ReceivedApiDto.kt](app/src/main/java/com/kuit/afternote/feature/receiver/data/dto/ReceivedApiDto.kt)와 [ReceivedMindRecord](app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedMindRecord.kt)를 그대로 사용한다. - -- `ReceivedMindRecordResponseDto`: mindRecordId, sourceType, content, recordDate -- `ReceivedMindRecord`: mindRecordId, sourceType, content, recordDate -- [ReceivedMapper](app/src/main/java/com/kuit/afternote/feature/receiver/data/mapper/ReceivedMapper.kt) 기존 매핑 유지 - ---- - -## UI 매핑 (Entity → ExpandableRecordItem) - -| ExpandableRecordItem | ReceivedMindRecord | -|---------------------|--------------------| -| date | recordDate 포맷 ("yyyy년 M월 d일") | -| tags | sourceType 또는 빈 문자열 | -| question | sourceType 또는 빈 문자열 | -| content | content | -| hasImage | false (API에 필드 없음) | - ---- - -## 나머지 구현 요약 - -1. **receiverId 전달**: [ReceiverMainRoute](app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverMainRoute.kt)에서 RECORD 탭에 `receiverId` 전달. MindRecordDetailScreen이 노출되는 경로(탭 내부 vs 별도 라우트)에 맞춰 `receiverId` 인자 추가. - -2. **UiModel**: ExpandableRecordItem 한 건 분량 (date, tags, question, content, hasImage). Entity → UiModel 변환은 ViewModel 또는 Mapper 레이어에서 수행. - -3. **MindRecordDetailViewModel**: receiverId 수신, GetReceivedMindRecordsUseCase(receiverId) 호출, `recordDate == selectedDate`(문자열 비교 또는 파싱)로 필터, UiModel 리스트로 매핑 후 UiState 노출. selectedDate 변경 시 재필터 또는 재요청. - -4. **MindRecordDetailScreen**: ViewModel 연동, LazyColumn을 `uiState.mindRecordItems`로 채우고 하드코딩된 ExpandableRecordItem 2개 제거. Preview는 빈 리스트 또는 샘플 UiModel. diff --git a/.cursor/plans/receiver-home-download-all.plan.md b/.cursor/plans/receiver-home-download-all.plan.md deleted file mode 100644 index 928bb149..00000000 --- a/.cursor/plans/receiver-home-download-all.plan.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -name: "" -overview: "" -todos: [] -isProject: false ---- - -# Receiver HOME: 탭 이동 + 모든 기록 내려받기 (구현 계획) - -## 목표 - -1. **HOME 탭 ContentSection 버튼:** 마음의 기록 / 타임레터 / 애프터노트 섹션의 버튼을 누르면 해당 탭(화면)으로 전환. -2. **모든 기록 내려받기:** 다이얼로그 "예" 선택 시 receiverId로 타임레터·마인드레코드·애프터노트 세 API를 호출하여 그 발신자의 기록을 모두 받음. - -## 전제 - -- 수신자별로 진입하면 **마스터키로 발신자가 1:1 매칭**되어 있음. receiverId로 API를 호출하면 **그 발신자의 내용만** 내려옴. senderId 필드·필터링 없이 receiverId만 사용. -- **DTO는 스웨거 명세 유지.** 변경 없음. - ---- - -## Part 1: ContentSection 버튼 → 탭 전환 - -### 1.1 ContentSection - -- **파일:** [ContentSection.kt](app/src/main/java/com/kuit/afternote/feature/receiver/presentation/component/ContentSection.kt) -- **변경:** 파라미터 `onButtonClick: () -> Unit = {}` 추가. `Button(onClick = { })` → `Button(onClick = onButtonClick)`. - -### 1.2 ReceiverAfternoteScreen - -- **파일:** [ReceiverAfternoteScreen.kt](app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/ReceiverAfternoteScreen.kt) -- **변경:** - - 파라미터 추가 (기본값 `{}`로 Preview 호환): - `onNavigateToRecord`, `onNavigateToTimeLetter`, `onNavigateToAfternote` (각 `() -> Unit`). - - 세 개 ContentSection에 각각 `onButtonClick = onNavigateToRecord` / `onNavigateToTimeLetter` / `onNavigateToAfternote` 전달. - -### 1.3 ReceiverMainRoute - -- **파일:** [ReceiverMainRoute.kt](app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverMainRoute.kt) -- **변경:** -`ReceiverAfterNoteScreen(showBottomBar = false)` 호출을 다음으로 교체: - - `onNavigateToRecord = { selectedBottomNavItem = BottomNavItem.RECORD }` - - `onNavigateToTimeLetter = { selectedBottomNavItem = BottomNavItem.TIME_LETTER }` - - `onNavigateToAfternote = { selectedBottomNavItem = BottomNavItem.AFTERNOTE }` - ---- - -## Part 2: receiverId 전달 - -- **ReceiverMainRoute:** 이미 `receiverId: String` 보유. -`ReceiverAfterNoteScreen(..., receiverId = receiverId)` 인자 추가. -- **ReceiverAfternoteScreen:** `receiverId: String = "1"` 파라미터 추가 (Preview용 기본값). - ---- - -## Part 3: 모든 기록 내려받기 (Domain · Data) - -### 3.1 도메인 엔티티: ReceivedMindRecord - -- **파일 (신규):** `feature/receiver/domain/entity/ReceivedMindRecord.kt` -- **내용:** [ReceivedMindRecordResponseDto](app/src/main/java/com/kuit/afternote/feature/receiver/data/dto/ReceivedApiDto.kt) 필드에 맞춘 data class. - - `mindRecordId: Long`, `sourceType: String?`, `content: String?`, `recordDate: String?` - -### 3.2 도메인 Repository: ReceivedMindRecordRepository - -- **파일 (신규):** `feature/receiver/domain/repository/iface/ReceivedMindRecordRepository.kt` -- **내용:** -`suspend fun getReceivedMindRecords(receiverId: Long): Result>` - -### 3.3 Data: ReceivedMindRecordRepositoryImpl + Mapper - -- **파일 (신규):** `feature/receiver/data/repository/impl/ReceivedMindRecordRepositoryImpl.kt` - - [ReceivedRepository](app/src/main/java/com/kuit/afternote/feature/receiver/data/repository/iface/ReceivedRepository.kt)의 `getReceivedMindRecords(receiverId)` 호출 후, DTO 리스트를 Mapper로 엔티티로 변환해 반환. (기존 [ReceivedTimeLetterRepositoryImpl](app/src/main/java/com/kuit/afternote/feature/receiver/data/repository/impl/ReceivedTimeLetterRepositoryImpl.kt) 패턴과 동일.) -- **ReceivedMapper:** `toReceivedMindRecord(dto: ReceivedMindRecordResponseDto): ReceivedMindRecord` 함수 추가. - -### 3.4 UseCase: GetReceivedMindRecordsUseCase - -- **파일 (신규):** `feature/receiver/domain/usecase/GetReceivedMindRecordsUseCase.kt` -- **내용:** [GetReceivedTimeLettersUseCase](app/src/main/java/com/kuit/afternote/feature/receiver/domain/usecase/GetReceivedTimeLettersUseCase.kt)와 동일 패턴. -`ReceivedMindRecordRepository.getReceivedMindRecords(receiverId)` 호출 후 그대로 반환. - -### 3.5 UseCase: DownloadAllReceivedUseCase - -- **파일 (신규):** `feature/receiver/domain/usecase/DownloadAllReceivedUseCase.kt` -- **의존성:** GetReceivedTimeLettersUseCase, GetReceivedMindRecordsUseCase, GetReceivedAfterNotesUseCase 주입. -- **반환 타입:** 세 결과를 묶은 data class (예: `data class DownloadAllResult(val timeLetters: List, val mindRecords: List, val afternotes: List)`). -- **동작:** `invoke(receiverId: Long)`: 세 UseCase를 순차 호출. 셋 다 성공 시 `Result.success(DownloadAllResult(...))`, 하나라도 실패 시 `Result.failure`. - -### 3.6 Hilt: ReceiverModule - -- **파일:** [ReceiverModule.kt](app/src/main/java/com/kuit/afternote/feature/receiver/data/di/ReceiverModule.kt) -- **변경:** -`ReceivedMindRecordRepository` 바인딩 추가: -`bindReceivedMindRecordRepository(impl: ReceivedMindRecordRepositoryImpl): ReceivedMindRecordRepository` - ---- - -## Part 4: ViewModel · UI - -### 4.1 ViewModel: ReceiverDownloadAllViewModel - -- **파일 (신규):** `feature/receiver/presentation/viewmodel/ReceiverDownloadAllViewModel.kt` -- **의존성:** DownloadAllReceivedUseCase. -- **receiverId:** 생성자로 받지 않고, **메서드 인자로 받음.** -`fun confirmDownloadAll(receiverId: Long)` → UseCase(receiverId) 호출. (Screen에서 `receiverId`를 인자로 넘기면 되므로 SavedStateHandle/Assisted 불필요.) -- **State:** `UiState(isLoading, errorMessage, downloadSuccess 등).` -`confirmDownloadAll` 호출 시 isLoading = true → 성공 시 downloadSuccess 처리 후 다이얼로그 닫기, 실패 시 errorMessage 설정. - -### 4.2 ReceiverAfternoteScreen 연동 - -- **ReceiverAfternoteScreen**에서 `ReceiverDownloadAllViewModel`을 `hiltViewModel()`으로 주입. -- `uiState`를 `collectAsStateWithLifecycle()`로 구독. -- 다이얼로그 "예" 버튼: `viewModel.confirmDownloadAll(receiverId.toLongOrNull() ?: return@Button)` 호출 후 `showDialog = false` (또는 성공 콜백에서 닫기). -- 로딩 중: 다이얼로그에 로딩 표시 또는 버튼 비활성화. -- `errorMessage`가 있으면 토스트/스낵바로 표시. - ---- - -## 구현 순서 (체크리스트) - - -| # | 작업 | | -| --- | -------------------------------------------------------------------------------------------------- | --- | -| 1 | ContentSection에 `onButtonClick` 추가 | | -| 2 | ReceiverAfternoteScreen에 탭 전환 콜백 3개 추가 및 ContentSection에 연결 | | -| 3 | ReceiverMainRoute에서 HOME 호출 시 위 콜백 + receiverId 전달 | | -| 4 | ReceivedMindRecord 엔티티, ReceivedMindRecordRepository 인터페이스, ReceivedMapper.toReceivedMindRecord 추가 | | -| 5 | ReceivedMindRecordRepositoryImpl 구현, ReceiverModule에 바인딩 | | -| 6 | GetReceivedMindRecordsUseCase 신설 | | -| 7 | DownloadAllReceivedUseCase 신설 (DownloadAllResult data class 포함) | | -| 8 | ReceiverDownloadAllViewModel 신설, UiState 정의 | | -| 9 | ReceiverAfternoteScreen에서 ViewModel 주입, 다이얼로그 "예"에서 confirmDownloadAll(receiverId) 호출, 로딩·에러 UI 처리 | | - - ---- - -## 참고: 기존 구조 - -- 타임레터: [ReceivedTimeLetterRepository](app/src/main/java/com/kuit/afternote/feature/receiver/domain/repository/iface/ReceivedTimeLetterRepository.kt) → ReceivedTimeLetterRepositoryImpl → GetReceivedTimeLettersUseCase. -- 애프터노트: ReceivedAfternoteRepository → ReceivedAfternoteRepositoryImpl → GetReceivedAfterNotesUseCase. -- 마인드레코드: Data의 [ReceivedRepository.getReceivedMindRecords](app/src/main/java/com/kuit/afternote/feature/receiver/data/repository/iface/ReceivedRepository.kt)만 존재. Domain Repository + UseCase는 이번에 신설. - diff --git a/.cursor/plans/wheel-picker-calendar-sync.plan.md b/.cursor/plans/wheel-picker-calendar-sync.plan.md deleted file mode 100644 index 7b9d2b77..00000000 --- a/.cursor/plans/wheel-picker-calendar-sync.plan.md +++ /dev/null @@ -1,31 +0,0 @@ -# 휠 피커 선택 날짜와 캘린더 동기화 (수정) - -## 추가 요구사항: 연도 2026년부터 표시 - -휠 데이트 피커의 **연도 범위**를 **2026년부터 그 이후**만 보이도록 변경한다. - -### 변경 위치 - -**파일:** [ReceiverWheelDatePickerDialog.kt](app/src/main/java/com/kuit/afternote/feature/receiver/presentation/component/ReceiverWheelDatePickerDialog.kt) - -- **현재:** `YEAR_RANGE_PAST = 2`, `YEAR_RANGE_FUTURE = 2` → `years = (currentYear - 2 .. currentYear + 2).toList()` (예: 2023~2027). -- **변경:** 연도 리스트를 **2026년부터** 시작하도록 수정. - - 예: `val minYear = 2026`, `val years = remember { (minYear..minYear + 10).toList() }` 또는 `(2026..2036).toList()` 등으로 2026 이후만 노출. - - `initialDate`가 2026 미만이면 인덱스는 0(2026)으로 보정해 두기. - -### 구체적 수정 - -1. `YEAR_RANGE_PAST` / `YEAR_RANGE_FUTURE` 제거 또는 무시. -2. 상수 추가: `private const val WHEEL_PICKER_MIN_YEAR = 2026`, (선택) `private const val WHEEL_PICKER_YEAR_COUNT = 11`. -3. `years` 계산: `(WHEEL_PICKER_MIN_YEAR until WHEEL_PICKER_MIN_YEAR + WHEEL_PICKER_YEAR_COUNT).toList()` 또는 `(2026..2036).toList()`. -4. `yearIndex` 계산 시 `initialDate.year`가 2026 미만이면 `years.indexOf(2026).coerceIn(0, years.lastIndex)` 또는 0으로 설정. - ---- - -## 기존 플랜 요약: 휠 ↔ 캘린더 동기화 - -1. **CalendarGrid**: `displayYearMonth: YearMonth` 추가, 해당 월 일수·첫날 오프셋으로 그리드 표시. -2. **ReceiverDailyRecordScreen**: `selectedDate`를 `LocalDate`로, 휠 `onConfirm`으로 갱신, 버튼/캘린더에 반영. -3. **strings.xml**: "yyyy년 M월" 포맷 리소스 추가. - -위 연도 범위 변경은 **ReceiverWheelDatePickerDialog**만 수정하면 되며, 캘린더/화면 연동 플랜과 함께 적용하면 된다. diff --git a/.cursor/rules/quality/code-quality.mdc b/.cursor/rules/quality/code-quality.mdc deleted file mode 100644 index 9dc8a918..00000000 --- a/.cursor/rules/quality/code-quality.mdc +++ /dev/null @@ -1,223 +0,0 @@ ---- -description: Code quality, formatting, and test rules -globs: **/*.kt -alwaysApply: true ---- - -# Code Quality & Formatting (코드 품질) - -## Role & Goal -You are a Senior Android Developer with strict adherence to Kotlin coding standards and maintainable code. -**Goal**: Generate clean, readable Kotlin/Compose code and address issues when they are reported. - -## Critical Instructions for Code Generation - -### 1. Context & Config Awareness -- **Before generating code, briefly scan `.editorconfig` in the project root** if it exists to understand custom indentations or max line lengths. -- If no config is found, default to **Standard Kotlin Style Guide**. - -### 2. Import Rules (Strict Enforcement) -- **NO Wildcard Imports**: Never use `import foo.*`. Always import individual classes. -- **Import Order**: Imports must be ordered in lexicographic order without any empty lines in-between with "java", "javax", "kotlin" and aliases in the end. - -### 4. Complexity & Smells - Critical - -#### Function Complexity -- **Function Length**: Keep functions short and focused (aim for < 20 lines). Break down complex logic into helper functions. -- **Max Line Length**: Limit lines to 120 characters (or 100 depending on your team's rule). Wrap long chained calls or parameters. -- **Long Parameter List**: Functions and constructors must have **at most 7 parameters** (Sonar S107 / kotlin:S107). Use data classes or params objects to group arguments when you would exceed 7. - - **⚠️ Critical: @Suppress는 최후의 수단** - - **절대 먼저 시도해야 할 것**: `@Suppress`를 사용하기 전에 반드시 코드 구조 개선을 시도해야 합니다. - - **State Hoisting 패턴 적용 시 파라미터가 많아지는 경우**: - * **콜백 그룹화**: 3개 이상의 콜백이 있으면 Event Interface나 data class로 묶기 (예: `RecipientCallbacks`, `ProcessingMethodCallbacks`) - * **UI 상태 캡슐화**: `expanded`, `boxWidth` 같은 UI 상태는 State Class로 묶기 (예: `SelectionDropdownState`) - * **초기값 처리**: `initialShowTextField`, `initialExpandedItemId` 같은 초기값은 State 생성자로 옮기기 - - **TooManyFunctions 경고 발생 시**: - * **불필요한 함수 제거**: 사용되지 않는 헬퍼 함수나 중복 함수 제거 - * **책임 분리**: Manager 클래스로 로직 위임 고려 (예: `RecipientManager`, `CategoryManager`) - * **Private 함수 활용**: 외부에 노출될 필요 없는 함수는 `private`으로 변경 (detekt 설정에서 `ignorePrivate: true`인 경우 카운트 제외) - - **Use `@Suppress` only when absolutely necessary and document why**: 구조적 개선이 불가능할 때(라이브러리 제약 등)만 사용하고, 단순히 "편해서" 사용하지 마세요. - -#### Code Smells -- **Magic Numbers**: Avoid hardcoded numbers. Extract them to `const val` or strict named constants, except for obvious values like 0, 1, or -1. - - 예: `val timeout = 5000` ❌ → `const val TIMEOUT_MS = 5000` ✅ -- **Nested Blocks**: Avoid deep nesting (max depth: 4). Use guard clauses (`return` early) to flatten `if-else` structures. -- **Empty Blocks**: Never leave `catch` or `if` blocks empty. Add a comment explaining why it's empty or handle the exception properly. -- **Top-Level Declarations**: Group constants, extension functions, and classes logically. - -#### Static Analysis Rules -- 프로젝트의 정적 분석 설정(예: `config/detekt.yml`) 및 IDE 규칙(Sonar S107)을 준수합니다. - - **함수·생성자 매개변수**: 최대 **7개**. 초과 시 Params 데이터 클래스로 묶어 전달합니다 (S107 준수로 IDE 경고 방지). - - `Magic Number` 사용을 지양하고 상수로 정의합니다. - - 함수 복잡도 제한을 준수합니다. -- **⚠️ Critical: @Suppress 사용 전 코드 구조 개선 필수** - - **경험 사례**: State Hoisting 패턴 적용 후 `LongParameterList`와 `TooManyFunctions` 경고가 발생했을 때, `@Suppress`를 사용하는 대신 코드 구조를 개선하여 해결했습니다. - - **LongParameterList 해결 방법**: - * 콜백 함수들을 Event Interface로 그룹화 (예: `RecipientCallbacks`) - * UI 상태를 State Class로 캡슐화 (예: `SelectionDropdownState`) - * 초기값 파라미터를 State 생성자로 이동 - - **TooManyFunctions 해결 방법**: - * 불필요한 함수 제거 (사용되지 않는 헬퍼 함수) - * 책임 분리 (Manager 클래스로 로직 위임) - * Private 함수 활용 (외부 노출 불필요한 함수는 `private`으로 변경) - - **원칙**: `@Suppress`는 구조적 개선이 불가능할 때만 사용하고, 항상 먼저 코드 구조 개선을 시도해야 합니다. - -## Output Format -- Provide clean, production-ready Kotlin code. -- Do not include comments like "// formatting fix" unless necessary for explanation. -- Avoid obvious formatting and static-analysis issues. - -### PR/CI 준수 (Commitlint) -- **PR이 merge되려면** PR **제목**이 commitlint(Conventional Commits) 규칙을 만족해야 합니다. - -## Testing Rules - -### Testing Strategy (테스트 전략) - Critical - -프로젝트는 **테스트 피라미드** 원칙을 따릅니다. 각 테스트 유형은 고유한 목적이 있으며, 중복을 피해야 합니다. - -| 테스트 유형 | 목적 | 도구 | 실행 시점 | -|------------|------|------|----------| -| **Unit Tests (MockK)** | ViewModel/UseCase 로직 검증 | MockK | 모든 PR, CI/CD | -| **Integration Tests** | 실제 API 응답 검증 | Retrofit + Real Server | 주기적 (야간/릴리스 전) | -| **UI Tests** | UI 흐름 검증 | Espresso/Compose | 릴리스 전 | - -#### 테스트 유형별 규칙 - -1. **Unit Tests (MockK) - 필수** - - ViewModel, UseCase의 모든 로직을 테스트합니다. - - 의존성은 MockK로 모킹합니다 (`coEvery`, `coVerify`). - - 네트워크 없이 빠르게 실행 가능해야 합니다. - - **모든 PR에서 실행**합니다. - -2. **Integration Tests - 선택적** - - 실제 API 서버를 호출하여 응답을 검증합니다. - - API 계약(Contract) 변경을 감지합니다. - - **주기적으로 실행** (야간 빌드, 릴리스 전). - - 모든 PR에서 실행하지 않습니다 (네트워크 의존성, 느림). - -3. **Mock API Tests - 삭제 또는 UI 테스트로 전환** - - ❌ **금지**: "Input → API → Output" 형태의 Mock API 테스트는 Unit Tests와 중복이므로 작성하지 않습니다. - - UI 테스트는 Espresso/Compose로 UI 흐름만 검증합니다 (예: "LoginScreen에서 401 반환 시 에러 다이얼로그 표시"). - -### Unit Test -- `Domain Layer`(`UseCase`)와 `ViewModel`은 100%에 가까운 테스트 커버리지를 지향합니다. -- `MockK`를 사용하여 의존성을 모킹합니다. - -### Naming -- 테스트 함수명은 행위를 명확히 기술합니다 (예: `fetchUser_whenSuccess_returnsUser`). - -### Test Code Verification -코드를 생성하거나 수정할 때마다 **반드시** 다음을 확인하고 필요하면 테스트 코드를 추가해야 합니다: - -#### UseCase 추가/수정 시 -- **위치**: `app/src/test/java/com/kuit/afternote/feature/{feature_name}/domain/usecase/` -- **파일명**: `{UseCaseName}Test.kt` -- **내용**: UseCase의 모든 메서드에 대한 단위 테스트 작성 - -#### ViewModel 추가/수정 시 -- **위치**: `app/src/test/java/com/kuit/afternote/feature/{feature_name}/presentation/viewmodel/` -- **파일명**: `{ViewModelName}Test.kt` -- **내용**: ViewModel의 상태 변경, 이벤트 처리에 대한 단위 테스트 작성 - -### ⚠️ Critical: HTTP Status Code Testing (API 테스트 필수 규칙) - -**API를 사용하는 ViewModel은 반드시 HTTP 상태 코드별 테스트를 포함해야 합니다.** - -#### 필수 테스트 케이스 - -| HTTP Status | 테스트 함수명 예시 | 설명 | -|-------------|-------------------|------| -| **400 Bad Request** | `login_when400BadRequest_setsErrorMessage` | 잘못된 요청 형식 | -| **401 Unauthorized** | `login_when401Unauthorized_setsErrorMessage` | 인증 실패 (잘못된 자격 증명) | -| **404 Not Found** | `login_when404NotFound_setsErrorMessage` | 리소스를 찾을 수 없음 | -| **500 Server Error** | `login_when500ServerError_setsErrorMessage` | 서버 내부 오류 | -| **Network Error** | `login_whenNetworkError_setsErrorMessage` | 네트워크 연결 실패 | - -#### 테스트 작성 패턴 - -```kotlin -// HTTP Error 테스트 패턴 -@Test -fun functionName_when{StatusCode}{ErrorType}_setsErrorMessage() = runTest { - val errorBody = """{"status":404,"code":404,"message":"Not found"}""" - .toResponseBody("application/json".toMediaType()) - val httpException = HttpException(Response.error(404, errorBody)) - coEvery { useCase(any(), any()) } returns Result.failure(httpException) - - viewModel.doAction(...) - advanceUntilIdle() - - assertEquals("HTTP 404 ", viewModel.uiState.value.errorMessage) - assertFalse(viewModel.uiState.value.successFlag) - assertFalse(viewModel.uiState.value.isLoading) -} - -// Network Error 테스트 패턴 -@Test -fun functionName_whenNetworkError_setsErrorMessage() = runTest { - coEvery { useCase(any(), any()) } returns Result.failure( - java.io.IOException("Network unavailable") - ) - - viewModel.doAction(...) - advanceUntilIdle() - - assertEquals("Network unavailable", viewModel.uiState.value.errorMessage) - assertFalse(viewModel.uiState.value.successFlag) -} -``` - -#### 필수 Import - -```kotlin -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.ResponseBody.Companion.toResponseBody -import retrofit2.HttpException -import retrofit2.Response -``` - -#### 적용 대상 -- API를 호출하는 모든 ViewModel -- 현재 적용 대상 (Auth API): - * `LoginViewModel` ✅ - * `LogoutViewModel` ✅ - * `SignUpViewModel` ✅ - * `PasswordChangeViewModel` ✅ - * `ReissueViewModel` ✅ - * `SendEmailCodeViewModel` ✅ - * `VerifyEmailViewModel` ✅ - -#### 검증 항목 -각 HTTP 에러 테스트에서 반드시 검증해야 할 항목: -1. **errorMessage 설정**: 에러 메시지가 올바르게 설정되었는지 -2. **success 플래그**: 성공 플래그가 `false`인지 -3. **isLoading 상태**: 로딩 상태가 `false`로 변경되었는지 - -## Documentation - -### Public API (필수) -**모든 `public` 함수나 클래스(특히 `UseCase`, `Repository` 인터페이스)에는 반드시 KDoc 형식의 주석을 작성해야 합니다.** -- Detekt의 `undocumented-public-function` 규칙을 준수하기 위함입니다. -- KDoc은 함수의 역할, 파라미터, 반환값을 명확히 설명해야 합니다. -- 예외: 단순한 data class나 enum은 선택적으로 작성 가능 - -### Complex Logic -복잡한 비즈니스 로직이 들어가는 곳에는 주석을 통해 '왜(Why)' 이렇게 구현했는지 설명합니다. - -### ⚠️ Critical: 구조 변경 시 필수 검증 절차 -**파일 이동, 패키지 변경, 클래스 리팩토링 등 구조적 변경 작업 시 반드시 다음 절차를 따라야 합니다:** - -1. **변경 전 빌드 확인**: 구조 변경 전에 현재 상태가 빌드되는지 확인 (`./gradlew :app:compileDebugKotlin`) -2. **사용처 전체 검색**: 이동/변경할 파일이 사용되는 모든 위치를 `grep`으로 검색하여 리스트업 - - 예: `grep -r "ClassName" app/src/main/java/com/kuit/afternote/feature/afternote` -3. **Import 경로 일괄 변경**: 모든 사용처의 import 경로를 한 번에 변경 (단계적으로 하지 말고 전체를 먼저 파악) -4. **변수/함수명 변경 시**: 변수명이나 함수명을 변경할 때는 `grep`으로 모든 사용처 확인 후 일괄 변경 -5. **변경 후 즉시 빌드 검증**: 파일 이동/변경 후 반드시 빌드하여 컴파일 오류 확인 - - 명령어: `./gradlew :app:compileDebugKotlin` -6. **State/Params 클래스 이동 시**: State 클래스나 Params 클래스를 이동할 때는 해당 클래스를 사용하는 모든 컴포넌트의 import 경로를 확인 -7. **중복 제거 시**: 중복 파일을 제거하기 전에 사용처를 모두 확인하고, 하나로 통합한 후 모든 import 경로를 업데이트 -8. **경험 사례**: - - `IconResourceMapper` 중복 제거 시 `main/`과 `util/`에 모두 존재했으나, 사용처를 확인하지 않고 하나만 삭제하여 빌드 오류 발생 - - `AfternoteItemMapper` 이동 시 `toAfternoteItems` 메서드가 사용되고 있었으나 확인하지 않아 빌드 오류 발생 - - `SelectionDropdown` 리팩토링 시 `boxWidth` 변수를 `state.boxWidth`로 변경하지 않아 빌드 오류 발생 diff --git a/.cursor/rules/quality/k2-compose-assigned-value-suppress.mdc b/.cursor/rules/quality/k2-compose-assigned-value-suppress.mdc deleted file mode 100644 index 4929a845..00000000 --- a/.cursor/rules/quality/k2-compose-assigned-value-suppress.mdc +++ /dev/null @@ -1,47 +0,0 @@ ---- -description: How to handle K2 false positive "Assigned value is never read" in Compose -globs: "**/*.kt" -alwaysApply: false ---- - -# K2 False Positive: Assigned Value Is Never Read (Compose) - -## When This Appears - -The IDE (Kotlin K2, Android Studio 2025+) may report **"Assigned value is never read"** when: - -1. You **assign** to a state variable inside a lambda (e.g. `showDialog = false` in `onDismiss`, or `selectedDate = date` in `onDateChanged`). -2. The **read** of that state happens during **recomposition** (e.g. `if (showDialog) { ... }` or `initialDate = selectedDate ?: ...`). - -The analyzer does not track usage across the Composable boundary, so it wrongly treats the assignment as unused. This is a **known false positive** (KT-78881, KTIJ-34162). - -## What To Do - -- **Do not** "fix" it by refactoring the logic (e.g. wrapping in `run { }`) when the code is correct. -- **Suppress and document:** - - Prefer **file-level**: at the top of the file (above `package`), add a short comment and the suppression: - ```kotlin - // K2 false positive: state assigned in dialog/callback lambdas is read at recomposition (KT-78881). - @file:Suppress("AssignedValueIsNeverRead") - package ... - ``` - - Or **statement-level** on the assignment line if only one or two lines are affected: - ```kotlin - onDismiss = { - @Suppress("AssignedValueIsNeverRead") - showDatePickerDialog = false - } - ``` -- Always add a brief comment (or reference KT-78881) so future readers know this is a tooling bug, not dead code. - -## When The Warning Is Real - -If you introduced a **local copy** of state (shadowing), the warning is correct. Example of wrong pattern: - -```kotlin -// ❌ Wrong: local 'show' is updated, not the Compose state -var show = showDatePickerDialog -DatePickerDialog(onDismissRequest = { show = false }) -``` - -Ensure you are assigning to the **actual state property** used in the Composable (e.g. the one from `var x by remember { mutableStateOf(...) }`), not a local variable. diff --git a/.cursor/rules/tech-stack/android-kotlin.mdc b/.cursor/rules/tech-stack/android-kotlin.mdc deleted file mode 100644 index 4e940229..00000000 --- a/.cursor/rules/tech-stack/android-kotlin.mdc +++ /dev/null @@ -1,103 +0,0 @@ ---- -description: Android/Kotlin coding conventions and Import rules -globs: **/*.kt -alwaysApply: true ---- - -# Android/Kotlin 코딩 규칙 - -## Import Rules (임포트 규칙) - Critical - -### 1. Always Include Full Import Statements -- **ALWAYS include full import statements** for every class, function, and extension used in the code snippet. -- Android Studio는 종종 확장 함수와 Compose delegate의 자동 임포트에 실패합니다. - -### 2. Explicitly Import Extension Functions -- **Never assume extension functions are globally available.** Always add the import line explicitly. -- 다음 확장 함수들은 자동 임포트되지 않으므로 명시적으로 임포트해야 합니다: - - `androidx.core.view.isVisible` → `import androidx.core.view.isVisible` - - `androidx.lifecycle.compose.collectAsStateWithLifecycle` → `import androidx.lifecycle.compose.collectAsStateWithLifecycle` (**필수**: StateFlow 수집 시) - - `androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel` → `import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel` (**필수**: `@HiltViewModel` ViewModel 주입 시) - - `androidx.compose.ui.platform.LocalContext` → `import androidx.compose.ui.platform.LocalContext` - - `androidx.compose.ui.platform.LocalDensity` → `import androidx.compose.ui.platform.LocalDensity` - - `androidx.compose.ui.platform.LocalFocusManager` → `import androidx.compose.ui.platform.LocalFocusManager` - -### 3. Jetpack Compose Imports -- **Always import `androidx.compose.runtime.*` delegates when using `by` keyword:** - - `import androidx.compose.runtime.getValue` - - `import androidx.compose.runtime.setValue` - - `import androidx.compose.runtime.remember` -- **Avoid wildcard imports** (e.g., `import androidx.compose.material3.*`) unless strictly necessary. - -### 4. Standard Import Rules -- **NEVER import `android.R`**: 절대 `android.R`을 임포트하지 마세요. 항상 애플리케이션 패키지의 `R` 클래스를 사용하세요 (예: `com.kuit.afternote.R`). -- **NO Wildcard Imports**: `import java.util.*` 같은 와일드카드 임포트를 사용하지 마세요. 명시적으로 특정 클래스를 임포트하세요. -- **Prefer AndroidX**: 항상 레거시 `android.support.*` 패키지보다 `androidx.*` 패키지를 선호하세요. - -### 5. No Hallucinated Imports -- **Do not invent extension functions** that do not exist in standard AndroidX libraries. -- **Verify existence**: Before generating code, verify that all imported classes/functions exist in: - - Standard Android SDK - - Commonly used libraries (Hilt, Retrofit, Room, Compose, etc.) - -## Dependency Injection (Hilt) - -### ViewModel 주입 (Compose) -- **`@HiltViewModel` ViewModel**: `hiltViewModel()`을 사용하세요. -- **일반 ViewModel** (DI 없음): `viewModel()`을 사용하세요. -- **중요**: `@HiltViewModel`로 정의된 ViewModel은 반드시 `hiltViewModel()`으로 주입해야 합니다. `viewModel()`은 Hilt Factory를 사용하지 않습니다. -- **Import**: `import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel` -- **참고**: `androidx.hilt.navigation.compose.hiltViewModel`은 deprecated됨 (2025+) - - -@HiltViewModel ViewModel 주입은 hiltViewModel() 함수를 사용하세요. - -import androidx.hilt.navigation.compose.hiltViewModel // Deprecated! - -@Composable -fun MyRoute(viewModel: MyHiltViewModel = hiltViewModel()) { - // Deprecated import 사용 -} - - -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel - -@Composable -fun MyRoute(viewModel: MyHiltViewModel = hiltViewModel()) { - // 최신 import 사용 - 올바른 방법 -} - - - -### Constructor Injection -- 가능한 모든 클래스(`RepositoryImpl`, `UseCase`, `ViewModel`)는 생성자 주입(`@Inject constructor`)을 사용합니다. -- `@HiltViewModel`을 사용하고, `SavedStateHandle`이 필요한 경우 생성자에 포함합니다. - -## Navigation Rules - -### Type-Safe Navigation -1. **Serializable Routes**: 모든 Route는 `kotlinx.serialization`의 `@Serializable` 어노테이션을 사용한 `sealed interface` 또는 `sealed class`로 정의합니다. - - 문자열 하드코딩(예: "home/{id}")은 금지합니다. - -2. **Domain Navigation (도메인 간 네비게이션)** - - **다른 도메인으로 navigate 할 때는 인터페이스를 사용하세요.** - - navgraph 내부에서 다른 도메인을 직접 아는 것은 지양합니다. - - 각 도메인은 자신의 Navigator 인터페이스를 정의하고, 구현체는 app 레벨에서 생성합니다. - -## Kotlin & Compose Style -- 가능한 한 `var`보다 `val`을 사용하세요. -- Jetpack Compose에서 `Modifier` 체이닝 순서를 선호하세요: Size -> Layout -> Drawing -> Event Listeners. -- 기본적으로 Material3 디자인 시스템 컴포넌트를 사용하세요. -- Coroutines: ViewModel에서는 `viewModelScope`를, Fragments/Activities에서는 `LifecycleScope`를 사용하세요. - -## Resource Management - -### Drawable -- 아이콘은 `Icon` 컴포저블을 사용하고, `contentDescription`을 반드시 지정합니다. -- 이미지 리소스 사용 시 `painterResource(R.drawable.name)`을 사용합니다. -- SVG나 PNG를 그대로 가져다 쓸 때는 `Modifier.size()`를 설정하지 말아야 합니다. -- drawable 파일에 이미 정의된 크기를 그대로 사용해야 합니다. - -### Strings -- 모든 UI 텍스트는 `strings.xml`에 정의하여 사용합니다 (`stringResource(R.string.key)`). -- 하드코딩된 문자열은 금지합니다. diff --git a/.cursor/rules/tech-stack/architecture.mdc b/.cursor/rules/tech-stack/architecture.mdc deleted file mode 100644 index a48e1f01..00000000 --- a/.cursor/rules/tech-stack/architecture.mdc +++ /dev/null @@ -1,47 +0,0 @@ ---- -description: Clean Architecture and layer separation rules -globs: **/domain/**/*.kt, **/data/**/*.kt, **/presentation/**/*.kt -alwaysApply: false ---- - -# Architecture Rules (아키텍처 규칙) - -## Layer Separation (계층 분리) -모든 Feature 모듈은 아래 3계층을 엄격히 준수해야 합니다. - -1. **Domain Layer** (Pure Kotlin) - - 외부 의존성(Android Framework, Data Layer 등)을 가지지 않습니다. - - `Entity`, `Repository Interface`, `UseCase`가 위치합니다. - - 비즈니스 로직의 핵심입니다. - -2. **Data Layer** - - Domain Layer의 `Repository`를 구현(`Impl`)합니다. - - API 호출, DB 접근, DTO 매핑을 담당합니다. - - `Mapper`를 통해 DTO를 Domain Entity로 변환하여 반환합니다. - -3. **Presentation Layer** - - UI (`Screen`, `Component`)와 상태 관리 (`ViewModel`)를 담당합니다. - - Domain Layer의 `UseCase`를 주입받아 비즈니스 로직을 수행합니다. - - `UiModel`을 정의하여 UI에 필요한 데이터만 가공해 보여줍니다. - -## Dependency Flow (의존성 방향) -- **Correct**: Presentation → Domain ← Data -- **Incorrect**: Domain → Data (X), Domain → Presentation (X) - -## Component Location Rules (컴포넌트 위치 결정 규칙) - Critical -- **핵심 원칙**: Feature 모듈 간 직접 의존은 절대 금지됩니다. Feature A가 Feature B의 컴포넌트를 직접 참조하면 순환 참조 위험과 클린 아키텍처 위반이 발생합니다. -- **Core 모듈로 이동 기준**: - * **2개 이상의 Feature에서 사용될 가능성이 있는가?** (YES) - * **도메인 로직이나 특정 화면 상태에 의존하지 않는가?** (순수 UI 컴포넌트인가? YES) - * **브랜드 아이덴티티(디자인 시스템)의 일부인가?** (버튼, 텍스트 필드, 헤더 등) -- **Feature 내부에 유지 (`feature/xxx/component/common`)**: - * 해당 Feature의 여러 화면(Screen)에서만 공유되고, 다른 Feature에서는 절대 쓰일 일이 없는 경우 - * 특정 Feature의 도메인 데이터(예: `Album`, `Song`)를 파라미터로 받는 경우 -- **마이그레이션 전략**: - 1. 패키지 이동: `feature/afternote/.../common/` → `core/` - 2. 의존성 확인: 이동한 컴포넌트가 `afternote`만의 리소스나 도메인 모델을 참조하는지 확인 - 3. Import 경로 수정: 모든 사용처의 import 경로를 `core`로 변경 - 4. 빌드 테스트: 앱 전체를 빌드하여 깨지는 곳이 없는지 확인 -- **예시**: - * `Header`, `LabeledTextField`, `MessageTextField`, `RequiredLabel` → **Core로 이동** (다른 Feature에서도 필요) - * `InfoCard`, `InfoRow` (detail 전용) → **Feature 내부 유지** (나중에 필요해지면 그때 이동) diff --git a/.cursor/rules/tech-stack/coil-image-loading.mdc b/.cursor/rules/tech-stack/coil-image-loading.mdc deleted file mode 100644 index e392fb87..00000000 --- a/.cursor/rules/tech-stack/coil-image-loading.mdc +++ /dev/null @@ -1,66 +0,0 @@ ---- -description: Coil 3 image loading rules (dependencies, User-Agent, AsyncImage, error handling) -globs: **/presentation/**/*.kt, **/ui/**/*.kt, **/component/**/*.kt, **/core/**/*.kt -alwaysApply: false ---- - -# Coil 3 Image Loading Rules - -## Dependencies - Critical - -### coil-network-okhttp Required -- **Coil 3 is modular.** `coil-compose` alone cannot load network (http/https) images. -- **Must add** `coil-network-okhttp` dependency in the app module (e.g. in `libs.versions.toml` and `app/build.gradle.kts`). -- Without it, Coil has no Fetcher for URLs and fails silently, showing the `error` drawable. - -## HTTP 403 Prevention - Critical - -### User-Agent Header for Network Images -- Some servers (e.g. Wikipedia, Wikimedia) block requests without a `User-Agent` header and return **403 Forbidden**. -- **Always add User-Agent** when loading external URLs via Coil: - -```kotlin -ImageRequest.Builder(context) - .data(url) - .httpHeaders( - NetworkHeaders.Builder() - .set("User-Agent", "AppName Android App") - .build() - ) - .build() -``` - -- Use the app’s real name (e.g. `"Afternote Android App"`) instead of a generic string when applicable. - -## AsyncImage Best Practices - -### Prefer AsyncImage -- Use **AsyncImage** (with `ImageRequest` when you need headers/options) over `rememberAsyncImagePainter` for built-in error/placeholder handling. - -### Always Add onError Logging -- **Silent failures make debugging difficult.** Always add an `onError` callback and log the throwable (e.g. with `Log.e(TAG, "Coil load failed: uri=...", state.result.throwable)`). -- This surfaces "Unable to create a fetcher", network errors, and 403s in Logcat. - -### Always Provide error (and optionally placeholder) -- Set **error** to a fallback painter (e.g. `painterResource(fallbackDrawable)`) so failed loads show a defined image instead of nothing. -- Optionally set **placeholder** for loading state. - -## Required Imports for Network Images - -When using Coil 3 for network URLs with headers: - -```kotlin -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter -import coil3.network.NetworkHeaders -import coil3.network.httpHeaders -import coil3.request.ImageRequest -``` - -## Summary of Issues Encountered - -| Issue | Root Cause | Rule | -|-------|-----------|------| -| Image not loading (silent failure) | Missing `coil-network-okhttp` | Always add network dependency with Coil 3 | -| HTTP 403 Forbidden | No User-Agent header | Always set User-Agent for external URLs | -| Hard to debug | No error logging | Always add `onError` callback with logging | diff --git a/.cursor/rules/tech-stack/compose-focus-popup.mdc b/.cursor/rules/tech-stack/compose-focus-popup.mdc deleted file mode 100644 index 56ae5c32..00000000 --- a/.cursor/rules/tech-stack/compose-focus-popup.mdc +++ /dev/null @@ -1,53 +0,0 @@ ---- -description: Compose DropdownMenu/popup + focus patterns; avoid focus races and dead callbacks -globs: **/presentation/**/*.kt, **/ui/**/*.kt, **/component/**/*.kt -alwaysApply: false ---- - -# Compose: DropdownMenu / Popup + Inline Edit & Dead Callbacks - -## DropdownMenu (or focusable Popup) + revealing a focusable field - Critical - -When the user action **closes a DropdownMenu** (or similar focusable popup) and **in the same click handler** you set state that **reveals a focusable composable** (e.g. BasicTextField/OutlinedTextField for inline edit), the popup dismiss and focus cleanup can run in the same frame. That causes: - -- The newly shown text field to receive **focus loss** immediately (from the popup’s focus behavior). -- Any **confirm-on-focus-loss** logic (e.g. commit edit, exit edit mode) to run **before** the user ever sees the field, so the edit mode appears to do nothing or disappear instantly. - -### Required: Defer state change by one frame - -**Defer the state change** that reveals the editable field until **after** the popup has dismissed and its focus cleanup has run. - -- Use `rememberCoroutineScope()` and `scope.launch { withFrameNanos { } ; setState(...) }` (or one frame delay) so the DropdownMenu fully dismisses before the editing state is set. -- Use **`androidx.compose.runtime.withFrameNanos`** for the one-frame deferral in Compose (not `kotlinx.coroutines`). - -Example (conceptual): - -```kotlin -val scope = rememberCoroutineScope() -DropdownMenuItem( - text = { Text("수정") }, - onClick = { - onDismissRequest() - scope.launch { - withFrameNanos { } - state.startEditingItem(item.id) - } - } -) -``` - -### Required: Guard confirm-on-focus-loss in the inline-edit field - -If the inline-edit text field **commits or exits edit mode on focus loss** (`onFocusChanged` or similar): - -- **Do not** call confirm/commit on the **first** focus loss that can occur from the opening transition (dropdown dismiss, recomposition). -- Track that the field **has gained focus at least once** (e.g. `var hasGainedFocus`). Only run confirm/commit when `hasGainedFocus && !isFocused` (or equivalent). -- Optionally **defer** `FocusRequester.requestFocus()` by one frame (`LaunchedEffect(editingItemId) { withFrameNanos { }; focusRequester.requestFocus() }`) so the field appears and then receives focus after the dismiss cycle. - -This prevents the dropdown’s focus turbulence from immediately triggering confirm → exit edit mode. - -## Dead callbacks / parameters - -- If a **callback or parameter** is **never invoked** by the component that receives it (e.g. passed from parent but the child never calls it), **remove it** from the API. -- Do not leave “for future use” or “TODO” callbacks that are never called; they confuse call sites and misrepresent the contract. -- When implementing a feature that is fully handled inside a child (e.g. “edit” triggered and completed inside a list), do not add a parent callback for that action unless the parent actually needs to be notified. diff --git a/.cursor/rules/tech-stack/compose.mdc b/.cursor/rules/tech-stack/compose.mdc deleted file mode 100644 index 1eb2539e..00000000 --- a/.cursor/rules/tech-stack/compose.mdc +++ /dev/null @@ -1,404 +0,0 @@ ---- -description: Jetpack Compose coding rules and patterns -globs: **/presentation/**/*.kt, **/ui/**/*.kt, **/component/**/*.kt -alwaysApply: false ---- - -# Jetpack Compose Rules - -## Composable Functions - -### 1. State Hoisting -State must be hoisted to the caller, and Composables should receive state as parameters (Stateless preferred). -- **Complete State Hoisting**: **All states** inside screens (tab selection, navigation item selection, etc.) must move to ViewModel. -- **No Local State**: Do not manage state with `remember { mutableStateOf() }` inside Composable, manage with ViewModel's State. -- **Bottom Navigation State to ViewModel**: `BottomNavigationBar`'s `selectedItem` state must also be included in ViewModel's `UiState`. - - -Make composables reusable through state hoisting. - -@Composable -fun MyScreen() { - var selectedTab by remember { mutableStateOf(Tab.ALL) } - var selectedNavItem by remember { mutableStateOf(NavItem.HOME) } - // Local state usage - not reusable -} - - -@Composable -fun MyRoute(viewModel: MyViewModel = viewModel()) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - MyScreen(uiState = uiState, onEvent = viewModel::onEvent) -} - -@Composable -fun MyScreen(uiState: MyUiState, onEvent: (MyEvent) -> Unit) { - // All states received as parameters - reusable -} - - - -### 2. Lifecycle-aware State Collection -- **❌ Deprecated**: `collectAsState()` 사용 금지 (메모리 누수 위험) -- **✅ 권장**: `androidx.lifecycle.compose.collectAsStateWithLifecycle()` 사용 -- **Import**: `import androidx.lifecycle.compose.collectAsStateWithLifecycle` (필수) - -### 3. Modifier -모든 Composable 함수는 `modifier: Modifier = Modifier`를 첫 번째 선택적 파라미터로 받아야 합니다. - -### 4. Preview (필수) -**모든 public Composable 함수는 반드시 `@Preview` 어노테이션을 사용한 Preview 함수를 포함해야 합니다.** -- Preview 함수는 `private`으로 선언하고, `@Preview(showBackground = true)`를 사용합니다. -- 모든 상태를 보여 줘(선택됨/선택 안 됨, 활성화/비활성화 등). - -#### Preview + Hilt ViewModel (render 이슈 방지) - Critical -**Compose Preview는 Hilt 컨테이너 없이 실행되므로, 화면에서 `hiltViewModel()`을 그대로 사용하면 Preview가 렌더되지 않습니다.** -다음 패턴을 적용하여 Preview에서도 정상 렌더되도록 합니다. - -1. **ViewModel Contract 인터페이스**: 화면이 ViewModel에서 실제로 사용하는 멤버만 정의한 인터페이스를 작성합니다 (예: `XxxViewModelContract` — `uiState`, `loadXxx()`, `updateXxx()` 등). -2. **실제 ViewModel**: `@HiltViewModel` ViewModel이 해당 인터페이스를 구현하도록 합니다 (`override` 적용). -3. **화면 파라미터**: 화면 Composable의 ViewModel 파라미터 타입을 **인터페이스**로 두고, 기본값은 `hiltViewModel<실제ViewModel>()`로 합니다. -4. **Fake ViewModel (Preview 전용)**: 인터페이스를 구현하는 Fake 클래스를 같은 화면 파일(또는 Preview 전용 위치)에 두고, 샘플 `UiState`와 no-op 메서드로 채웁니다. -5. **Preview에서 Fake 사용**: `@Preview` Composable에서는 `hiltViewModel()`을 호출하지 말고, `remember { FakeXxxViewModel() }`를 넘깁니다. - -- **❌ 금지**: Preview에서 `hiltViewModel()`에 의존 (Preview 컨텍스트에 Hilt 없음 → 렌더 실패). -- **✅ 권장**: Contract 인터페이스 + Fake ViewModel을 Preview에 전달하여 Hilt 없이 렌더. - -## File Structure -- **File Length Limit**: 파일이 400줄을 넘어가면 여러 파일로 분리해야 합니다. -- **Folder Structure**: 폴더당 파일 수를 **15개 이하**로 유지합니다. - -## Navigation & State (NavGraphBuilder) - Critical - -### NavGraphBuilder와 Compose State 연결 규칙 - -`NavHost` 안에서 사용하는 `fun NavGraphBuilder.xxxNavGraph(params: XxxNavGraphParams)` 확장 함수는 **@Composable 이 아니므로 한 번만 실행**되고, 이 안에서 캡처한 값은 **초기값 스냅샷**으로 고정됩니다. - -- **절대 금지**: NavGraphBuilder 내부에서 `val items = params.items` 처럼 **값을 한 번 복사해 두고**, 그 값을 route 람다에서 계속 사용하는 패턴 - - 이렇게 하면 `items`는 `remember { mutableStateOf(...) }`가 업데이트되어도 새 값을 보지 못하고, 리스트가 비어 있거나 예전 값인 상태로 유지됩니다. -- **필수 규칙**: 화면에서 관리하는 Compose state는 NavGraphParams에 **값이 아니라 provider**로 넘기고, **route의 @Composable 람다 안에서 호출**해서 읽습니다. - - -NavGraphBuilder에서 Compose state를 전달할 때는 값 스냅샷이 아니라 provider/lambda를 사용하여 route 람다 안에서 현재 값을 읽어야 합니다. - -// NavGraph.kt -var afternoteItems by remember { mutableStateOf(listOf<AfternoteItem>()) } - -NavHost(navController, startDestination = "afternote") { - afternoteNavGraph( - params = AfternoteNavGraphParams( - afternoteItems = afternoteItems, // ❌ 값 스냅샷 전달 - onItemsUpdated = { afternoteItems = it } - ) - ) -} - -// AfternoteNavgraph.kt -data class AfternoteNavGraphParams( - val afternoteItems: List<AfternoteItem>, - ... -) - -fun NavGraphBuilder.afternoteNavGraph( - navController: NavController, - params: AfternoteNavGraphParams -) { - val afternoteItems = params.afternoteItems // ❌ NavGraphBuilder 실행 시점 스냅샷 - - afternoteComposable<AfternoteRoute.DetailRoute> { backStackEntry -> - // 여기서 읽는 afternoteItems는 항상 초기값 (빈 리스트)일 수 있음 - AfternoteDetailRouteContent( - backStackEntry = backStackEntry, - navController = navController, - listItems = afternoteItems - ) - } -} - - -// NavGraph.kt -var afternoteItems by remember { mutableStateOf(listOf<AfternoteItem>()) } - -NavHost(navController, startDestination = "afternote") { - afternoteNavGraph( - params = AfternoteNavGraphParams( - afternoteItemsProvider = { afternoteItems }, // ✅ provider/lambda 전달 - onItemsUpdated = { afternoteItems = it } - ) - ) -} - -// AfternoteNavgraph.kt -data class AfternoteNavGraphParams( - val afternoteItemsProvider: () -> List<AfternoteItem>, - ... -) - -fun NavGraphBuilder.afternoteNavGraph( - navController: NavController, - params: AfternoteNavGraphParams -) { - afternoteComposable<AfternoteRoute.DetailRoute> { backStackEntry -> - val currentItems = params.afternoteItemsProvider() // ✅ 매 recomposition 시 현재 state 읽기 - - AfternoteDetailRouteContent( - backStackEntry = backStackEntry, - navController = navController, - listItems = currentItems - ) - } -} - - - -### Dev 더미 데이터와 실제 API 데이터 구분 규칙 - -- **원칙**: 실제 API에서 목록이 성공적으로 로드된 이후에는 **dev/더미 데이터(fallback)** 를 detail 화면에서 사용하지 않습니다. -- dev·Preview용 더미 데이터는 `if (realItems.isEmpty())` 같은 분기에서 **리스트 화면·편집 화면의 초기 상태에만** 사용하고, - **detail route에서는 NavGraph가 들고 있는 실제 목록(state)만 사용**해야 합니다. - - -Afternote와 같은 목록/상세 구조에서 detail 화면은 항상 NavGraph의 실제 state 리스트를 사용하고, dev 더미 데이터는 리스트가 비어 있을 때의 초기 렌더링에만 사용합니다. - -// Detail route에서 dev 더미 fallback 사용 - ❌ 실제 API 데이터와 불일치 가능 -val listItems = resolveListItems(afternoteItems, afternoteProvider) // dev 더미 포함 -val item = listItems.find { it.id == route.itemId } ?: return DesignPendingDetailContent(...) - - -// Detail route에서는 NavGraph state만 사용 - ✅ 리스트/상세가 항상 동일 source of truth -val listItems = params.afternoteItemsProvider() -val item = listItems.find { it.id == route.itemId } - -val showDesignPending = item == null || item.type !in DESIGNED_DETAIL_TYPES -if (showDesignPending) { - DesignPendingDetailContent(onBackClick = { navController.popBackStack() }) -} else { - SocialNetworkDetailScreen(...) -} - - - -## Rem-based Responsive Design (rem 기반 반응형 디자인) - Critical - -### REM 구현 시 우선순위 (Implementation preference) -- **1순위: `Spacer(Modifier.weight())` 사용**: 레이아웃에 제한된 높이가 있을 때(스크롤 없음) 큰 간격(24dp 이상)은 **반드시 weight으로 구현**한다. 비율 계산이나 `BoxWithConstraints`보다 단순하고 유지보수에 유리하다. -- **2순위: BoxWithConstraints**: 스크롤 가능한 컨테이너(`verticalScroll`, `LazyColumn`) 내부에서는 `weight()`를 쓸 수 없으므로, 이 경우에만 `BoxWithConstraints` + 비율 기반 `Spacer(Modifier.height(...))`를 사용한다. - -### 핵심 규칙 -3. **큰 간격은 weight과 Arrangement로 해결**: padding 값이 큰 경우(24dp 이상) `Spacer(Modifier.weight())` 또는 `Arrangement.SpaceBetween`, `Arrangement.SpaceEvenly` 등을 사용 - - **❌ 금지**: `padding(top = 100.dp)` 같은 큰 padding 값 - - **✅ 권장**: `Column(verticalArrangement = Arrangement.SpaceBetween) { ... }` 또는 `Spacer(Modifier.weight(0.056f))` 등 비율 weight -4. **공통 컴포넌트 최대한 활용**: `core` 모듈에 있는 공통 컴포넌트(`Header`, `LabeledTextField`, `MessageTextField`, `CustomRadioButton`, `BottomNavigationBar` 등)를 최대한 재사용 -5. **컴포넌트 오버로딩**: 전체적인 틀은 비슷하지만 세부 디자인이 다른 경우, 별도 컴포넌트를 만들지 말고 **오버로딩**으로 처리 - - **예시**: `Header(title: String)`, `Header(title: String, onBackClick: () -> Unit)`, `Header(title: String, onBackClick: () -> Unit, onActionClick: () -> Unit)` - - **원칙**: 공통 구조는 유지하고, 선택적 파라미터로 차이점만 추가 - -### Hardcoded Dp (Figma parity) - Allowed -피그마 디자인과의 여백/간격을 빠르게 맞추기 위해, 화면/컴포넌트에서 `dp` 값을 **직접 하드코딩하는 것을 허용**합니다. -- 금지: 의미 없는 산술로 새 값을 만들지 않습니다 (예: `16.dp - 8.dp`). - -### 적용 우선순위 -1. **큰 간격 (24dp 이상)**: **REM 구현 시 weight 우선** — `Spacer(Modifier.weight(ratio))` 또는 `Arrangement` 사용 (예: `weight(0.056f)`, `weight(0.08f)`로 비율 간격) - - **⚠️ 중요 예외**: 스크롤 가능한 컨테이너 내부에서는 `Modifier.weight()`를 사용할 수 없음 → 아래 **스크롤 컨테이너 반응형 간격** 섹션 참조 -2. **중간 간격 (8-24dp)**: `Spacer(Modifier.height())` 사용 -3. **작은 간격 (8dp 이하)**: `Spacer(Modifier.height())` 사용 (고정값 허용) -4. **화면 가장자리 padding**: 고정값 허용 (일관성 유지, 예: `padding(horizontal = 20.dp)`) -6. **컴포넌트 간 간격**: `Arrangement.spacedBy()` 사용 권장 -7. **⚠️ Critical: 반응형 간격 계산 시 `BoxWithConstraints` 사용 필수** - - 스크롤 컨테이너 내부에서 비율 기반 간격이 필요할 때는 `LocalWindowInfo`나 `LocalConfiguration` 대신 `BoxWithConstraints`를 사용해야 합니다. - - 부모 컨테이너의 제약 조건(Constraints)을 기준으로 계산하여 분할 화면, 작은 창, 재사용성 문제를 해결합니다. - -### 스크롤 컨테이너 반응형 간격 (Scrollable Container Responsive Spacing) - Critical -스크롤 가능한 컨테이너(`verticalScroll`, `LazyColumn` 등) 내부에서는 `Modifier.weight()`를 사용할 수 없습니다. -대신 다음 방법들을 사용하여 반응형 간격을 구현할 수 있습니다. - -#### ⚠️ Critical: LocalWindowInfo 사용 금지 -**`LocalWindowInfo`나 `LocalConfiguration`을 사용한 화면 높이 기반 계산은 권장되지 않습니다.** -- **문제점**: - - 분할 화면(Split Screen)이나 팝업 모드에서 앱이 작은 창으로 실행될 때, 전체 화면 높이를 기준으로 계산하면 여백이 앱 화면보다 더 커져 콘텐츠가 잘리거나 불필요한 스크롤이 발생합니다. - - 재사용성 저하: 바텀시트나 다이얼로그 내부에서 재사용할 때 레이아웃이 깨집니다. - - 컨텍스트 무시: 부모 컨테이너의 실제 사용 가능한 공간을 무시하고 전체 화면 크기를 참조합니다. -- **대안**: `BoxWithConstraints`를 사용하여 부모 컨테이너의 제약 조건(Constraints)을 기준으로 계산하세요. - -#### 1. BoxWithConstraints 사용 (권장) ⭐ -**부모 컨테이너의 Viewport 높이를 기준으로 비율 기반 간격을 설정합니다.** -- **장점**: 분할 화면, 작은 창, 다이얼로그 등 모든 상황에서 부모 컨테이너의 실제 사용 가능한 공간을 기준으로 계산하여 안전합니다. -- **Import**: `import androidx.compose.foundation.layout.BoxWithConstraints` - -```kotlin -BoxWithConstraints(modifier = modifier) { - // maxHeight는 이 컴포넌트가 사용할 수 있는 최대 높이(Viewport 높이)를 제공합니다. - // 화면 전체 높이가 아닌 부모 컨테이너의 높이를 기준으로 하여 분할 화면이나 작은 창에서도 정상 동작합니다. - val spacerHeight = maxHeight * 0.57f - - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - ) { - // Content... - - // Viewport 높이의 57% (800dp 화면 기준 약 456dp) - Spacer(modifier = Modifier.height(spacerHeight)) - - Button(...) - } -} -``` - -#### 2. LazyColumn + fillParentMaxHeight (복잡한 리스트) -`LazyColumn`에서는 `fillParentMaxHeight()`를 사용하여 비율 기반 높이를 설정할 수 있습니다. - -```kotlin -LazyColumn(modifier = Modifier.fillMaxSize()) { - item { /* Content */ } - item { Spacer(modifier = Modifier.fillParentMaxHeight(0.2f)) } - item { Button(...) } -} -``` - -#### 선택 기준 -| 상황 | 권장 방법 | -|------|----------| -| 스크롤 가능한 컨테이너 내부 반응형 간격 | **`BoxWithConstraints`** (권장) ⭐ | -| 복잡한 리스트 | `LazyColumn` + `fillParentMaxHeight()` | -| 작은 간격 (24dp 미만) | 고정 `Spacer(Modifier.height())` 허용 | -| ❌ 전체 화면 기준 계산 | **`LocalWindowInfo` 사용 금지** (분할 화면/재사용성 문제) | - -#### 반응형 간격 패턴 예시 - -스크롤 컨테이너 내부에서 반응형 간격을 계산할 때는 BoxWithConstraints를 사용하세요. - -// ❌ LocalWindowInfo 사용 - 분할 화면이나 작은 창에서 문제 발생 -val density = LocalDensity.current -val windowInfo = LocalWindowInfo.current -val screenHeight = with(density) { windowInfo.containerSize.height.toDp() } - -Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) -) { - // Content... - // 화면 전체 높이 기준 - 부모 컨테이너를 무시함 - Spacer(modifier = Modifier.height(screenHeight * 0.57f)) -} - - -// ✅ BoxWithConstraints 사용 - 부모 컨테이너의 제약 조건을 기준으로 계산 -BoxWithConstraints(modifier = modifier) { - // maxHeight는 이 컴포넌트가 사용할 수 있는 최대 높이(Viewport 높이)를 제공합니다. - val spacerHeight = maxHeight * 0.57f - - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - ) { - // Content... - // Viewport 높이 기준 - 분할 화면, 작은 창, 재사용 모두 안전 - Spacer(modifier = Modifier.height(spacerHeight)) - } -} - - - -### 큰 간격 처리 예시 - -큰 간격은 Arrangement나 weight을 사용하여 처리하세요. - -Column( - modifier = Modifier - .fillMaxSize() - .padding(top = 100.dp, bottom = 50.dp) -) { - // Content -} - - -Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween -) { - Spacer(Modifier.weight(1f)) - // Content - Spacer(Modifier.weight(1f)) -} - - - -## State Holder 패턴 (Cognitive Complexity 해결) - Critical -- **목표**: SonarQube 인지 복잡도(Cognitive Complexity)를 15 이하로 유지 -- **핵심 원칙**: "로직과 상태를 Composable에서 분리해내는 것(State Holder 패턴)" -- **⚠️ Critical: TooManyFunctions 경고 해결** - - State Holder 패턴 적용 후 `TooManyFunctions` 경고가 발생하면, `@Suppress`를 사용하기 전에 반드시 다음 방법들을 시도해야 합니다: - - **불필요한 함수 제거**: 사용되지 않는 헬퍼 함수나 중복 함수 제거 - * 예: 드롭다운 상태 관리 함수들이 State Class로 이동하면서 불필요해진 경우 제거 - - **책임 분리**: Manager 클래스로 로직 위임 고려 - * 예: `RecipientManager`, `CategoryManager`, `ProcessingMethodManager`로 분리 - * 함수 24개 → 16개로 감소 가능 - - **Private 함수 활용**: 외부에 노출될 필요 없는 함수는 `private`으로 변경 - * detekt 설정에서 `ignorePrivate: true`인 경우 카운트에서 제외됨 -- **필수 적용 규칙**: - 1. **State 클래스 생성**: 모든 상태 변수와 로직을 `@Stable` 클래스로 그룹화 - 2. **로직 캡슐화**: Composable 내부의 조건문, 람다 내부 중첩 조건문을 State 클래스의 멤버 함수로 이동 - 3. **Computed Properties**: UI 내 삼항 연산자나 조건문을 State 클래스의 computed property로 이동 - 4. **콜백 람다 제거**: Composable 내부에서 `val create...Callbacks = { ... }` 같은 람다 정의 금지 - 5. **함수 참조 사용**: State의 메서드는 함수 참조(`::`)로 전달 - 6. **조건부 렌더링**: `if (showDialog)` 대신 `state.activeDialog?.let { ... }` 패턴 사용 - 7. **UI 분리**: Scaffold 내부의 긴 UI 코드를 `EditContent` 같은 private Composable로 분리 - -## Parameter Refactoring Pattern (LongParameterList / S107 해결) - Critical -- **제한**: 함수·생성자 파라미터는 **최대 7개** (Sonar S107 / kotlin:S107). 이 이상이 되면 Params 데이터 클래스로 묶어서 전달합니다. -- **문제**: Composable 또는 NavGraph 함수에 7개 초과 파라미터가 필요할 때 단순히 하나의 data class로 묶는 것으로 해결합니다. -- **⚠️ Critical: @Suppress는 최후의 수단** - - State Hoisting 패턴 적용 후 `LongParameterList` 또는 S107 경고가 발생하면, `@Suppress` 전에 반드시 다음 방법을 시도합니다: - - **콜백 그룹화**: 3개 이상의 콜백이 있으면 Event Interface나 data class로 묶기 - * 예: `RecipientCallbacks`, `ProcessingMethodCallbacks` 같은 인터페이스/데이터 클래스 사용 - * 파라미터 11개 → 4개로 감소 가능 (콜백 5개를 1개로 그룹화) - - **UI 상태·Params 캡슐화**: 여러 인자를 하나의 Params/State 클래스로 묶기 - * 예: `SelectionDropdownState`, `AfternoteNavGraphParams` 클래스 생성 - * 파라미터 8개 이상 → 7개 이하로 감소 (IDE S107 경고 방지) - - **초기값 처리**: `initialShowTextField`, `initialExpandedItemId` 같은 초기값은 State 생성자로 옮기기 - * UI 컴포넌트는 초기값을 직접 알 필요 없이 완성된 `state`만 받으면 됨 -- **올바른 접근**: 다음 패턴을 순서대로 적용 - 1. **공통 섹션 추출**: 여러 화면에서 동일하게 사용되는 섹션은 별도 클래스로 추출 - 2. **섹션별 그룹화**: UI 섹션 단위로 상태(State)와 액션(Actions)을 함께 묶기 - 3. **콜백 그룹화**: 관련 콜백들을 별도 data class로 묶기 - 4. **Nullable 활용**: 조건부로 표시되는 섹션은 nullable로 처리 - 5. **최종 Params 클래스**: 섹션들을 조합하여 최종 파라미터 클래스 구성 (4개 이하 필드 목표) - -## Core 컴포넌트 최대한 활용 - Critical -- **원칙**: `core` 모듈에 있는 공통 컴포넌트를 최대한 재사용하여 코드 중복을 방지하고 일관성을 유지합니다. -- **사용 가능한 Core 컴포넌트**: - - `Header`: 화면 상단 헤더 (오버로딩 지원) - - `LabeledTextField`: 라벨이 있는 텍스트 필드 - - `MessageTextField`: 멀티라인 메시지 입력 필드 - - `CustomRadioButton`: 커스텀 라디오 버튼 - - `BottomNavigationBar`: 하단 네비게이션 바 - - `RequiredLabel`: 필수 입력 표시 라벨 - - `CircleArrowIcon`: 원형 화살표 아이콘 - - `SelectableRadioCard`: 선택 가능한 라디오 카드 -- **사용 전 확인**: 새로운 컴포넌트를 만들기 전에 `core` 모듈에 유사한 컴포넌트가 있는지 반드시 확인 -- **오버로딩 활용**: 전체적인 틀은 비슷하지만 세부 디자인이 다른 경우, 별도 컴포넌트를 만들지 말고 기존 컴포넌트의 **오버로딩**을 활용 - - **예시**: `Header` 컴포넌트는 `Header(title: String)`, `Header(title: String, onBackClick: () -> Unit)`, `Header(onBackClick: () -> Unit, onEditClick: () -> Unit)` 등으로 오버로딩되어 있음 - - **원칙**: 공통 구조는 유지하고, 선택적 파라미터로 차이점만 추가 - -## Slot API 패턴 (Composition over Inheritance) - Critical -- **핵심 철학**: 유사한 구조를 가진 컴포넌트들을 통일할 때는 **상속이 아닌 합성(Composition)**을 사용합니다. -- **원칙**: "공통 껍데기를 제공하고, 내용물은 호출부에서 주입받는다" -- **구현 방법**: - 1. **공통 껍데기 컴포넌트 생성**: 공통 레이아웃, 스타일, 동작을 가진 컴포넌트를 `core` 패키지에 생성 - 2. **Slot 파라미터 사용**: `content: @Composable ColumnScope.() -> Unit` 같은 Slot 파라미터로 내용물을 주입받음 - 3. **접근성 지원**: `Modifier.selectable`과 `Role.RadioButton` 등을 사용하여 접근성 개선 - 4. **이벤트 충돌 방지**: 내부 자식 컴포넌트의 클릭 이벤트는 `null`로 설정하여 부모 컴포넌트의 클릭과 충돌 방지 - -## Optimistic Updates Pattern (낙관적 업데이트 패턴) - -Optimistic Updates 패턴에 대한 상세 규칙은 `.cursor/rules/tech-stack/optimistic-updates.mdc`를 참조하세요. - -**핵심 원칙:** -- StateFlow 기반 UiState로 단방향 데이터 흐름(UDF) 유지 -- Optimistic update 실패 시 `needsRollback` 플래그로 롤백 처리 -- Screen에서 rollback 시 navigation 취소 -- Race condition 고려 (연속 요청 시 이전 상태 스냅샷 + 요청 ID 추적) diff --git a/.cursor/rules/tech-stack/data-as-data-ioc.mdc b/.cursor/rules/tech-stack/data-as-data-ioc.mdc deleted file mode 100644 index 8bea8c70..00000000 --- a/.cursor/rules/tech-stack/data-as-data-ioc.mdc +++ /dev/null @@ -1,91 +0,0 @@ ---- -description: Data-as-data and IoC — data source (dummy vs real) must be decided at a higher layer (DI), not by ViewModels/screens -globs: **/domain/**/*.kt, **/data/**/*.kt, **/presentation/**/*.kt, **/di/**/*.kt -alwaysApply: false ---- - -# Data as Data / Inversion of Control (IoC) - Critical - -## Principle: Decision at a Higher Layer - -**Whether data is dummy or real must not be decided or visible in ViewModels or screens.** -The **data source** (dummy vs real) must be decided at a **higher layer** (DI / build variant). ViewModels and screens depend only on **interfaces** (e.g. Repository) and **domain types** (e.g. `Song`, `User`); they receive "just data" and never import or call dummy/real types directly. - -- **Bad:** ViewModel or Screen imports and calls `AfternoteEditDummies.defaultSongs()` or `ReceiverDummies.receiverList`. The consumer decides or sees the data source. -- **Good:** ViewModel depends on `SongRepository` and calls `repo.getSongs()`. It receives `List`. The choice of dummy vs real is made in Hilt (which implementation is bound). - -Naming (dummy, stub, fake, etc.) is not what matters. Keep existing names (e.g. *Dummies). The requirement is **where the decision lives**: not in ViewModels or screens. - -## Where the Decision Lives - -The "dummy vs real" decision belongs **only** in: - -1. **Hilt modules** (main vs. debug source set): bind Real implementation in release, Fake (or dummy-backed) implementation in debug. -2. **Compose Previews:** Previews do not use Hilt. The **Preview composable only** may pass canned data (e.g. from *Dummies) or a Fake ViewModel. Production Screen/ViewModel must not import or call *Dummies. - -## Concrete Pattern (Step-by-Step) - -### Step A: Contract (Domain Layer) - -- Define an **interface** in domain (e.g. `SongRepository`, `ReceiverListRepository`). -- No Retrofit, no *Dummies, no Fake types. Only domain models. - -```kotlin -// :domain or feature/xxx/domain/repository -interface SongRepository { - suspend fun getSongs(): List -} -``` - -### Step B: Implementations - -- **Real implementation** (data layer): implements the interface using API/DB. No *Dummies imports. -- **Fake implementation** (debug or test): implements the same interface; **this is the only place** that imports and uses the canned data (e.g. *Dummies). - -```kotlin -// data layer - Real -class RealSongRepository @Inject constructor(private val api: SongApiService) : SongRepository { - override suspend fun getSongs() = api.fetchSongs().toDomain() -} - -// debug or test - Fake (only place that imports *Dummies or canned data) -class FakeSongRepository @Inject constructor() : SongRepository { - override suspend fun getSongs() = AfternoteEditDummies.defaultSongs() -} -``` - -### Step C: DI Binding (App Module) - -- **Main/release:** Hilt module binds `RealSongRepository` to `SongRepository`. -- **Debug:** Hilt module in `app/src/debug` binds `FakeSongRepository` to `SongRepository` (or a debug proxy that switches at runtime). - -### Step D: Consumer (Presentation) - -- ViewModel (and UseCases) depend only on the **interface** and **domain types**. -- No import of *Dummies (or any canned-data type) in ViewModels or in production Screen composables. The implementation (Fake) that uses *Dummies is the only place that imports them. - -```kotlin -@HiltViewModel -class SongViewModel @Inject constructor( - private val repo: SongRepository // Blind to real vs fake -) : ViewModel() { ... } -``` - -### Step E: Previews - -- **Route (connected):** Gets data from ViewModel (which gets it from Repository; Hilt decides which impl). -- **Screen (stateless):** Receives data as parameters (e.g. `songs: List`). -- **Preview:** Calls the Screen with explicit state. Using canned data (e.g. *Dummies) **in the Preview composable only** is acceptable because Previews are a dev tool and do not go through Hilt. - -## Canned Data and Implementations - -- Keep existing naming (e.g. *Dummies, core.dummy). Do not rename for this rule. -- The **Fake** (or dummy-backed) implementation of the repository interface is the only place that imports and uses *Dummies. ViewModels and production screens never import *Dummies. - -## Migration Direction - -When adding or refactoring features that need canned data: - -1. Define a Repository (or DataSource) interface in domain. ViewModels depend on the interface. -2. Implement Real (data layer) and Fake (debug/test; Fake is the only place that uses *Dummies). Bind via Hilt (main vs debug). -3. Remove direct references to *Dummies from ViewModels and production screens. Keep *Dummies references only in Fake implementations and in Preview composables. diff --git a/.cursor/rules/tech-stack/dto-serialization.mdc b/.cursor/rules/tech-stack/dto-serialization.mdc deleted file mode 100644 index adbeaf17..00000000 --- a/.cursor/rules/tech-stack/dto-serialization.mdc +++ /dev/null @@ -1,54 +0,0 @@ ---- -description: kotlinx.serialization DTO rules — when to use @SerialName and @JsonNames -globs: **/*Dto.kt,**/dto/*.kt,**/remote/*.kt -alwaysApply: false ---- - -# DTO Serialization (kotlinx.serialization) - -## @SerialName — Use Only When JSON Key Differs - -- **Omit `@SerialName` when the JSON key and the Kotlin property name are identical.** - kotlinx.serialization uses the property name as the serial name by default; adding `@SerialName("sameName")` is redundant and inconsistent. - -- **Use `@SerialName` only when the JSON key differs from the property name** (e.g. API uses different casing or spelling). - Examples: `@SerialName("timeLetterID") val timeLetterId` (API uses capital `ID`), `@SerialName("profileUrl") val profileUrl` only if the API key were different. - -### Good - -```kotlin -@Serializable -data class MusicTrackDto( - val artist: String, - val title: String, - val albumImageUrl: String? = null -) -``` - -```kotlin -@Serializable -data class CreateTimeLetterReceiverRequestDto( - @SerialName("timeLetterID") val timeLetterId: Long, // JSON key differs (ID vs Id) - val receiverIds: List, - val deliveredAt: String? = null -) -``` - -### Avoid - -```kotlin -@SerialName("name") val name: String -@SerialName("albumImageUrl") val albumImageUrl: String? = null -``` - -## @JsonNames — Alternate Keys - -- Use **`@JsonNames("snake_case", "other_key")`** when the API can return more than one key for the same field (e.g. camelCase and snake_case). - Keep the Kotlin property name as the primary serial name; do not add a redundant `@SerialName` that matches the property. - -Example: `@JsonNames("afternote_id") val afternoteId: Long` - -## Consistency - -- Do not add `@SerialName` "for consistency" when the key and property already match. - Consistency in this project means: **no `@SerialName` when key and property are the same**. diff --git a/.cursor/rules/tech-stack/optimistic-updates.mdc b/.cursor/rules/tech-stack/optimistic-updates.mdc deleted file mode 100644 index 2b26702d..00000000 --- a/.cursor/rules/tech-stack/optimistic-updates.mdc +++ /dev/null @@ -1,119 +0,0 @@ -## Optimistic Updates Pattern (낙관적 업데이트 패턴) - Critical - -### When to Use -- **Non-critical updates**: Profile, settings, preferences -- **High success rate operations**: Toggle switches, simple updates -- **User-initiated actions**: Where immediate feedback is expected - -### Implementation Pattern - -1. **UiState에 needsRollback 플래그 추가**: -```kotlin -data class MyUiState( - val success: Boolean = false, - val needsRollback: Boolean = false // Optimistic update 실패 시 rollback 필요 -) -``` - -2. **ViewModel에서 Optimistic Update 구현**: -```kotlin -fun updateSomething() { - viewModelScope.launch { - // Optimistic: 즉시 성공 상태로 설정 - _uiState.update { - it.copy(success = true, needsRollback = false) - } - - // 백그라운드에서 실제 API 호출 - useCase.update() - .onFailure { e -> - // Rollback: 성공 상태 취소 및 에러 표시 - _uiState.update { - it.copy( - success = false, - needsRollback = true, - errorMessage = mapErrorToUserMessage(e) - ) - } - } - } -} -``` - -3. **Screen에서 Rollback 처리**: -```kotlin -LaunchedEffect(uiState.success, uiState.needsRollback) { - if (uiState.needsRollback) { - viewModel.clearSuccess() - viewModel.clearRollback() - return@LaunchedEffect - } - - if (uiState.success && !uiState.needsRollback) { - // Navigation or success feedback - } -} -``` - -### Rollback Handling -- **Navigation 취소**: `needsRollback`이 true면 navigation을 취소 -- **에러 표시**: Rollback 시 에러 메시지를 사용자에게 표시 -- **상태 초기화**: Rollback 후 `clearRollback()` 호출하여 상태 초기화 - -### Race Condition 처리 (연속 요청 시) - Critical - -연속적인 요청이 발생할 수 있는 경우 (예: 토글 스위치를 빠르게 클릭, 연속 저장): - -**핵심 원칙:** -1. **이전 상태 스냅샷**: Rollback 시 이전 상태로 복원할 수 있도록 스냅샷 저장 -2. **진행 중 요청 식별자**: 각 요청에 고유 ID를 부여하여 최신 요청만 처리 -3. **에러 메시지 정책**: 사용자에게 보여줄 에러 메시지 정책 명확화 - -**Race Condition 처리 예시:** -```kotlin -private var currentRequestId = 0 - -fun updateSomething() { - viewModelScope.launch { - val requestId = ++currentRequestId - val previousState = _uiState.value // 이전 상태 스냅샷 저장 - - // Optimistic: 즉시 성공 상태로 설정 - _uiState.update { - it.copy(success = true, needsRollback = false) - } - - // 백그라운드에서 실제 API 호출 - useCase.update() - .onSuccess { - // 최신 요청인지 확인 (레이스 컨디션 방지) - if (requestId == currentRequestId) { - // 이미 성공 상태이므로 추가 업데이트 불필요 - } - } - .onFailure { e -> - // 최신 요청인지 확인 (레이스 컨디션 방지) - if (requestId == currentRequestId) { - // Rollback: 이전 상태로 복원 - _uiState.update { - previousState.copy( - success = false, - needsRollback = true, - errorMessage = mapErrorToUserMessage(e) - ) - } - } - } - } -} -``` - -**Race Condition이 필요 없는 경우:** -- 사용자가 연속으로 호출할 가능성이 낮은 작업 (예: 비밀번호 변경) -- 단순히 `needsRollback` 플래그만 사용해도 충분 - -### Avoid For -- Security-critical operations (password changes - use with caution) -- Financial transactions -- Irreversible operations -- Operations requiring server-side validation diff --git a/.cursor/rules/workflow/api-implementation.mdc b/.cursor/rules/workflow/api-implementation.mdc deleted file mode 100644 index 2bc2828d..00000000 --- a/.cursor/rules/workflow/api-implementation.mdc +++ /dev/null @@ -1,11 +0,0 @@ ---- -description: API 구현 시 참고 규칙 -alwaysApply: true ---- - -# API Implementation Rules (API 구현 규칙) - -## API 구현 시 참고 - -- **참고 자료**: 구현 시 `docs/API specification from notion/엔드포인트` 및 동일 폴더 CSV, Swagger UI(https://afternote.kro.kr/swagger-ui/index.html), OpenAPI JSON(`docs/openapi.json`)을 참고할 수 있습니다. -- **우선순위**: 명세서·Swagger와 구두/문서 요청이 불일치할 경우, 팀 합의 또는 백엔드 담당자 확인을 권장합니다. diff --git a/.cursor/rules/workflow/branch-workflow.mdc b/.cursor/rules/workflow/branch-workflow.mdc deleted file mode 100644 index d268ab9e..00000000 --- a/.cursor/rules/workflow/branch-workflow.mdc +++ /dev/null @@ -1,69 +0,0 @@ ---- -description: 브랜치 작업 및 워크플로우 규칙 -globs: "**/*" -alwaysApply: true ---- - -# Branch Workflow Rules (브랜치 작업 규칙) - -## 브랜치 전환 가이드 (권장) - -**사용자가 어떤 브랜치에서 작업을 요청하든, 아래 절차를 권장합니다:** - -2. **브랜치 선택 기준** - - 작업 내용을 분석하여 적절한 브랜치 타입을 결정합니다: - * 새로운 기능 추가 → `feat/{feature-name}` - * 버그 수정 → `fix/{bug-description}` - * 문서 수정 → `docs/{document-name}` - * 설정/도구 변경 → `chore/{tool-name}` - * 리팩토링 → `refactor/{component-name}` - -3. **예외: 현재 브랜치가 작업 내용과 일치하는 경우** - - 현재 브랜치가 작업 요청 내용과 정확히 일치하는 경우에만 현재 브랜치에서 작업합니다 - - 예: `feat/login-screen` 브랜치에서 로그인 화면 관련 작업을 요청한 경우 - -### 규칙 - -2. **브랜치 네이밍 규칙** - - `feat/`: 새로운 기능 - - `fix/`: 버그 수정 - - `docs/`: 문서 수정 - - `chore/`: 기타 작업 (설정, 도구 등) - - `refactor/`: 리팩토링 - -3. **작업 흐름** - ``` - 사용자 작업 요청 - → 현재 브랜치 확인 - → 작업 내용 분석 - → 코드 생성/수정 - → 변경사항 커밋 - → 필요시 메인에 병합 - → (병합 완료 후) 브랜치 정리 - ``` - -4. **구현 요구 시 브랜치 사용 가이드 (권장)** - - 브랜치 이름은 작업 내용을 명확히 나타내는 것을 권장합니다. - - 예: `feat/add-custom-radio-button`, `fix/text-field-padding`, `refactor/move-component-to-core` - -5. **병합 제안 규칙 (Merge Suggestion Rules) - 안전 우선** - - **병합 자동 실행 금지**: 절대 자동으로 병합하지 않습니다. 대신 병합이 필요한 경우 사용자에게 제안만 합니다. - - **병합 제안 조건**: 작업이 완료되고 다음 조건을 만족하는 경우 병합을 제안할 수 있습니다: - * 작업이 완료되었고 테스트가 통과한 경우 - * 다른 브랜치와의 충돌이 없는 경우 - -6. **예외 상황** - - 현재 브랜치의 주제와 직접 관련된 변경은 현재 브랜치에서 작업 가능 - - 긴급한 버그 수정은 별도 브랜치 생성 후 메인에 병합 - -7. **⚠️ Critical: Main 브랜치 버전 제약 규칙 (Version Constraint Rule)** - - **원칙**: 작업 브랜치의 의존성 버전(예: AGP, Kotlin 등)은 main 브랜치의 버전을 초과하지 않아야 합니다. - - **예외**: main 브랜치 업그레이드가 작업의 목적인 경우는 예외로 합니다 (예: `chore(gradle): upgrade AGP to X.Y.Z`). - - **적용 대상**: - * Android Gradle Plugin (AGP) - * Kotlin 버전 - * 기타 주요 빌드 도구 버전 - -8. **브랜치 정리 규칙 (Branch Cleanup Rule) - Critical** - - `main`에 **성공적으로 병합된 브랜치**는 작업이 끝났다면 정리(삭제)합니다.(`main`/`master`/`develop` 및 현재 브랜치는 제외) - - 자세한 조건/명령어는 `.cursor/rules/workflow/git-policy.mdc`의 “Branch Cleanup Rule”을 따릅니다. \ No newline at end of file diff --git a/.cursor/rules/workflow/claude-md-sync.mdc b/.cursor/rules/workflow/claude-md-sync.mdc deleted file mode 100644 index c17107f3..00000000 --- a/.cursor/rules/workflow/claude-md-sync.mdc +++ /dev/null @@ -1,36 +0,0 @@ ---- -description: Keep CLAUDE.md in sync with .cursor/rules whenever rules are added, removed, or renamed -globs: ".cursor/rules/**/*.mdc,CLAUDE.md,.cursorrules" -alwaysApply: true ---- - -# CLAUDE.md Sync Rule - Critical - -## Responsibility - -- **You (the AI) are responsible for keeping `CLAUDE.md` in sync with the Cursor rules.** The user does not maintain `CLAUDE.md`; you must update it whenever rules change. - -## When to Update CLAUDE.md - -**Whenever you add, remove, or rename any file under `.cursor/rules/`**, you **MUST** update `CLAUDE.md` in the same change so that: - -1. The **Detailed Guidelines** section accurately lists all current rule files. -2. Each rule is referenced under the correct subsection: **Workflow**, **Tech Stack**, or **Quality**. -3. Format: one line per rule: `- @.cursor/rules//.mdc - **` -4. No stale references: remove entries for deleted or renamed rules; add entries for new rules. - -## How to Update - -1. **After adding a rule:** Add a new line under the appropriate subsection (Workflow / Tech Stack / Quality) with the `@` path and a brief description. -2. **After removing a rule:** Remove that rule’s line from `CLAUDE.md`. -3. **After renaming a rule:** Remove the old line and add a new line with the new path and description. -4. **Order:** Keep a consistent order within each subsection (e.g. alphabetical by filename or by logical group). - -## CLAUDE.md Structure to Maintain - -- **Core Rules:** `@.cursorrules` (do not remove). -- **Workflow:** All `.cursor/rules/workflow/*.mdc` files. -- **Tech Stack:** All `.cursor/rules/tech-stack/*.mdc` files. -- **Quality:** All `.cursor/rules/quality/*.mdc` files. - -Do not leave `CLAUDE.md` out of date when you modify `.cursor/rules/` or `.cursorrules`. diff --git a/.cursor/rules/workflow/created-modified-checks.mdc b/.cursor/rules/workflow/created-modified-checks.mdc deleted file mode 100644 index c80b0dde..00000000 --- a/.cursor/rules/workflow/created-modified-checks.mdc +++ /dev/null @@ -1,38 +0,0 @@ ---- -description: Commitlint, ktlint, and app distribution checks for created or modified files -globs: "**/*.kt,**/*.kts,.github/**/*.yml,.github/**/*.yaml,commitlint.config.cjs,build.gradle.kts,settings.gradle.kts,**/build.gradle.kts,gradle.properties" -alwaysApply: true ---- - -# Required Checks for Created or Modified Files - -**Scope**: Only files you create or modify. Existing untouched files are out of scope. For any file you create or change, ensure the following before considering the task done. - -## 1. Commitlint - -- **When**: You suggest or write commit messages or PR titles. -- **Requirement**: Use Conventional Commits; every line (subject, body, footer) ≤ 100 characters. -- **Config**: `commitlint.config.cjs`. -- When proposing commit/PR text, run `echo "type(scope): subject" | npx commitlint` (or equivalent) to verify. CI: `.github/workflows/commitlint.yml`. - -## 2. Ktlint - -- **When**: You create or modify Kotlin sources (`.kt`, `.kts`) or Gradle scripts. -- **Requirement**: Code must pass ktlint; follow `.editorconfig` and any `app/config/ktlint/baseline.xml` for exceptions. -- After editing Kotlin/Gradle files, run `./gradlew ktlintCheck` and fix any reported issues before marking the task complete. CI: `.github/workflows/ktlint.yml`. - -## 3. App Distribution Automation - -- **When**: You create or modify code, Gradle config, or CI workflows that affect the build or distribution pipeline. -- **Requirement**: Do not introduce errors in the app distribution automation (debug build + Firebase App Distribution). `./gradlew assembleDebug` must succeed; workflow steps in `.github/workflows/firebase_app_distribution.yml` (e.g. google-services.json, Build Debug APK, Upload to Firebase) must remain valid. -- After changes that could affect build/distribution, run `./gradlew assembleDebug` and fix any failures before marking the task complete. - -## Completion checklist (for created or modified work) - -Before considering the task done: - -1. Propose commit/PR text that follows Conventional Commits (and would pass commitlint). -2. After changing Kotlin/Gradle files: run `./gradlew ktlintCheck` and fix violations. -3. After changes that affect build/distribution: run `./gradlew assembleDebug` and fix build errors. - -Optionally: `./scripts/check-pr-commitlint-ktlint.sh` to verify commitlint + ktlint together. diff --git a/.cursor/rules/workflow/debugging-investigation.mdc b/.cursor/rules/workflow/debugging-investigation.mdc deleted file mode 100644 index dec80490..00000000 --- a/.cursor/rules/workflow/debugging-investigation.mdc +++ /dev/null @@ -1,23 +0,0 @@ ---- -description: Debugging and investigation — log first, no speculative fixes -globs: "**/*" -alwaysApply: true ---- - -# Debugging & Investigation (디버깅·조사) - -## ⚠️ Log First, No Strange or Complicated Things - Critical - -When the user reports **unclear behavior**, **bugs**, or **"X doesn't work" / "Y never appears"**: - -1. **Do first**: Add **logging** (or minimal instrumentation) so you can see actual values and flow (e.g. `Log.d(TAG, "key=$value")`, or log which branch is taken). Suggest where to look in Logcat. -2. **Do not** implement speculative fixes, refactors, placeholder/error handling, new parameters, or other "strange or complicated" changes **before** the root cause is known or confirmed via logs. -3. **Only after** logs (or user confirmation) show the cause, propose or implement a **targeted** fix. - -### Why -- Guessing the cause and adding logic (e.g. `isNullOrBlank()`, Coil placeholder/error, new UI) can hide the real bug or add unnecessary complexity. -- Logging reveals what is actually happening so the fix can be minimal and correct. - -### Summary -- **Before doing strange and complicated things, just log.** -- Do not add fixes or refactors before logging to understand the problem. diff --git a/.cursor/rules/workflow/edit-screen-and-nav-state.mdc b/.cursor/rules/workflow/edit-screen-and-nav-state.mdc deleted file mode 100644 index 933f427f..00000000 --- a/.cursor/rules/workflow/edit-screen-and-nav-state.mdc +++ /dev/null @@ -1,51 +0,0 @@ ---- -description: Edit screen data source, category identification, and nav-level state holder lifecycle -globs: "**/afternote/**/AfternoteEdit*.kt", "**/afternote/**/AfternoteNavgraph*.kt", "**/navgraph/**/*Edit*.kt" -alwaysApply: false ---- - -# Edit Screen Data Flow & Nav-Level State - Critical - -These rules prevent bugs where edit-screen data is wrong, missing, or stale due to wrong data source, title-based category inference, or reused state from a previous session. - -## 1. Single Data Source When Editing by ID - -When the user enters **edit mode** (route has an itemId / existing item): - -- **Use the detail API as the single source of truth.** Load edit data only via the detail API (e.g. `loadForEdit` → `getDetailUseCase`), then map into edit state (e.g. `LoadFromExistingParams` → `state.loadFromExisting()`). -- **Do not pass or use list-item data** (e.g. `initialItem` from the list) to populate edit state when `route.itemId != null`. The list API often omits fields (e.g. `processMethod`, `informationProcessingMethod`, gallery actions). Using both list and detail causes: - - **Race condition**: A synchronous `LaunchedEffect(initialItem?.id)` can run first and call `loadFromExisting` with incomplete data, setting `loadedItemId`. When the async detail API completes and tries to apply the correct data, the guard `state.loadedItemId != route.itemId` is already false, so the detail result is discarded. -- **Fix pattern**: When `route.itemId != null`, pass `initialItem = null` so only `loadForEdit` (detail API) fills the edit state. - -## 2. Identify Category from API / ServiceType, Not from Title - -- **Do not infer category** (e.g. Gallery, Social, Playlist) from **display titles** (e.g. "갤러리", "파일", "소셜네트워크"). Titles can be localized or user-edited and are unreliable for logic. -- **Use API category or ServiceType** from the detail response (e.g. `detail.category` → `"GALLERY"`, `"SOCIAL"`) and map to edit-screen display strings (e.g. `serverCategoryToEditScreenCategory(detail.category)` → `categoryDisplayString`). -- **Edit screen category state** (e.g. `selectedCategory`) must be set from this API-derived value when loading for edit, so the correct UI section (Gallery vs Social vs Information) and process-method options are shown. - -## 3. Category-Specific List Mapping in loadForEdit - -When building edit-state params from the detail API (e.g. `LoadFromExistingParams`): - -- **Route list fields by category.** For example: - - **Gallery**: `detail.actions` (or equivalent) → `galleryProcessingMethodsList`; do **not** put Gallery actions into the social/business list or hardcode `galleryProcessingMethodsList = emptyList()`. - - **Social/Business/Other**: `detail.actions` → `processingMethodsList` (or the list used by the Social section). -- **Do not hardcode** category-specific lists (e.g. `galleryProcessingMethodsList`) to `emptyList()` for all categories; populate them from the detail response when the category matches. -- **Process-method enum mapping** (e.g. `serverProcessMethodToInfoEnum`) should apply only to the categories that use that enum (e.g. Gallery/Information), not to Social. Use explicit checks (e.g. `isGalleryCategory`) so Social does not get the wrong mapping. - -## 4. Edit State Holder Lifecycle (Create vs Edit) - -When using a **NavGraph-level state holder** for the edit screen (e.g. `editStateHandling.holder`): - -- **Create mode** (`route.itemId == null`): Always use a **fresh** edit state and **clear** the holder (or bypass it). Do not use `editStateHandling.holder.value ?: newState` when in create mode, because: - - The holder is only cleared on explicit paths (e.g. TopBar back, save success). If the user left the previous edit session via system back or bottom nav, `onClear()` may never run, so the holder still holds the previous edit’s data. - - Reusing that holder on the next screen (create) causes **stale data** from the last edited afternote (e.g. gallery processing methods, category, processMethod) to appear on the new-afternote creation screen. -- **Fix pattern**: When `route.itemId == null`, set state to a fresh instance (e.g. `newState` only) and set `editStateHandling.holder.value = null` so the next composition does not reuse stale state. - -## 5. State Holder Instance Consistency with LaunchedEffect + Async Load - -When a `LaunchedEffect` (or similar) triggers an async call (e.g. detail API) and then writes to a state holder (e.g. `state.loadFromExisting(params)`): - -- Ensure the **state reference** passed into the async flow is the **same instance** that the UI observes (e.g. the one stored in `editStateHandling.holder.value`). If the NavGraph uses `val state = editStateHandling.holder.value ?: newState` and the LaunchedEffect captures `state`, then: - - If at first composition `holder.value` is null, `state` is `newState`; that same `newState` must later be stored in the holder so that when the async callback runs, it writes to the instance the UI is reading. -- Avoid patterns where the LaunchedEffect captures one instance (e.g. `newState`) but the UI later observes another (e.g. a different instance later stored in the holder), which would make the loaded data never appear. diff --git a/.cursor/rules/workflow/execute-dont-instruct.mdc b/.cursor/rules/workflow/execute-dont-instruct.mdc deleted file mode 100644 index 88d5d975..00000000 --- a/.cursor/rules/workflow/execute-dont-instruct.mdc +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Execute requested actions instead of only giving instructions -globs: "**/*" -alwaysApply: true ---- - -# Execute, Don't Instruct - Critical - -**When the user asks you to do something, perform the action yourself when possible. Do not respond with only instructions, links, or templates for the user to do it.** - -## Rule - -1. **Do the action first.** If the user asks you to create a PR, run a command, apply a change, or write something: - - **Create the PR**: Run `gh pr create` (or equivalent) with title and body. Do not only give a link and suggested content. - - **Run a command**: Run it (e.g. `./gradlew ktlintCheck`, `git push`). Do not only list the commands for the user to run. - - **Apply a change**: Edit files, create files. Do not only describe what to change or paste code for the user to apply. - -2. **Fall back to instructions only when execution is not possible**, for example: - - The required tool is not available (e.g. `gh` not installed or not authenticated). - - The action requires user input or auth that failed (e.g. login prompt). - - The user is in Ask mode and file modification is disabled. - -3. **Do not assume the user will perform the action.** Do not respond with "here's how" or "here's the content, you can create the PR" when you could have created the PR (or run the command, or made the edit) yourself. - -## Summary - -- **Wrong**: User says "create a PR" → You give a link and PR title/body and say "create the PR here." -- **Right**: User says "create a PR" → You run `gh pr create` with the title and body and report the PR URL. diff --git a/.cursor/rules/workflow/gemini-prompt-web-search.mdc b/.cursor/rules/workflow/gemini-prompt-web-search.mdc deleted file mode 100644 index 8a587632..00000000 --- a/.cursor/rules/workflow/gemini-prompt-web-search.mdc +++ /dev/null @@ -1,44 +0,0 @@ ---- -description: Gemini prompts — always require web search, no project context, reply = prompt only -alwaysApply: true ---- - -# Gemini Prompts — Web Search and Context - -## 1. Gemini has no project context - -**Gemini does not have access to this project.** It cannot see the repository, open files, Cursor rules, or any project-specific context. - -When you **write or create a prompt for Gemini** (or content meant to be used in Gemini): - -- **Assume zero project context:** Assume Gemini cannot see this codebase, `.cursorrules`, or `.cursor/rules/`. -- **Make the prompt self-contained:** Either embed in the prompt all information Gemini needs (relevant code snippets, file paths, constraints, or specs), or explicitly tell the user to paste that context into the same or a follow-up message. -- **Avoid references that only work in Cursor:** Do not say things like "see the open file" or "as in our rules"; include the actual content or tell the user to paste it. - -## 2. Web search: always required - -When the user asks you to **write**, **create**, or **draft a prompt for Gemini** (or any prompt intended to be used with Gemini), the generated prompt **must** instruct Gemini to **always perform web search** (not only "when appropriate" or "when needed"). The instruction should make web search a required part of Gemini's process for that task. - -## What to add - -Include one of the following (or equivalent) in the prompt you produce: - -- "Always use web search (e.g. Google Search) before or while answering; do not rely only on your training data." -- "You must perform web search to look up current or factual information as part of answering this request." -- "Use web search for this task. Do not skip web search; include it as a step in your process." - -Avoid wording that makes web search optional (e.g. "when needed", "if relevant", "when appropriate"). The prompt should require Gemini to actually perform web search for the user's request. - -Place this instruction in a visible place in the prompt (e.g. at the start under "Instructions" or in a "Guidelines" section). - -## 3. Reply format: prompt only - -When you **deliver** a Gemini prompt to the user (in reply to a request to write or create a prompt for Gemini), your reply must contain **only** the content intended to be pasted directly into Gemini. - -- **No wrapper or copy-paste instructions:** Do not add any introductory or closing text that tells the user how to use the prompt. Forbidden examples: "Use this as the Gemini prompt (copy and paste as-is):", "Use this prompt:", "Paste the following into Gemini:", "Copy the block below:", "You can copy and paste the following:", or similar. Just output the prompt. -- **No extra markdown or separators** whose sole purpose is to frame the prompt (e.g. avoid a line of "---" or "Paste the whole block below" after the prompt). -- **Output = the prompt itself:** The user should be able to select all of your reply and paste it straight into Gemini with no editing. If you need a minimal separator (e.g. to distinguish code from prose inside the prompt), keep it part of the prompt content that Gemini will see, not as Cursor-side instructions to the user. - -## Scope - -These rules apply when the user explicitly asks you to **write** or **create** a prompt **for Gemini** (or for use with Gemini). They do not apply to general Cursor instructions or to prompts for other tools unless the user specifies the same requirement. diff --git a/.cursor/rules/workflow/git-policy.mdc b/.cursor/rules/workflow/git-policy.mdc deleted file mode 100644 index d8e06d71..00000000 --- a/.cursor/rules/workflow/git-policy.mdc +++ /dev/null @@ -1,56 +0,0 @@ ---- -description: Git 작업 및 브랜치 관리 정책 -globs: "**/*" -alwaysApply: true ---- - -# Git & Commit Rules (커밋 규칙) - -## ⚠️ GIT PROTOCOL - STRICT ENFORCEMENT - -### 5. 검증 절차 (Critical) -**푸시 명령 생성 전, 현재 브랜치가 `main`/`develop`이 아닌지 반드시 먼저 검증하시오.** -- `git branch --show-current`로 현재 브랜치 확인 -- 만약 `main`이나 `develop`이라면 작업을 중단하고 경고 메시지를 출력하시오 -- **물리적 제어**: 프로젝트 루트의 `scripts/pre-push-check.sh` 스크립트를 Git Hook으로 설정하여 시스템 레벨에서 차단하세요. - - 설정 방법: `cp scripts/pre-push-check.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push` -### 8. 브랜치 정리 규칙 (Branch Cleanup Rule) - Critical -**`main`에 성공적으로 병합된 이후(그리고 필요 시 푸시 완료 후) 자동으로 병합된 브랜치를 정리합니다.** - -#### 자동 정리 조건 -- **푸시 완료 후 자동 실행**: 작업 브랜치를 푸시한 후, `main` 브랜치에 병합된 브랜치를 자동으로 삭제합니다. -- **삭제 대상**: - * `main` 브랜치에 병합된 로컬 브랜치 (`git branch --merged main`) - * 현재 브랜치가 아닌 브랜치 - * `main`, `master`, `develop` 브랜치가 아닌 브랜치 - -## Conventional Commits -커밋 메시지는 아래 형식을 따릅니다: -`type(scope): subject` - -- **PR 제목**: Pull Request 생성 시에도 동일한 `type(scope): subject` 형식을 사용합니다. (`pull-request-review.mdc` 참고) -- **Types**: - - `feat`: 새로운 기능 추가 - - `fix`: 버그 수정 - - `refactor`: 코드 리팩토링 (기능 변경 없음) - - `style`: 포맷팅, 세미콜론 누락 등 (코드 변경 없음) - - `docs`: 문서 수정 - - `test`: 테스트 추가/수정 - - `chore`: 빌드 태스크, 패키지 매니저 설정 등 - - `build`: 빌드 시스템 또는 외부 종속성 변경 - - `ci`: CI 설정 파일 및 스크립트 변경 - - `perf`: 성능 개선 - -### Commitlint Line Length Rules (CI 강제) -**CI에서 `commitlint`가 커밋 메시지를 검증합니다. 다음 규칙을 반드시 준수하세요.** - -- **PR 통과 조건:** PR **제목**이 commitlint(Conventional Commits) 규칙을 만족해야 합니다. - -1. **Subject Line (제목)**: 최대 100자 (`header-max-length`) -2. **Body Line (본문 각 줄)**: 최대 100자 (`body-max-line-length`) -3. **Footer Line (꼬리말 각 줄)**: 최대 100자 (`footer-max-line-length`) - -#### 본문 작성 규칙 -- 본문의 **모든 줄**이 100자를 초과하면 안 됩니다 -- 긴 설명은 줄바꿈으로 나누어 작성하세요 -- 함수명, 클래스명 등이 길면 줄을 나누어 작성하세요 diff --git a/.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc b/.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc deleted file mode 100644 index b3dabad1..00000000 --- a/.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Do not include "Made with Cursor" or Cursor attribution in PRs or commits -alwaysApply: true ---- - -# No Cursor Attribution in PR / Commits - -**Do not include "Made with Cursor" or any Cursor branding in content that is committed or submitted to the repo.** - -## Applies to - -- **PR title and body**: Do not add "Made with Cursor", Cursor links, or similar attribution in pull request descriptions or titles. -- **Commit messages**: Do not add Cursor attribution in commit subject, body, or footer. -- **Code and docs**: Do not add "Made with Cursor" (or similar) in comments, README, or any file that would be part of a PR. - -## Rule - -When generating or suggesting text for PRs, commits, or repo-facing content, omit any line or block that promotes or attributes the content to Cursor (e.g. "Made with Cursor", Cursor badges, or Cursor marketing links). Keep content professional and tool-agnostic. diff --git a/.cursor/rules/workflow/pr-ci-checks.mdc b/.cursor/rules/workflow/pr-ci-checks.mdc deleted file mode 100644 index dd8f4424..00000000 --- a/.cursor/rules/workflow/pr-ci-checks.mdc +++ /dev/null @@ -1,36 +0,0 @@ ---- -description: PR/CI required checks - commitlint, ktlint, build -globs: "**/*" -alwaysApply: true ---- - -# Required Checks (PR/CI) - -**Code must pass these checks before merge. Follow them when writing or changing code.** - -## 1. Commitlint - -- **Scope**: PR title and commit messages. -- **Rule**: Conventional Commits format; every line (subject, body, footer) ≤ 100 characters. -- **Config**: `commitlint.config.cjs`. -- **Verify locally**: `echo "type(scope): subject" | npx commitlint` -- **CI**: `.github/workflows/commitlint.yml` runs on PR (opened, synchronize, reopened). - -## 2. Ktlint - -- **Scope**: Kotlin code style (`.kt`, `.kts`). -- **Rule**: Code must pass ktlint; follow `.editorconfig` and `app/config/ktlint/baseline.xml` for existing exceptions. -- **Verify locally**: `./gradlew ktlintCheck` -- **CI**: `.github/workflows/ktlint.yml` runs on PR (targeting main/develop). - -## 3. Build / App Distribution - -- **Scope**: Repository state must allow a successful debug build and app distribution. -- **Rule**: Code and config must not break `./gradlew assembleDebug` or the steps used by Firebase App Distribution. -- **Verify locally**: `./gradlew assembleDebug` -- **CI**: `.github/workflows/firebase_app_distribution.yml` runs on PR merge to main; `.github/workflows/pr-build-check.yml` runs on PR (opened, synchronize, reopened) to catch build failures before merge. - -## Before Pushing / Opening PR - -- Run `./scripts/check-pr-commitlint-ktlint.sh` (optional) to verify commitlint + ktlint locally. -- Ensure PR title follows Conventional Commits; ensure `./gradlew ktlintCheck` and `./gradlew assembleDebug` pass. diff --git a/.cursor/rules/workflow/pull-request-review.mdc b/.cursor/rules/workflow/pull-request-review.mdc deleted file mode 100644 index dfdc48f7..00000000 --- a/.cursor/rules/workflow/pull-request-review.mdc +++ /dev/null @@ -1,99 +0,0 @@ ---- -description: Pull Request 작성 및 코드 리뷰 규칙 -globs: "**/*" -alwaysApply: true ---- - -# Pull Request & Code Review Rules (PR 및 코드 리뷰 규칙) - -## Pull Request 작성 규칙 - Critical - -**PR을 생성할 때는 아래 항목을 반드시 포함하여, 해당 PR이 어떤 내용을 다루거나 수정한 것인지 명확히 기술해야 합니다.** - -### 1. PR 제목 (Title) - -- **Conventional Commits 타입을 포함**합니다. (`git-policy.mdc`의 Conventional Commits 형식과 동일) -- 형식: `type(scope): subject` 또는 `[type] subject` -- **Types**: `feat`, `fix`, `refactor`, `style`, `docs`, `test`, `chore`, `build`, `ci`, `perf` - -#### 예시 -- `feat(afternote): 애프터노트 작성 화면 플레이리스트 곡 수 동기화` -- `fix(edit): 노래 추가 화면 뒤로가기 시 잘못된 카테고리 복원 문제` -- `refactor(nav): MemorialPlaylist 공유 상태 holder 도입` - -### 2. PR 본문 (Description) 필수 항목 - -다음 항목을 포함하여 **어떤 내용을 다루거나 수정했는지**를 구체적으로 작성합니다. - -| 항목 | 설명 | 예시 | -|------|------|------| -| **목적 / 배경** | 이 PR이 해결하려는 문제나 추가하려는 기능 | "작성 화면 곡 수와 노래 추가 화면 곡 수 불일치", "뒤로가기 시 소셜네트워크로 초기화되는 문제" | -| **변경 사항 요약** | 수정/추가된 내용을 bullet으로 간단히 | - NavGraph에 playlistStateHolder, editState 호이스팅
- AddSongScreen에 currentPlaylistCount 전달 | -| **주요 수정 파일** | 변경된 주요 경로 (선택) | `NavGraph.kt`, `AfternoteEditScreen.kt`, `AddSongScreen.kt` | -| **테스트/검증** | 수행한 확인 내용 (가능 시) | "추모 가이드라인 → 노래 추가 → 뒤로가기 시 추모 가이드라인 유지 확인" | - -#### PR 본문 템플릿 (선택 사용) - -```markdown -## 목적 / 배경 -- - -## 변경 사항 요약 -- - -## 주요 수정 파일 -- - -## 테스트/검증 -- -``` - -### 3. PR 작성 시 주의사항 - -- **타입과 scope를 생략하지 않습니다.** PR만 봐도 `feat`/`fix`/`refactor` 등 성격을 알 수 있어야 합니다. -- **subject는 한글로**, 변경 의도를 한 문장으로 요약합니다. -- 여러 타입이 섞인 경우, **가장 비중이 큰 타입 하나**를 선택하고, 본문에 나머지를 기술합니다. - ---- - -## PR 통과 조건 (CI) - Critical - -**PR이 merge되려면 CI가 모두 통과해야 합니다.** 코드 리뷰 Approve만으로는 부족하고, 아래가 만족되어야 합니다. - -- **Commitlint:** PR **제목**이 Conventional Commits 형식(`type(scope): subject`)을 만족해야 합니다. -- **Ktlint:** PR 브랜치의 코드 전체가 ktlint를 통과해야 합니다. - -PR 생성·업데이트 전에 로컬에서 `./gradlew ktlintCheck`로 코드를 검사해 두는 것을 권장합니다. - ---- - -## 코드 리뷰 규칙 - Critical - -**코드 리뷰 없이 바로 merge하면, 잘못된 부분이 수정되지 않은 채 merge될 수 있습니다. 서로의 PR에 대해 코드 리뷰를 진행한 후 merge합니다.** - -### 2. 리뷰 시 확인할 것 (가이드) - -- **동작/로직**: 요구사항 대로 동작하는지, 엣지 케이스는 없는지 -- **아키텍처/의존성**: 레이어 분리, Feature 간 직접 참조, 불필요한 의존성 -- **코드 품질**: 네이밍, 함수 길이, 매직 넘버, `@Suppress` 남발 여부 -- **리소스/성능**: 메모리, Compose 재구성, 불필요한 remember/LaunchedEffect -- **규칙 준수**: `code-quality.mdc`, `architecture.mdc`, `compose.mdc`, `git-policy.mdc` 등 프로젝트 규칙 위반 여부 - -### 4. 리뷰 후 merge - -- **Request changes**가 해결되거나, **Comment**에 대해 작성자와 합의가 이루어진 뒤 merge합니다. -- 긴급/트리비얼 변경은 팀 합의에 따라 리뷰 생략을 허용할 수 있으나, **예외로 두고 기본은 리뷰 후 merge**를 원칙으로 합니다. - ---- - -## 워크플로우 요약 - -``` -브랜치 작업 → 커밋(Conventional Commits 권장) → 푸시 - → PR 생성 (제목: type(scope): subject, 본문: 목적/변경사항/수정 파일/검증) - → CI 통과 (commitlint: PR 제목, ktlint: 브랜치 코드) - → 코드 리뷰 요청 - → 팀원 리뷰 (Comment / Request changes) - → 피드백 반영 또는 논의 - → Approve 후 merge -``` diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index ddb9621c..00000000 --- a/.cursorrules +++ /dev/null @@ -1,154 +0,0 @@ -# ⚠️ CRITICAL RULES SUMMARY - READ FIRST - -**This section contains the core rules that AI must recognize first. For detailed explanations, refer to `.cursor/rules/*.mdc` files.** - ---- -# GIT PROTOCOL - STRICT ENFORCEMENT (Highest Priority) - - -## MUST DO -1. **Git**: - - **Verification**: Before push command, **must first verify** that current branch is not `main`/`develop` - - **Commitlint**: 커밋 메시지의 모든 줄(subject, body, footer)은 **100자 이내**. 긴 내용은 줄바꿈으로 분리 -2. **Branch (Recommended)**: Prefer using an appropriate branch (`feat/`, `fix/`, `docs/`, etc.) -3. **Branch Cleanup**: After a successful merge to `main`, delete merged branches - - **Local**: Delete branches that are merged into `main` (excluding `main`/`develop`/`master`) -4. **Preview**: All public Composables must have `@Preview`. Screens using `hiltViewModel()` must use a ViewModel contract + Fake in Preview (see compose.mdc) so Previews render without Hilt. -7. **Modifier**: If a Composable has a modifier parameter, it should be placed first. -8. **State**: State hoisting to caller, Stateless composable preferred - - **Complete State Hoisting**: All screen states (tabs, navigation items, etc.) must move to ViewModel -9. **Architecture**: Presentation → Domain ← Data (strict layer separation) -11. **ViewModel Injection**: `@HiltViewModel` uses `hiltViewModel()`, regular ViewModel uses `viewModel()` -12. **StateFlow Collection**: Use `collectAsStateWithLifecycle()` (never use `collectAsState()`) -13. **`.cursorrules` Management**: If this file exceeds 300 lines, consider splitting or keeping only core rules -15. **HTTP Status Code Tests Required**: ViewModels that call APIs must include HTTP error tests - - 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Server Error - - Network Error (IOException) - - See `.cursor/rules/quality/code-quality.mdc` for details -16. **Required checks**: Code must pass commitlint, ktlint, and build checks before merge (see `.cursor/rules/workflow/pr-ci-checks.mdc`) - -## MUST NOT DO -2. **Hand off instead of doing**: When the user asks you to do something (create PR, run command, apply change), do not respond with only instructions or content for the user to do it. Perform the action yourself when possible (e.g. run `gh pr create`, run gradle, edit files). Fall back to instructions only when execution is not possible (e.g. tool not available, auth failed). -3. `android.R` import -4. Wildcard imports (`import foo.*`) -5. Android/Data dependencies in Domain Layer -8. **File Modification in Ask Mode**: In Ask mode, STRICTLY ANSWER ONLY. Do not attempt file modification. Interpret questions as pure curiosity, not criticism or modification requests. -9. **ViewModel Injection Confusion**: Never use `viewModel()` for `@HiltViewModel` (runtime error) -10. **Non Lifecycle-aware State Collection**: Never use `collectAsState()` (memory leak risk) -11. **Local State Management**: Never manage screen state with `remember { mutableStateOf() }` inside Composable (move to ViewModel) - -## CRITICAL PATTERNS -- **Figma parity**: Hardcoded `dp` values are allowed in UI to quickly verify margins match Figma. - Avoid creating “new” values via meaningless arithmetic (e.g. `16.dp - 8.dp`). -- **Named Arguments Pattern**: Use explicit parameter names for readability and safety. Use `FontWeight.Medium` instead of `FontWeight(500)`. -- **Rem-based Design**: REM 구현 시 **weight 우선** — 큰 간격(24dp+)은 `Spacer(Modifier.weight(ratio))` 또는 `Arrangement` 사용. 스크롤 내부일 때만 `BoxWithConstraints`. 작은 간격(≤8dp)은 `Spacer`, 컴포넌트 크기는 고정 dp -- **Maximize Core Component Reuse**: Maximize reuse of common components in `core` module (`Header`, `LabeledTextField`, `MessageTextField`, `CustomRadioButton`, etc.) -- **Component Overloading**: When overall structure is similar but details differ, use **overloading** instead of separate components -- **Component Reuse**: If uncertain, use `private` function; if reuse confirmed, separate file -- **File Limit**: Split if exceeds 400 lines, maintain 10-15 files per folder -- **`.cursorrules` Optimization**: Recommend ≤300 lines, consider splitting to `.cursor/rules/*.mdc` if exceeded -- **Tests**: Check/add tests when UseCase/ViewModel changes -- **Parameter Refactoring Pattern**: When 10+ parameters needed, don't just wrap in data class, apply **section-based grouping** pattern -- **State Holder Pattern for Cognitive Complexity**: Must apply **State Holder pattern** to keep SonarQube cognitive complexity ≤15. No lambda definitions inside Composable, all logic moves to State class -- **Slot API Pattern (Composition over Inheritance)**: When unifying components with similar structure, use **composition, not inheritance**. Provide common shell and inject content via Slot (`content: @Composable () -> Unit`) -- **Component Location Rules**: Common components must be in `core` module. Direct dependencies between Feature modules are prohibited, common components must be in `core` -- **Code Structure Improvement Before @Suppress**: When detekt warning occurs, must try code structure improvement before using `@Suppress`. When parameters increase after State Hoisting pattern, group with Event Interface or State Class -- **Required Verification on Structure Changes**: File moves, package changes, class refactoring must follow: pre/post build check, search all usages, batch import path changes -- **Preview + Hilt ViewModel**: To avoid Preview render failure, use a ViewModel contract interface, real ViewModel implements it, screen takes contract with default `hiltViewModel()`, and Preview passes `remember { FakeXxxViewModel() }`. See `.cursor/rules/tech-stack/compose.mdc`. ---- - -# Project Context -- **Project Type**: Android App (Kotlin, Jetpack Compose) -- **Architecture**: Clean Architecture (Data, Domain, Presentation Layers) -- **Dependency Injection**: Hilt -- **Navigation**: Navigation Compose (Type-safe with kotlinx.serialization) -- **Code Quality**: Detekt -- **JDK**: JDK 21 (Eclipse Adoptium) - ---- - -# Rule File Structure - -This project uses `.cursor/rules/*.mdc` file structure to separate rules: - -- **`.cursor/rules/workflow/git-policy.mdc`**: Git operations and branch management policy (always applied: `alwaysApply: true`) -- **`.cursor/rules/workflow/branch-workflow.mdc`**: Branch work and workflow rules (always applied: `alwaysApply: true`) -- **`.cursor/rules/workflow/api-implementation.mdc`**: API implementation rules -- **`.cursor/rules/workflow/pull-request-review.mdc`**: Pull Request and code review rules -- **`.cursor/rules/workflow/execute-dont-instruct.mdc`**: Execute requested actions instead of only giving instructions -- **`.cursor/rules/workflow/pr-ci-checks.mdc`**: Required checks (commitlint, ktlint, build) before merge -- **`.cursor/rules/tech-stack/architecture.mdc`**: Clean Architecture and layer separation rules -- **`.cursor/rules/tech-stack/compose.mdc`**: Jetpack Compose coding rules and patterns -- **`.cursor/rules/tech-stack/android-kotlin.mdc`**: Android/Kotlin coding conventions and Import rules -- **`.cursor/rules/tech-stack/optimistic-updates.mdc`**: Optimistic Updates pattern -- **`.cursor/rules/quality/code-quality.mdc`**: Code quality, formatting, test rules - -Each `.mdc` file uses `globs` pattern so rules are injected only when opening or working on related files. -**Workflow rules (Git, branch) are applied to all files, so set `alwaysApply: true`.** - ---- - -# General Guidelines - -## Basic Principles - -3. **Work on Appropriate Branch** - - See `.cursor/rules/workflow/branch-workflow.mdc` for details - -4. **`.cursorrules` File Writing Rules** - - **Critical Rules Summary Required at Top**: When writing or modifying `.cursorrules` file, must include "⚠️ CRITICAL RULES SUMMARY" section at the top - - **Summary Structure**: - * MUST DO: List core rules concisely - * MUST NOT DO: Clearly mark prohibited items - * CRITICAL PATTERNS: Important pattern summary - - **Purpose**: Enable AI to recognize core rules at the beginning of Context Window to prevent rule ignoring or hallucination - -5. **Memo of Attempted Solutions** - - When solving problems, memo attempted methods - - To avoid repeating same mistakes - -7. **Component Preview Required** - - All component functions must include Preview function with `@Preview` annotation - - Previews should show various states (selected/not selected, enabled/disabled, etc.) - - Exception: Internal-only components (`internal` functions) are optional - -## Problem Solving & Research Rules - -### ⚠️ Critical: Required Verification Procedure on Structure Changes -**When performing structural changes (file moves, package changes, class refactoring), must follow these procedures:** - -2. **Search All Usages**: Search all locations where file to move/change is used with `grep` and list them -3. **Batch Import Path Changes**: Change import paths at all usages at once (don't do step by step, understand whole first) -4. **Variable/Function Name Changes**: When changing variable/function names, check all usages with `grep` then change in batch -6. **State/Params Class Moves**: When moving State or Params classes, check import paths of all components using that class -7. **Duplicate Removal**: Before removing duplicate files, check all usages, unify to one, then update all import paths - -### Perplexity Prompt Writing Request Rules - Critical -**When user requests Perplexity Prompt:** -- **Context limitation**: External tools (e.g. Perplexity) cannot see the project repository, open files, or Cursor rules. The prompt must either be self-contained with all information needed for the task, or explicitly instruct the user to paste the necessary context (e.g. code, rule excerpts) in the same or a follow-up message so the tool can perform the task. -- **Provide Prompt Content Only**: Provide only content to paste into prompt, nothing else -- **No Additional Explanation**: Do not include guidance like "paste this prompt into Perplexity" after writing prompt -- **No Code Modification**: Do not modify code simultaneously with prompt writing -- **Format**: Provide prompt as plain text without markdown code blocks (so user can copy-paste immediately) - ---- - -# ⚠️ CRITICAL RULES RE-CONFIRMATION (File End - Sandwich Technique) - -**When reading this file, must re-confirm the core rules above:** - -## Highest Priority Rules (Never Ignore) -1. **Git Work Pre-Verification**: Before commit/push command generation, **must first verify** that current branch is not `main`/`develop` -3. **`.cursorrules` Length Management**: If this file exceeds 300 lines, consider splitting or keeping only core rules - -## Core Technical Rules -- **ViewModel Injection**: `@HiltViewModel` uses `hiltViewModel()` -- **StateFlow Collection**: Use `collectAsStateWithLifecycle()` (never use `collectAsState()`) -- **State Hoisting**: All screen states move to ViewModel -- **Preview Required**: All public Composables must have `@Preview` - -## Rule File References -- For detailed rules, refer to `.cursor/rules/*.mdc` files -- Each file uses `globs` pattern so rules are automatically injected during related work - -**Violating the above rules may cause serious problems. Must comply.** diff --git a/.windsurfrules b/.windsurfrules deleted file mode 100644 index 33e0c30d..00000000 --- a/.windsurfrules +++ /dev/null @@ -1,437 +0,0 @@ -# 🚀 Android Project Rules (Windsurf) - -## 📋 Project Context -- **Project Type**: Android App (Kotlin, Jetpack Compose) -- **Architecture**: Clean Architecture (Data, Domain, Presentation Layers) -- **Dependency Injection**: Hilt -- **Navigation**: Navigation Compose (Type-safe with kotlinx.serialization) -- **Code Quality**: Detekt -- **JDK**: JDK 21 (Eclipse Adoptium) - ---- - -# 🛡️ CRITICAL SYSTEM INSTRUCTIONS (ALWAYS ACTIVE) - -## Git & Workflow Rules (항상 적용) - -### Git Operations - CRITICAL -**사용자가 명시적으로 커밋 또는 푸시를 요청하지 않으면 절대 커밋하거나 푸시하지 않습니다.** - -1. **커밋 금지 원칙** - - 사용자가 "커밋해", "commit", "git commit" 등 명시적으로 커밋을 요청하지 않으면 커밋하지 않습니다 - - 코드 변경만 하고 커밋은 하지 않습니다 - -2. **푸시 금지 원칙** - - 사용자가 "푸시해", "push", "git push" 등 명시적으로 푸시를 요청하지 않으면 푸시하지 않습니다 - - **검증 절차**: 푸시 명령 생성 전, 현재 브랜치가 `main`/`develop`이 아닌지 **반드시 먼저 검증** (`git branch --show-current`) - - 만약 `main`이나 `develop`이라면 작업을 중단하고 경고 메시지를 출력하시오 - -3. **병합 전략 (Merge Strategy) - Critical** - 사용자가 "메인에 병합해" 또는 "메인과 병합해"라고 요청한 경우: - - **1단계**: 현재 브랜치에 원격 main의 최신 변경사항을 먼저 가져와서 병합 (`git fetch origin main && git merge origin/main`) - - **2단계**: 충돌이 있으면 해결하고, 빌드 확인은 사용자가 명시적으로 요청한 경우에만 실행 - - **3단계**: 문제가 없을 때만 main 브랜치로 체크아웃하여 현재 브랜치를 병합 - - **4단계**: main 브랜치에 푸시 - - **원칙**: "먼저 현재 브랜치에 main을 병합하고, 문제 없을 때만 main에 반영" - -### Branch Workflow - CRITICAL -**변경사항은 항상 주제에 맞는 브랜치에서 작업해야 합니다.** - -1. **작업 요청 시 즉시 브랜치 확인 및 전환** - - 작업 요청을 받으면 먼저 현재 브랜치를 확인 (`git branch --show-current`) - - 작업 내용에 맞는 적절한 브랜치가 있으면 해당 브랜치로 체크아웃 - - 적절한 브랜치가 없으면 새로 생성하고 체크아웃 - - **이 작업은 코드 생성/수정 전에 반드시 수행해야 합니다** - -2. **브랜치 선택 기준** - - 새로운 기능 추가 → `feat/{feature-name}` - - 버그 수정 → `fix/{bug-description}` - - 문서 수정 → `docs/{document-name}` - - 설정/도구 변경 → `chore/{tool-name}` - - 리팩토링 → `refactor/{component-name}` - -3. **브랜치 정리** - - 푸시 완료 후 main에 병합된 로컬 브랜치 자동 삭제 - - 원격 브랜치도 정리 - -### Build Rules - CRITICAL -**사용자가 명시적으로 요청하지 않으면 빌드를 자동으로 실행하지 않음** -- Android Studio와 동시 사용 시 파일 잠금 문제가 발생할 수 있음 -- 빌드가 필요한 경우 사용자에게 요청하거나, 사용자가 명시적으로 "빌드해", "build", "컴파일 확인해" 등을 요청한 경우에만 실행 - ---- - -# 📱 Tech Stack Rules - -## Conditional Rules -- **IF user is editing `*.kt` files**: Apply Kotlin/Android/Compose rules strictly -- **IF user is editing `*.gradle.kts` files**: Apply dependency management rules strictly -- **IF user asks about UI/Layout/Compose**: Refer to the "Jetpack Compose Rules" section -- **IF user performs git operations**: STRICTLY follow "Git Policy" section -- **IF user is working on architecture/domain/data layers**: Refer to "Clean Architecture Rules" - -## Jetpack Compose Rules - -### Composable Functions -1. **State Hoisting** - CRITICAL - - 상태(`State`)는 호출부로 끌어올리고, Composable은 상태를 인자로 받아야 합니다 (Stateless 지향) - - **완전한 State Hoisting**: 화면 내부의 **모든 상태**(탭 선택, 네비게이션 아이템 선택 등)는 ViewModel로 이동해야 합니다 - - **로컬 상태 금지**: Composable 내부에서 `remember { mutableStateOf() }`로 상태를 관리하지 말고, ViewModel의 State로 관리하세요 - -2. **Lifecycle-aware State Collection** - - **❌ Deprecated**: `collectAsState()` 사용 금지 (메모리 누수 위험) - - **✅ 권장**: `androidx.lifecycle.compose.collectAsStateWithLifecycle()` 사용 - - **Import**: `import androidx.lifecycle.compose.collectAsStateWithLifecycle` (필수) - -3. **Modifier** - - 모든 Composable 함수는 `modifier: Modifier = Modifier`를 첫 번째 선택적 파라미터로 받아야 합니다 - -4. **Preview (필수)** - - **모든 public Composable 함수는 반드시 `@Preview` 어노테이션을 사용한 Preview 함수를 포함해야 합니다** - - Preview 함수는 `private`으로 선언하고, `@Preview(showBackground = true)`를 사용합니다 - -5. **Rem-based Responsive Design** - - **컴포넌트 크기**: 피그마의 dp 값을 그대로 사용 (예: `height(56.dp)`, `width(350.dp)`) - - **Padding 지양, Spacer 사용**: 컴포넌트에 `Modifier.padding()`을 직접 적용하지 말고, `Spacer`를 사용하여 간격을 표현 - - **큰 간격은 weight과 Arrangement로 해결**: padding 값이 큰 경우(24dp 이상) `Spacer(Modifier.weight())` 또는 `Arrangement.SpaceBetween` 등으로 해결 - - **화면 가장자리 padding만 고정값 허용**: 일관성 유지 (예: `padding(horizontal = 20.dp)`) - -6. **State Holder Pattern (Cognitive Complexity 해결)** - - **목표**: SonarQube 인지 복잡도(Cognitive Complexity)를 15 이하로 유지 - - **핵심 원칙**: "로직과 상태를 Composable에서 분리해내는 것(State Holder 패턴)" - - **필수 적용 규칙**: - 1. State 클래스 생성: 모든 상태 변수와 로직을 `@Stable` 클래스로 그룹화 - 2. 로직 캡슐화: Composable 내부의 조건문, 람다 내부 중첩 조건문을 State 클래스의 멤버 함수로 이동 - 3. Computed Properties: UI 내 삼항 연산자나 조건문을 State 클래스의 computed property로 이동 - 4. 콜백 람다 제거: Composable 내부에서 `val create...Callbacks = { ... }` 같은 람다 정의 금지 - 5. 함수 참조 사용: State의 메서드는 함수 참조(`::`)로 전달 - -7. **Parameter Refactoring Pattern (LongParameterList 해결)** - - **⚠️ Critical: @Suppress는 최후의 수단** - - **콜백 그룹화**: 3개 이상의 콜백이 있으면 Event Interface나 data class로 묶기 - - **UI 상태 캡슐화**: `expanded`, `boxWidth` 같은 UI 상태는 State Class로 묶기 - - **초기값 처리**: `initialShowTextField`, `initialExpandedItemId` 같은 초기값은 State 생성자로 옮기기 - -8. **Core 컴포넌트 최대한 활용** - - `core` 모듈의 공통 컴포넌트(`Header`, `LabeledTextField`, `MessageTextField`, `CustomRadioButton`, `BottomNavigationBar` 등)를 최대한 재사용 - - 전체적인 틀은 비슷하고 세부 디자인만 다른 경우, 별도 컴포넌트 대신 **오버로딩**으로 처리 - -9. **Slot API 패턴 (Composition over Inheritance)** - - 유사한 구조를 가진 컴포넌트들을 통일할 때는 **상속이 아닌 합성(Composition)**을 사용 - - 공통 껍데기를 제공하고, 내용물은 Slot(`content: @Composable () -> Unit`)으로 주입받는 방식 - -## Clean Architecture Rules - -### Layer Separation (계층 분리) -모든 Feature 모듈은 아래 3계층을 엄격히 준수해야 합니다. - -1. **Domain Layer** (Pure Kotlin) - - 외부 의존성(Android Framework, Data Layer 등)을 가지지 않습니다 - - `Entity`, `Repository Interface`, `UseCase`가 위치합니다 - - 비즈니스 로직의 핵심입니다 - -2. **Data Layer** - - Domain Layer의 `Repository`를 구현(`Impl`)합니다 - - API 호출, DB 접근, DTO 매핑을 담당합니다 - - `Mapper`를 통해 DTO를 Domain Entity로 변환하여 반환합니다 - -3. **Presentation Layer** - - UI (`Screen`, `Component`)와 상태 관리 (`ViewModel`)를 담당합니다 - - Domain Layer의 `UseCase`를 주입받아 비즈니스 로직을 수행합니다 - - `UiModel`을 정의하여 UI에 필요한 데이터만 가공해 보여줍니다 - -### Dependency Flow (의존성 방향) -- **Correct**: Presentation → Domain ← Data -- **Incorrect**: Domain → Data (X), Domain → Presentation (X) - -### Component Location Rules (컴포넌트 위치 결정 규칙) - Critical -- **핵심 원칙**: Feature 모듈 간 직접 의존은 절대 금지됩니다 -- **Core 모듈로 이동 기준**: - * 2개 이상의 Feature에서 사용될 가능성이 있는가? (YES) - * 도메인 로직이나 특정 화면 상태에 의존하지 않는가? (순수 UI 컴포넌트인가? YES) - * 브랜드 아이덴티티(디자인 시스템)의 일부인가? (버튼, 텍스트 필드, 헤더 등) -- **Feature 내부에 유지**: 해당 Feature의 여러 화면에서만 공유되고, 다른 Feature에서는 절대 쓰일 일이 없는 경우 - -## Android/Kotlin Coding Rules - -### Import Rules (임포트 규칙) - Critical - -1. **Always Include Full Import Statements** - - **ALWAYS include full import statements** for every class, function, and extension used in the code snippet - - Android Studio는 종종 확장 함수와 Compose delegate의 자동 임포트에 실패합니다 - -2. **Explicitly Import Extension Functions** - - **Never assume extension functions are globally available.** Always add the import line explicitly - - 다음 확장 함수들은 자동 임포트되지 않으므로 명시적으로 임포트해야 합니다: - - `androidx.core.view.isVisible` → `import androidx.core.view.isVisible` - - `androidx.lifecycle.compose.collectAsStateWithLifecycle` → `import androidx.lifecycle.compose.collectAsStateWithLifecycle` (**필수**: StateFlow 수집 시) - - `androidx.lifecycle.viewmodel.compose.viewModel` → `import androidx.lifecycle.viewmodel.compose.viewModel` (**필수**: ViewModel 주입 시) - - `androidx.compose.ui.platform.LocalContext` → `import androidx.compose.ui.platform.LocalContext` - - `androidx.compose.ui.platform.LocalDensity` → `import androidx.compose.ui.platform.LocalDensity` - - `androidx.compose.ui.platform.LocalFocusManager` → `import androidx.compose.ui.platform.LocalFocusManager` - - **중요**: `androidx.hilt.navigation.compose.hiltViewModel`은 **절대 사용하지 마세요** (Deprecated) - -3. **Jetpack Compose Imports** - - **Always import `androidx.compose.runtime.*` delegates when using `by` keyword:** - - `import androidx.compose.runtime.getValue` - - `import androidx.compose.runtime.setValue` - - `import androidx.compose.runtime.remember` - - **Avoid wildcard imports** (e.g., `import androidx.compose.material3.*`) unless strictly necessary - -4. **Standard Import Rules** - - **NEVER import `android.R`**: 절대 `android.R`을 임포트하지 마세요. 항상 애플리케이션 패키지의 `R` 클래스를 사용하세요 (예: `com.kuit.afternote.R`) - - **NO Wildcard Imports**: `import java.util.*` 같은 와일드카드 임포트를 사용하지 마세요. 명시적으로 특정 클래스를 임포트하세요 - - **Prefer AndroidX**: 항상 레거시 `android.support.*` 패키지보다 `androidx.*` 패키지를 선호하세요 - -5. **No Hallucinated Imports** - - **Do not invent extension functions** that do not exist in standard AndroidX libraries - - **Verify existence**: Before generating code, verify that all imported classes/functions exist in standard Android SDK or commonly used libraries - -### Dependency Injection (Hilt) - -#### ViewModel 주입 (Compose) -- **절대 `androidx.hilt.navigation.compose.hiltViewModel()`을 사용하지 마세요** -- **Deprecated**: `hiltViewModel()`은 Hilt 1.2.0+ 및 Navigation 2.8.0+에서 deprecated 되었습니다 -- **올바른 방법**: `androidx.lifecycle.viewmodel.compose.viewModel()`을 사용하세요 -- `@HiltViewModel`이 붙은 클래스는 `viewModel()` 함수가 자동으로 Hilt Factory를 사용하여 주입을 처리합니다 -- **Import**: `import androidx.lifecycle.viewmodel.compose.viewModel` - -#### Constructor Injection -- 가능한 모든 클래스(`RepositoryImpl`, `UseCase`, `ViewModel`)는 생성자 주입(`@Inject constructor`)을 사용합니다 -- `@HiltViewModel`을 사용하고, `SavedStateHandle`이 필요한 경우 생성자에 포함합니다 - -### Navigation Rules - -1. **Type-Safe Navigation** - - 모든 Route는 `kotlinx.serialization`의 `@Serializable` 어노테이션을 사용한 `sealed interface` 또는 `sealed class`로 정의합니다 - - 문자열 하드코딩(예: "home/{id}")은 금지합니다 - -2. **Domain Navigation (도메인 간 네비게이션)** - - 다른 도메인으로 navigate 할 때는 인터페이스를 사용하세요 - - navgraph 내부에서 다른 도메인을 직접 아는 것은 지양합니다 - - 각 도메인은 자신의 Navigator 인터페이스를 정의하고, 구현체는 app 레벨에서 생성합니다 - -### Kotlin & Compose Style -- 가능한 한 `var`보다 `val`을 사용하세요 -- Jetpack Compose에서 `Modifier` 체이닝 순서를 선호하세요: Size -> Layout -> Drawing -> Event Listeners -- 기본적으로 Material3 디자인 시스템 컴포넌트를 사용하세요 -- Coroutines: ViewModel에서는 `viewModelScope`를, Fragments/Activities에서는 `LifecycleScope`를 사용하세요 - -### Resource Management - -#### Drawable -- 아이콘은 `Icon` 컴포저블을 사용하고, `contentDescription`을 반드시 지정합니다 -- 이미지 리소스 사용 시 `painterResource(R.drawable.name)`을 사용합니다 -- SVG나 PNG를 그대로 가져다 쓸 때는 `Modifier.size()`를 설정하지 말아야 합니다 -- drawable 파일에 이미 정의된 크기를 그대로 사용해야 합니다 - -#### Strings -- 모든 UI 텍스트는 `strings.xml`에 정의하여 사용합니다 (`stringResource(R.string.key)`) -- 하드코딩된 문자열은 금지합니다 - ---- - -# 🧹 Code Quality & Formatting - -## Role & Goal -You are a Senior Android Developer with strict adherence to Kotlin coding standards and static analysis rules. -**Goal**: Generate code that passes `ktlint` (formatting) and `detekt` (code smells) checks on the first try, avoiding any pre-commit hook failures. - -## Critical Instructions for Code Generation - -### 1. Context & Config Awareness -- **Before generating code, briefly scan `.editorconfig` and `detekt.yml` in the project root** if they exist to understand custom indentations or max line lengths -- If no config is found, default to **Standard Kotlin Style Guide** - -### 2. Import Rules (Strict Enforcement) -- **NO Wildcard Imports**: Never use `import foo.*`. Always import individual classes -- **Import Order**: Imports must be ordered in lexicographic order without any empty lines in-between with "java", "javax", "kotlin" and aliases in the end - -### 3. Ktlint Compliance (Formatting) - Critical - -#### Formatting Rules -- **Indentation**: Use **4 spaces** for indentation. Do not use tabs -- **Spacing**: - - Put spaces around operators (`+`, `-`, `=`, etc.) - - Put a space before `{` and after `if`, `for`, `while` - - Example: `if (condition) {` ✅, `if(condition){` ❌ -- **Trailing Comma**: Use trailing commas in multi-line parameter lists or value arguments (if Kotlin version supports it) -- **Final Newline**: Ensure every file ends with a single newline character -- **Blank Lines**: Limit consecutive blank lines to one -- **Line Breaks**: Avoid unexpected newlines before `.` in chained calls. Keep chained modifiers on the same line or properly formatted - -#### Common Ktlint Violations to Avoid -- Unexpected newline before `.` in modifier chains -- Missing newline after `,` in multi-line parameter lists -- Unused imports (always remove unused imports) -- Import ordering violations - -### 4. Detekt Compliance (Complexity & Smells) - Critical - -#### Function Complexity -- **Function Length**: Keep functions short and focused (aim for < 20 lines). Break down complex logic into helper functions -- **Max Line Length**: Limit lines to 120 characters (or 100 depending on your team's rule). Wrap long chained calls or parameters -- **Long Parameter List**: Functions with more than 10 parameters should use data classes or be refactored - - **⚠️ Critical: @Suppress는 최후의 수단** - - **절대 먼저 시도해야 할 것**: `@Suppress`를 사용하기 전에 반드시 코드 구조 개선을 시도해야 합니다 - - **State Hoisting 패턴 적용 시 파라미터가 많아지는 경우**: - * 콜백 그룹화: 3개 이상의 콜백이 있으면 Event Interface나 data class로 묶기 - * UI 상태 캡슐화: `expanded`, `boxWidth` 같은 UI 상태는 State Class로 묶기 - * 초기값 처리: `initialShowTextField`, `initialExpandedItemId` 같은 초기값은 State 생성자로 옮기기 - - **TooManyFunctions 경고 발생 시**: - * 불필요한 함수 제거: 사용되지 않는 헬퍼 함수나 중복 함수 제거 - * 책임 분리: Manager 클래스로 로직 위임 고려 - * Private 함수 활용: 외부에 노출될 필요 없는 함수는 `private`으로 변경 - -#### Code Smells -- **Magic Numbers**: Avoid hardcoded numbers. Extract them to `const val` or strict named constants, except for obvious values like 0, 1, or -1 -- **Nested Blocks**: Avoid deep nesting (max depth: 4). Use guard clauses (`return` early) to flatten `if-else` structures -- **Empty Blocks**: Never leave `catch` or `if` blocks empty. Add a comment explaining why it's empty or handle the exception properly -- **Top-Level Declarations**: Group constants, extension functions, and classes logically - -### 5. Naming Convention -- Composable: `PascalCase` (예: `LoginScreen`) -- ViewModel: `PascalCase` + `ViewModel` (예: `LoginViewModel`) -- Repository/UseCase: `PascalCase` + `Repository`/`UseCase` (예: `LoginRepository`, `LoginUseCase`) -- Route: `PascalCase` + `Route` (예: `LoginRoute`) - -### 6. Named Arguments 필수 -함수 호출 시 파라미터 이름 명시 (아래 조건 해당 시): -- 인자가 3개 이상인 경우 -- Boolean, Null, 숫자 리터럴이 포함된 경우 -- 같은 타입의 인자가 연속되는 경우 -- Compose 함수 호출 시 (modifier 제외한 모든 인자) -- 예: `Recipient(id = "1", name = "김지은", label = "친구")`, `FontWeight.Medium` (NOT `FontWeight(500)`) - -## Testing Rules - -### Unit Test -- `Domain Layer`(`UseCase`)와 `ViewModel`은 100%에 가까운 테스트 커버리지를 지향합니다 -- `MockK`를 사용하여 의존성을 모킹합니다 - -### Naming -- 테스트 함수명은 행위를 명확히 기술합니다 (예: `fetchUser_whenSuccess_returnsUser`) - -### Test Code Verification -코드를 생성하거나 수정할 때마다 **반드시** 다음을 확인하고 필요하면 테스트 코드를 추가해야 합니다: - -#### UseCase 추가/수정 시 -- **위치**: `app/src/test/java/com/kuit/afternote/feature/{feature_name}/domain/usecase/` -- **파일명**: `{UseCaseName}Test.kt` -- **내용**: UseCase의 모든 메서드에 대한 단위 테스트 작성 - -#### ViewModel 추가/수정 시 -- **위치**: `app/src/test/java/com/kuit/afternote/feature/{feature_name}/presentation/viewmodel/` -- **파일명**: `{ViewModelName}Test.kt` -- **내용**: ViewModel의 상태 변경, 이벤트 처리에 대한 단위 테스트 작성 - -## Documentation - -### Public API (필수) -**모든 `public` 함수나 클래스(특히 `UseCase`, `Repository` 인터페이스)에는 반드시 KDoc 형식의 주석을 작성해야 합니다** -- Detekt의 `undocumented-public-function` 규칙을 준수하기 위함입니다 -- KDoc은 함수의 역할, 파라미터, 반환값을 명확히 설명해야 합니다 -- 예외: 단순한 data class나 enum은 선택적으로 작성 가능 - -### Complex Logic -복잡한 비즈니스 로직이 들어가는 곳에는 주석을 통해 '왜(Why)' 이렇게 구현했는지 설명합니다 - ---- - -# 🔧 General Guidelines - -## 기본 원칙 - -1. **Sycophancy 방지 (아첨 금지) - Critical** - - 사용자가 답변을 질문하거나 반문할 때, 즉시 사과하거나 답변을 변경하지 말 것 - - 먼저 자신의 논리와 근거를 설명할 것 - - 사실적으로 틀렸을 때만 사과할 것 - -2. **Ask Mode 전용 규칙 - Critical** - - **STRICTLY ANSWER ONLY**: Ask 모드에서는 파일 수정을 절대 시도하지 말 것 - - **질문 해석**: 사용자의 질문은 순수한 궁금증(PURE CURIOSITY)으로 해석할 것. 지적이나 수정 요구로 해석하지 말 것 - - **사과 금지**: Ask 모드에서 이전 코드에 대해 사과하지 말 것. 명시적으로 틀렸다고 증명되지 않는 한 - - **논리 설명**: 질문에 대해 논리를 차분히 설명할 것 - -3. **주제에 맞는 브랜치에서 작업** - - 작업 전 반드시 적절한 브랜치 확인/생성 (`feat/`, `fix/`, `docs/` 등) - -4. **시도한 해결책 메모** - - 문제 해결 시 시도한 방법들을 메모해두기 - - 같은 실수를 반복하지 않기 위함 - -5. **문서화** - - 중요한 설정이나 해결 방법은 문서로 남기기 - - 다른 팀원이나 미래의 자신을 위해 - -## 파일 변경 시 README 체크 규칙 - -**파일을 변경할 때마다 README 변경이 필요한지 체크하고, 필요하면 반드시 변경해야 합니다** - -### 체크해야 할 경우 -1. 새로운 설정이나 도구 추가 -2. 프로젝트 구조 변경 -3. 개발 환경 요구사항 변경 -4. 빌드/실행 방법 변경 -5. 트러블슈팅 정보 추가 -6. 워크플로우나 규칙 변경 - -**모든 답변이 "아니오"가 아니라면 README를 업데이트해야 합니다** - -## Problem Solving & Research Rules - -### ⚠️ Critical: 작업 중단 및 조사 규칙 - -**작업 중 문제가 발생하거나 막히는 경우, 즉시 중단하고 Perplexity 프롬프트를 제공해야 합니다** - -### 작업 중단 조건 -1. 예상치 못한 오류 발생 -2. API 또는 라이브러리 사용법 불확실 -3. 시각적 문제 발생 -4. 3회 이상 시도 실패 - -### ⚠️ Critical: 구조 변경 시 필수 검증 절차 -**파일 이동, 패키지 변경, 클래스 리팩토링 등 구조적 변경 작업 시 반드시 다음 절차를 따라야 합니다:** - -1. **변경 전 빌드 확인**: 구조 변경 전에 현재 상태가 빌드되는지 확인 - **단, 사용자가 명시적으로 요청한 경우에만 실행** -2. **사용처 전체 검색**: 이동/변경할 파일이 사용되는 모든 위치를 `grep`으로 검색하여 리스트업 -3. **Import 경로 일괄 변경**: 모든 사용처의 import 경로를 한 번에 변경 (단계적으로 하지 말고 전체를 먼저 파악) -4. **변수/함수명 변경 시**: 변수명이나 함수명을 변경할 때는 `grep`으로 모든 사용처 확인 후 일괄 변경 -5. **변경 후 빌드 검증**: 파일 이동/변경 후 빌드가 필요한 경우 사용자에게 요청하거나, 사용자가 명시적으로 요청한 경우에만 실행 -6. **State/Params 클래스 이동 시**: State 클래스나 Params 클래스를 이동할 때는 해당 클래스를 사용하는 모든 컴포넌트의 import 경로를 확인 -7. **중복 제거 시**: 중복 파일을 제거하기 전에 사용처를 모두 확인하고, 하나로 통합한 후 모든 import 경로를 업데이트 - -### Perplexity 프롬프트 작성 요청 시 규칙 - Critical -**사용자가 "퍼플렉시티 프롬프트 작성해" 또는 "Perplexity 프롬프트 작성해"라고 명시적으로 요청한 경우:** -- **프롬프트 내용만 제공**: 프롬프트에 붙여 넣을 내용 외에는 아무 말도 하지 않습니다 -- **추가 설명 금지**: 프롬프트 작성 후 "이 프롬프트를 Perplexity에 붙여넣으세요" 같은 안내 문구도 포함하지 않습니다 -- **코드 수정 금지**: 프롬프트 작성과 동시에 코드를 수정하지 않습니다 -- **형식**: 프롬프트는 마크다운 코드 블록 없이 순수 텍스트로 제공합니다 (사용자가 바로 복사-붙여넣기 할 수 있도록) - ---- - -# 📝 File Structure Rules - -- **One Component per File**: 하나의 파일에는 하나의 주요 `@Composable` 함수와 해당 `@Preview`만 위치시킵니다 -- **File Length Limit**: 파일이 400줄을 넘어가면 여러 파일로 분리해야 합니다 -- **Folder Structure**: 폴더당 파일 수를 **15개 이하**로 유지합니다 - ---- - -# ⚠️ CRITICAL RULES 재확인 (파일 끝 - 샌드위치 기법) - -**이 파일을 읽을 때 반드시 위의 핵심 규칙을 재확인하시오:** - -## 최우선 규칙 (절대 무시 금지) -1. **Git 작업 전 검증**: 커밋/푸시 명령 생성 전, 현재 브랜치가 `main`/`develop`이 아닌지 **반드시 먼저 검증** -2. **사용자 명시적 요청 없이 절대 커밋/푸시 금지** -3. **빌드 자동 실행 금지**: 사용자가 명시적으로 요청하지 않으면 빌드를 자동으로 실행하지 않음 - -## 핵심 기술 규칙 -- **ViewModel 주입**: `viewModel()` 사용 (절대 `hiltViewModel()` 사용 금지) -- **StateFlow 수집**: `collectAsStateWithLifecycle()` 사용 (절대 `collectAsState()` 사용 금지) -- **State Hoisting**: 모든 화면 상태는 ViewModel로 이동 -- **Preview 필수**: 모든 public Composable에 `@Preview` 필수 -- **Import**: 전체 import 문 포함, 확장 함수 명시적 import, 와일드카드 금지 - -**위 규칙들을 위반하면 심각한 문제가 발생할 수 있습니다. 반드시 준수하시오.** diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index de6ce1bb..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,63 +0,0 @@ -# Project Context & Rules - -**CLAUDE.md is kept in sync by the AI whenever Cursor rules (`.cursor/rules/` or `.cursorrules`) are added, removed, or renamed. Do not edit this file manually.** - -This file uses Claude Code's `@` import syntax to reference Cursor rule files. -Claude Code will automatically load the referenced files when this project is opened. - ---- - -## Core Rules - -Core project rules and critical guidelines: - -- @.cursorrules - ---- - -## Detailed Guidelines - -Please refer to the following specific rule files: - -### Workflow - -- @.cursor/rules/workflow/api-implementation.mdc - *API 구현 시 참고 규칙* -- @.cursor/rules/workflow/branch-workflow.mdc - *브랜치 작업 및 워크플로우 규칙* -- @.cursor/rules/workflow/claude-md-sync.mdc - *Keep CLAUDE.md in sync when Cursor rules change* -- @.cursor/rules/workflow/created-modified-checks.mdc - *Commitlint, ktlint, app distribution checks* -- @.cursor/rules/workflow/debugging-investigation.mdc - *Debugging and investigation — log first* -- @.cursor/rules/workflow/edit-screen-and-nav-state.mdc - *Edit screen data source, category from API, nav state holder lifecycle* -- @.cursor/rules/workflow/execute-dont-instruct.mdc - *Execute requested actions instead of only giving instructions* -- @.cursor/rules/workflow/gemini-prompt-web-search.mdc - *Gemini prompts: always require web search, no project context, reply = prompt only* -- @.cursor/rules/workflow/git-policy.mdc - *Git 작업 및 브랜치 관리 정책* -- @.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc - *Do not include "Made with Cursor" in PRs or commits* -- @.cursor/rules/workflow/pr-ci-checks.mdc - *PR/CI required checks — commitlint, ktlint, build* -- @.cursor/rules/workflow/pull-request-review.mdc - *Pull Request 작성 및 코드 리뷰 규칙* - -### Tech Stack - -- @.cursor/rules/tech-stack/android-kotlin.mdc - *Android/Kotlin 코딩 컨벤션 및 Import 규칙* -- @.cursor/rules/tech-stack/architecture.mdc - *Clean Architecture 및 계층 분리 규칙* -- @.cursor/rules/tech-stack/coil-image-loading.mdc - *Coil 3 image loading (dependencies, User-Agent, error handling)* -- @.cursor/rules/tech-stack/compose.mdc - *Jetpack Compose 코딩 규칙 및 패턴* -- @.cursor/rules/tech-stack/compose-focus-popup.mdc - *DropdownMenu/popup + inline edit focus race; dead callbacks* -- @.cursor/rules/tech-stack/data-as-data-ioc.mdc - *Data as data / IoC — ViewModels blind to real vs stub; decision at DI* -- @.cursor/rules/tech-stack/dto-serialization.mdc - *@SerialName only when JSON key differs from property name* -- @.cursor/rules/tech-stack/optimistic-updates.mdc - *Optimistic Updates 패턴* - -### Quality - -- @.cursor/rules/quality/code-quality.mdc - *코드 품질, 포맷팅, 테스트 규칙* -- @.cursor/rules/quality/k2-compose-assigned-value-suppress.mdc - *K2 false positive "Assigned value is never read" in Compose — suppress and document* - ---- - -## How This Works - -Claude Code uses the `@` syntax to import file contents. When you open this project in Claude Code, it will: - -1. Read this `CLAUDE.md` file -2. Automatically import all referenced rule files -3. Load their contents into the context - -This allows Cursor and Claude Code to share the same rule files without duplication or synchronization issues. diff --git a/app/src/main/java/com/kuit/afternote/app/compositionlocal/DataProviderLocals.kt b/app/src/main/java/com/kuit/afternote/app/compositionlocal/DataProviderLocals.kt index 781d6eaa..44806381 100644 --- a/app/src/main/java/com/kuit/afternote/app/compositionlocal/DataProviderLocals.kt +++ b/app/src/main/java/com/kuit/afternote/app/compositionlocal/DataProviderLocals.kt @@ -1,18 +1,20 @@ package com.kuit.afternote.app.compositionlocal import androidx.compose.runtime.compositionLocalOf -import com.kuit.afternote.data.provider.DataProviderSwitch import com.kuit.afternote.domain.provider.AfternoteEditDataProvider +import com.kuit.afternote.domain.provider.DataProviderSwitch import com.kuit.afternote.domain.provider.ReceiverDataProvider object DataProviderLocals { val LocalDataProviderSwitch = compositionLocalOf { null } - val LocalAfternoteEditDataProvider = compositionLocalOf { - error("No AfternoteEditDataProvider provided") - } + val LocalAfternoteEditDataProvider = + compositionLocalOf { + error("No AfternoteEditDataProvider provided") + } - val LocalReceiverDataProvider = compositionLocalOf { - error("No ReceiverDataProvider provided") - } + val LocalReceiverDataProvider = + compositionLocalOf { + error("No ReceiverDataProvider provided") + } } diff --git a/app/src/main/java/com/kuit/afternote/app/di/DataProviderEntryPoint.kt b/app/src/main/java/com/kuit/afternote/app/di/DataProviderEntryPoint.kt index 65e17162..69ad28f7 100644 --- a/app/src/main/java/com/kuit/afternote/app/di/DataProviderEntryPoint.kt +++ b/app/src/main/java/com/kuit/afternote/app/di/DataProviderEntryPoint.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.app.di -import com.kuit.afternote.data.provider.DataProviderSwitch +import com.kuit.afternote.domain.provider.DataProviderSwitch import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent diff --git a/app/src/main/java/com/kuit/afternote/app/di/TokenManagerEntryPoint.kt b/app/src/main/java/com/kuit/afternote/app/di/TokenManagerEntryPoint.kt index a98d0bd1..97e8c853 100644 --- a/app/src/main/java/com/kuit/afternote/app/di/TokenManagerEntryPoint.kt +++ b/app/src/main/java/com/kuit/afternote/app/di/TokenManagerEntryPoint.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.app.di -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent diff --git a/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt b/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt index 3452db47..fdef8d75 100644 --- a/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt @@ -4,6 +4,7 @@ package com.kuit.afternote.app.navigation.navgraph import android.os.Build import android.util.Log +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.biometric.BiometricManager @@ -32,8 +33,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -45,21 +46,21 @@ import com.kuit.afternote.R import com.kuit.afternote.app.compositionlocal.DataProviderLocals import com.kuit.afternote.app.di.ReceiverAuthSessionEntryPoint import com.kuit.afternote.app.di.TokenManagerEntryPoint +import com.kuit.afternote.core.navigation.ReceiverRoute import com.kuit.afternote.core.ui.component.list.AfternoteTab import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.util.getAfternoteDisplayRes import com.kuit.afternote.core.ui.util.getIconResForServiceName import com.kuit.afternote.core.uimodel.AfternoteListDisplayItem import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem +import com.kuit.afternote.feature.afternote.presentation.fingerprint.FingerprintLoginScreen +import com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditState +import com.kuit.afternote.feature.afternote.presentation.edit.MemorialPlaylistStateHolder import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteEditStateHandling import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteListRefreshParams import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteNavGraphParams -import com.kuit.afternote.feature.user.presentation.viewmodel.CurrentUserNameViewModel import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteRoute import com.kuit.afternote.feature.afternote.presentation.navgraph.afternoteNavGraph -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteEditState -import com.kuit.afternote.feature.afternote.presentation.screen.FingerprintLoginScreen -import com.kuit.afternote.feature.afternote.presentation.screen.MemorialPlaylistStateHolder import com.kuit.afternote.feature.dailyrecord.presentation.navgraph.recordNavGraph import com.kuit.afternote.feature.home.presentation.screen.HomeScreen import com.kuit.afternote.feature.home.presentation.screen.HomeScreenEvent @@ -77,15 +78,18 @@ import com.kuit.afternote.feature.receiverauth.screen.ReceiverOnboardingScreen import com.kuit.afternote.feature.receiverauth.screen.VerifySelfScreen import com.kuit.afternote.feature.setting.presentation.navgraph.SettingRoute import com.kuit.afternote.feature.setting.presentation.navgraph.settingNavGraph -import com.kuit.afternote.core.navigation.ReceiverRoute import com.kuit.afternote.feature.timeletter.presentation.navgraph.TimeLetterRoute import com.kuit.afternote.feature.timeletter.presentation.navgraph.timeLetterNavGraph +import com.kuit.afternote.feature.user.presentation.viewmodel.CurrentUserNameViewModel import dagger.hilt.android.EntryPointAccessors import kotlinx.coroutines.flow.map private const val TAG_FINGERPRINT = "FingerprintLogin" -private fun ejectToSplashIfLoggedOut(nav: NavHostController, isLoggedIn: Boolean?) { +private fun ejectToSplashIfLoggedOut( + nav: NavHostController, + isLoggedIn: Boolean?, +) { if (isLoggedIn != false) return val currentRoute = nav.currentBackStackEntry?.destination?.route if (currentRoute?.contains("Splash") == true) return @@ -95,21 +99,35 @@ private fun ejectToSplashIfLoggedOut(nav: NavHostController, isLoggedIn: Boolean } } -private fun dispatchFromRoot(nav: NavHostController, isLoggedIn: Boolean?) { +private fun dispatchFromRoot( + nav: NavHostController, + isLoggedIn: Boolean?, +) { when (isLoggedIn) { - true -> nav.navigate("home") { - popUpTo("root") { inclusive = true } - launchSingleTop = true + true -> { + nav.navigate("home") { + popUpTo("root") { inclusive = true } + launchSingleTop = true + } } - false -> nav.navigate(OnboardingRoute.SplashRoute) { - popUpTo("root") { inclusive = true } - launchSingleTop = true + + false -> { + nav.navigate(OnboardingRoute.SplashRoute) { + popUpTo("root") { inclusive = true } + launchSingleTop = true + } + } + + null -> { + Unit } - null -> Unit } } -private fun redirectFromHomeIfLoggedOut(nav: NavHostController, isLoggedIn: Boolean?) { +private fun redirectFromHomeIfLoggedOut( + nav: NavHostController, + isLoggedIn: Boolean?, +) { if (isLoggedIn != false) return nav.navigate(OnboardingRoute.SplashRoute) { launchSingleTop = true @@ -120,19 +138,28 @@ private fun redirectFromHomeIfLoggedOut(nav: NavHostController, isLoggedIn: Bool private fun createBottomNavTabSelectedHandler(nav: NavHostController): (BottomNavItem) -> Unit = { item -> when (item) { - BottomNavItem.HOME -> nav.navigate("home") { launchSingleTop = true } - BottomNavItem.AFTERNOTE -> + BottomNavItem.HOME -> { + nav.navigate("home") { launchSingleTop = true } + } + + BottomNavItem.AFTERNOTE -> { nav.navigate(AfternoteRoute.FingerprintLoginRoute) { launchSingleTop = true } - BottomNavItem.RECORD -> nav.navigate("record_main") { launchSingleTop = true } - BottomNavItem.TIME_LETTER -> + } + + BottomNavItem.RECORD -> { + nav.navigate("record_main") { launchSingleTop = true } + } + + BottomNavItem.TIME_LETTER -> { nav.navigate(TimeLetterRoute.TimeLetterMainRoute) { launchSingleTop = true } + } } } @Composable private fun FingerprintLoginRouteContent( navHostController: NavHostController, - onBottomNavTabSelected: (BottomNavItem) -> Unit + onBottomNavTabSelected: (BottomNavItem) -> Unit, ) { val context = LocalContext.current val activity = context as? FragmentActivity @@ -148,12 +175,10 @@ private fun FingerprintLoginRouteContent( fragActivity, executor, object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult - ) { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { navHostController.popBackStack() } - } + }, ) } } catch (e: Throwable) { @@ -163,7 +188,8 @@ private fun FingerprintLoginRouteContent( } val promptInfo = remember(promptTitle, promptSubtitle) { - BiometricPrompt.PromptInfo.Builder() + BiometricPrompt.PromptInfo + .Builder() .setTitle(promptTitle) .setSubtitle(promptSubtitle) .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) @@ -174,19 +200,21 @@ private fun FingerprintLoginRouteContent( if (activity == null) return@FingerprintLoginScreen val biometricManager = BiometricManager.from(context) when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) { - BiometricManager.BIOMETRIC_SUCCESS -> + BiometricManager.BIOMETRIC_SUCCESS -> { biometricPrompt?.authenticate(promptInfo) - else -> - android.widget.Toast + } + + else -> { + Toast .makeText( context, notAvailableMessage, - android.widget.Toast.LENGTH_SHORT - ) - .show() + Toast.LENGTH_SHORT, + ).show() + } } }, - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ) } @@ -197,53 +225,72 @@ private fun ReceiverAfternoteListRouteContent(navHostController: NavHostControll val afterNotesState by viewModel.uiState.collectAsStateWithLifecycle() var selectedTab by remember { mutableStateOf(AfternoteTab.ALL) } var selectedBottomNavItem by remember { mutableStateOf(BottomNavItem.AFTERNOTE) } - val filteredItems = remember(afterNotesState.items, selectedTab) { - val list = afterNotesState.items - when (selectedTab) { - AfternoteTab.ALL -> list - AfternoteTab.SOCIAL_NETWORK -> list.filter { - it.sourceType.equals("SOCIAL", ignoreCase = true) - } - AfternoteTab.GALLERY_AND_FILES -> list.filter { - it.sourceType.equals("GALLERY", ignoreCase = true) - } - AfternoteTab.MEMORIAL -> list.filter { - it.sourceType.equals("PLAYLIST", ignoreCase = true) + val filteredItems = + remember(afterNotesState.items, selectedTab) { + val list = afterNotesState.items + when (selectedTab) { + AfternoteTab.ALL -> { + list + } + + AfternoteTab.SOCIAL_NETWORK -> { + list.filter { + it.sourceType.equals("SOCIAL", ignoreCase = true) + } + } + + AfternoteTab.GALLERY_AND_FILES -> { + list.filter { + it.sourceType.equals("GALLERY", ignoreCase = true) + } + } + + AfternoteTab.MEMORIAL -> { + list.filter { + it.sourceType.equals("PLAYLIST", ignoreCase = true) + } + } } } - } - val displayItems = filteredItems.map { item -> - val iconResId = - if (item.title.isNotBlank()) getIconResForServiceName(item.title) - else getAfternoteDisplayRes(item.sourceType).second - AfternoteListDisplayItem( - id = item.id.toString(), - serviceName = item.title, - date = item.lastUpdatedAt, - iconResId = iconResId + val displayItems = + filteredItems.map { item -> + val iconResId = + if (item.title.isNotBlank()) { + getIconResForServiceName(item.title) + } else { + getAfternoteDisplayRes(item.sourceType).second + } + AfternoteListDisplayItem( + id = item.id.toString(), + serviceName = item.title, + date = item.lastUpdatedAt, + iconResId = iconResId, + ) + } + val listState = + ReceiverAfternoteListUiState( + items = displayItems, + selectedTab = selectedTab, + selectedBottomNavItem = selectedBottomNavItem, ) - } - val listState = ReceiverAfternoteListUiState( - items = displayItems, - selectedTab = selectedTab, - selectedBottomNavItem = selectedBottomNavItem - ) when { afterNotesState.isLoading && displayItems.isEmpty() -> { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } + afterNotesState.errorMessage != null && displayItems.isEmpty() -> { Column( - modifier = Modifier - .fillMaxSize() - .padding(PaddingValues(24.dp)), + modifier = + Modifier + .fillMaxSize() + .padding(PaddingValues(24.dp)), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text(text = afterNotesState.errorMessage!!) Spacer(modifier = Modifier.height(16.dp)) @@ -252,19 +299,25 @@ private fun ReceiverAfternoteListRouteContent(navHostController: NavHostControll } } } + else -> { ReceiverAfternoteListRoute( uiState = listState, onEvent = { event -> when (event) { - is ReceiverAfternoteListEvent.SelectTab -> + is ReceiverAfternoteListEvent.SelectTab -> { selectedTab = event.tab - is ReceiverAfternoteListEvent.SelectBottomNav -> + } + + is ReceiverAfternoteListEvent.SelectBottomNav -> { selectedBottomNavItem = event.navItem - is ReceiverAfternoteListEvent.ClickItem -> + } + + is ReceiverAfternoteListEvent.ClickItem -> { navHostController.navigate("receiver_afternote_detail/${event.itemId}") + } } - } + }, ) } } @@ -276,18 +329,23 @@ private fun HomeScreenContent( onSettingsClick: () -> Unit = {}, onDailyQuestionCtaClick: () -> Unit, onTImeLetterClick: () -> Unit, - onAfternoteClick: () -> Unit + onAfternoteClick: () -> Unit, ) { HomeScreen( - event = object : HomeScreenEvent { - override fun onBottomNavTabSelected(item: BottomNavItem) = - onBottomNavTabSelected(item) - override fun onProfileClick() = Unit - override fun onSettingsClick() = onSettingsClick() - override fun onDailyQuestionCtaClick() = onDailyQuestionCtaClick() - override fun onTimeLetterClick() = onTImeLetterClick() - override fun onAfterNoteClick() = onAfternoteClick() - } + event = + object : HomeScreenEvent { + override fun onBottomNavTabSelected(item: BottomNavItem) = onBottomNavTabSelected(item) + + override fun onProfileClick() = Unit + + override fun onSettingsClick() = onSettingsClick() + + override fun onDailyQuestionCtaClick() = onDailyQuestionCtaClick() + + override fun onTimeLetterClick() = onTImeLetterClick() + + override fun onAfterNoteClick() = onAfternoteClick() + }, ) } @@ -295,18 +353,22 @@ private fun HomeScreenContent( @Composable fun NavGraph(navHostController: NavHostController) { val context = LocalContext.current - val tokenManager = remember { - EntryPointAccessors.fromApplication( - context.applicationContext, - TokenManagerEntryPoint::class.java - ).tokenManager() - } - val receiverAuthSessionHolder = remember { - EntryPointAccessors.fromApplication( - context.applicationContext, - ReceiverAuthSessionEntryPoint::class.java - ).receiverAuthSessionHolder() - } + val tokenManager = + remember { + EntryPointAccessors + .fromApplication( + context.applicationContext, + TokenManagerEntryPoint::class.java, + ).tokenManager() + } + val receiverAuthSessionHolder = + remember { + EntryPointAccessors + .fromApplication( + context.applicationContext, + ReceiverAuthSessionEntryPoint::class.java, + ).receiverAuthSessionHolder() + } // null = still loading from DataStore, true/false = resolved val isLoggedIn by remember(tokenManager) { tokenManager.isLoggedInFlow.map { it } @@ -320,7 +382,7 @@ fun NavGraph(navHostController: NavHostController) { val receiverProvider = DataProviderLocals.LocalReceiverDataProvider.current val currentUserNameViewModel: CurrentUserNameViewModel = hiltViewModel() val currentUserName by currentUserNameViewModel.userName.collectAsStateWithLifecycle( - initialValue = "" + initialValue = "", ) LaunchedEffect(isLoggedIn) { if (isLoggedIn == true) currentUserNameViewModel.loadUserName() @@ -340,7 +402,7 @@ fun NavGraph(navHostController: NavHostController) { enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None } + popExitTransition = { ExitTransition.None }, ) { composable("root") { LaunchedEffect(isLoggedIn) { @@ -374,7 +436,7 @@ fun NavGraph(navHostController: NavHostController) { navHostController.navigate(TimeLetterRoute.TimeLetterMainRoute) { launchSingleTop = true } - } + }, ) } else { Box(Modifier.fillMaxSize()) @@ -383,44 +445,47 @@ fun NavGraph(navHostController: NavHostController) { onboardingNavGraph( navController = navHostController, - onNavigateToReceiverOnboarding = { navHostController.navigate("receiver_onboarding") } + onNavigateToReceiverOnboarding = { navHostController.navigate("receiver_onboarding") }, ) recordNavGraph( navController = navHostController, - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ) afternoteNavGraph( navController = navHostController, - params = AfternoteNavGraphParams( - afternoteItemsProvider = { afternoteItems }, - onItemsUpdated = { newItems -> - afternoteItems = newItems - }, - playlistStateHolder = playlistStateHolder, - afternoteProvider = afternoteProvider, - userNameProvider = { currentUserName }, - editStateHandling = AfternoteEditStateHandling( - holder = afternoteEditStateHolder, - onClear = { afternoteEditStateHolder.value = null } - ), - listRefresh = AfternoteListRefreshParams( - listRefreshRequestedProvider = { listRefreshRequested }, - onListRefreshConsumed = { listRefreshRequested = false }, - onAfternoteDeleted = { listRefreshRequested = true } + params = + AfternoteNavGraphParams( + afternoteItemsProvider = { afternoteItems }, + onItemsUpdated = { newItems -> + afternoteItems = newItems + }, + playlistStateHolder = playlistStateHolder, + afternoteProvider = afternoteProvider, + userNameProvider = { currentUserName }, + editStateHandling = + AfternoteEditStateHandling( + holder = afternoteEditStateHolder, + onClear = { afternoteEditStateHolder.value = null }, + ), + listRefresh = + AfternoteListRefreshParams( + listRefreshRequestedProvider = { listRefreshRequested }, + onListRefreshConsumed = { listRefreshRequested = false }, + onAfternoteDeleted = { listRefreshRequested = true }, + ), + onNavigateToSelectReceiver = { + navHostController.navigate(ReceiverRoute.ReceiverListRoute) + }, ), - onNavigateToSelectReceiver = { - navHostController.navigate(ReceiverRoute.ReceiverListRoute) - } - ), - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ) timeLetterNavGraph( navController = navHostController, - onNavItemSelected = onBottomNavTabSelected + onNavItemSelected = onBottomNavTabSelected, ) settingNavGraph( navController = navHostController, - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ) composable("receiver_main/{receiverId}") { backStackEntry -> @@ -430,9 +495,8 @@ fun NavGraph(navHostController: NavHostController) { navController = navHostController, receiverTitle = receiverProvider.getDefaultReceiverTitle(), // 팀원 의도 반영 albumCovers = afternoteProvider.getAlbumCovers(), - receiverAuthSessionHolder = receiverAuthSessionHolder // 경민님 로직 반영 + receiverAuthSessionHolder = receiverAuthSessionHolder, // 경민님 로직 반영 ) - } composable("receiver_afternote_list") { @@ -444,20 +508,20 @@ fun NavGraph(navHostController: NavHostController) { Log.d("NavGraph", "receiver_afternote_detail: itemId=$itemId, route=${backStackEntry.destination.route}") ReceiverAfternoteDetailRoute( navHostController = navHostController, - itemId = itemId + itemId = itemId, ) } composable("receiver_time_letter_list/{receiverId}") { ReceiverTimeLetterRoute( navController = navHostController, - onBackClick = { navHostController.popBackStack() } + onBackClick = { navHostController.popBackStack() }, ) } composable("receiver_time_letter_detail/{receiverId}/{timeLetterReceiverId}") { ReceiverTimeLetterDetailRoute( - onBackClick = { navHostController.popBackStack() } + onBackClick = { navHostController.popBackStack() }, ) } @@ -465,7 +529,7 @@ fun NavGraph(navHostController: NavHostController) { ReceiverOnboardingScreen( onLoginClick = { navHostController.popBackStack() }, onStartClick = { navHostController.navigate("receiver_verify_self") }, - onSignUpClick = { navHostController.popBackStack() } + onSignUpClick = { navHostController.popBackStack() }, ) } @@ -480,14 +544,14 @@ fun NavGraph(navHostController: NavHostController) { launchSingleTop = true popUpTo("receiver_onboarding") { inclusive = true } } - } + }, ) } composable("fingerprint_login") { FingerprintLoginRouteContent( navHostController = navHostController, - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ) } } diff --git a/app/src/main/java/com/kuit/afternote/core/dummy/afternote/AfternoteEditDummies.kt b/app/src/main/java/com/kuit/afternote/core/dummy/afternote/AfternoteEditDummies.kt index 93db3ce5..35095e38 100644 --- a/app/src/main/java/com/kuit/afternote/core/dummy/afternote/AfternoteEditDummies.kt +++ b/app/src/main/java/com/kuit/afternote/core/dummy/afternote/AfternoteEditDummies.kt @@ -1,9 +1,9 @@ package com.kuit.afternote.core.dummy.afternote import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Song +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem +import com.kuit.afternote.feature.afternote.presentation.edit.model.Song /** * Dummy data for 애프터노트 수정 화면 (플레이리스트, 수신자 등). @@ -13,29 +13,52 @@ object AfternoteEditDummies { /** * Default processing method items for record-type services (애프터노트 수정 화면). Dummy data. */ - fun defaultRecordProcessingMethods(): List = + fun defaultRecordProcessingMethods(): List = listOf( - ProcessingMethodItem("1", "게시물 내리기"), - ProcessingMethodItem("2", "추모 게시물 올리기"), - ProcessingMethodItem("3", "추모 계정으로 전환하기") + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + "1", + "게시물 내리기", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + "2", + "추모 게시물 올리기", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + "3", + "추모 계정으로 전환하기", + ), ) /** * Default processing method items for gallery/file-type services. Dummy data. */ - fun defaultGalleryProcessingMethods(): List = + fun defaultGalleryProcessingMethods(): List = listOf( - ProcessingMethodItem("1", "'엽사' 폴더 박선호에게 전송"), - ProcessingMethodItem("2", "'흑역사' 폴더 삭제") + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + "1", + "'엽사' 폴더 박선호에게 전송", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + "2", + "'흑역사' 폴더 삭제", + ), ) /** * Default afternote edit receivers for edit screen. Replace with API load when backend is ready. */ - fun defaultAfternoteEditReceivers(): List = + fun defaultAfternoteEditReceivers(): List = listOf( - AfternoteEditReceiver(id = "1", name = "김지은", label = "친구"), - AfternoteEditReceiver(id = "2", name = "박선호", label = "가족") + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "1", + name = "김지은", + label = "친구", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "2", + name = "박선호", + label = "가족", + ), ) /** @@ -51,24 +74,68 @@ object AfternoteEditDummies { PlaylistSongDisplay(id = "6", title = "안녕", artist = "폴킴"), PlaylistSongDisplay(id = "7", title = "첫눈처럼 너에게 가겠다", artist = "에일리"), PlaylistSongDisplay(id = "8", title = "너를 만나", artist = "폴킴"), - PlaylistSongDisplay(id = "9", title = "겨울비", artist = "박효신") + PlaylistSongDisplay(id = "9", title = "겨울비", artist = "박효신"), ) /** * Default playlist songs for edit screen. Replace with API load when backend is ready. */ - fun defaultSongs(): List = + fun defaultSongs(): List = listOf( - Song(id = "1", title = "노래 제목", artist = "가수 이름"), - Song(id = "2", title = "노래 제목", artist = "가수 이름"), - Song(id = "3", title = "노래 제목", artist = "가수 이름"), - Song(id = "4", title = "노래 제목", artist = "가수 이름"), - Song(id = "5", title = "노래 제목", artist = "가수 이름"), - Song(id = "6", title = "노래 제목", artist = "가수 이름"), - Song(id = "7", title = "노래 제목", artist = "가수 이름"), - Song(id = "8", title = "노래 제목", artist = "가수 이름"), - Song(id = "9", title = "노래 제목", artist = "가수 이름"), - Song(id = "10", title = "노래 제목", artist = "가수 이름"), - Song(id = "11", title = "노래 제목", artist = "가수 이름") + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "1", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "2", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "3", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "4", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "5", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "6", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "7", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "8", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "9", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "10", + title = "노래 제목", + artist = "가수 이름", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( + id = "11", + title = "노래 제목", + artist = "가수 이름", + ), ) } diff --git a/app/src/main/java/com/kuit/afternote/core/dummy/receiver/ReceiverDummies.kt b/app/src/main/java/com/kuit/afternote/core/dummy/receiver/ReceiverDummies.kt index c5027a48..6c4df650 100644 --- a/app/src/main/java/com/kuit/afternote/core/dummy/receiver/ReceiverDummies.kt +++ b/app/src/main/java/com/kuit/afternote/core/dummy/receiver/ReceiverDummies.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.core.dummy.receiver import com.kuit.afternote.R -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.feature.setting.presentation.screen.dailyanswer.DailyAnswerItemUiModel import com.kuit.afternote.feature.timeletter.presentation.component.LetterTheme import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem @@ -15,7 +15,7 @@ data class AfternoteListItemSeed( val serviceNameResId: Int?, val serviceNameLiteral: String?, val date: String, - val iconResId: Int + val iconResId: Int, ) data class ReceiverDummyDetail( @@ -26,7 +26,7 @@ data class ReceiverDummyDetail( val email: String, val dailyQuestionCount: Int, val timeLetterCount: Int, - val afternoteCount: Int + val afternoteCount: Int, ) /** @@ -34,69 +34,95 @@ data class ReceiverDummyDetail( * Replace with API load when backend is ready. */ object ReceiverDummies { - val receiverList: List = listOf( - AfternoteEditReceiver(id = "receiver_1", name = "김지은", label = "딸"), - AfternoteEditReceiver(id = "receiver_2", name = "김혜성", label = "아들"), - AfternoteEditReceiver(id = "receiver_3", name = "박서연", label = "조카"), - AfternoteEditReceiver(id = "receiver_4", name = "황은주", label = "언니"), - AfternoteEditReceiver(id = "receiver_5", name = "황은경", label = "동생") - ) + val receiverList: List = + listOf( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "receiver_1", + name = "김지은", + label = "딸", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "receiver_2", + name = "김혜성", + label = "아들", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "receiver_3", + name = "박서연", + label = "조카", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "receiver_4", + name = "황은주", + label = "언니", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "receiver_5", + name = "황은경", + label = "동생", + ), + ) - private val receiverDetails: Map = mapOf( - "receiver_1" to ReceiverDummyDetail( - receiverId = "receiver_1", - name = "김지은", - relationship = "딸", - phoneNumber = "010-1234-1234", - email = "jieun01@naver.com", - dailyQuestionCount = 8, - timeLetterCount = 12, - afternoteCount = 4 - ), - "receiver_2" to ReceiverDummyDetail( - receiverId = "receiver_2", - name = "김혜성", - relationship = "아들", - phoneNumber = "010-9876-5432", - email = "hyeseong02@gmail.com", - dailyQuestionCount = 1, - timeLetterCount = 0, - afternoteCount = 2 - ), - "receiver_3" to ReceiverDummyDetail( - receiverId = "receiver_3", - name = "박서연", - relationship = "조카", - phoneNumber = "010-4567-8901", - email = "seoyeon03@kakao.com", - dailyQuestionCount = 0, - timeLetterCount = 3, - afternoteCount = 0 - ), - "receiver_4" to ReceiverDummyDetail( - receiverId = "receiver_4", - name = "황은주", - relationship = "언니", - phoneNumber = "010-0000-0000", - email = "eunju04@outlook.com", - dailyQuestionCount = 21, - timeLetterCount = 6, - afternoteCount = 10 - ), - "receiver_5" to ReceiverDummyDetail( - receiverId = "receiver_5", - name = "황은경", - relationship = "동생", - phoneNumber = "010-1111-2222", - email = "eunkyung05@naver.com", - dailyQuestionCount = 5, - timeLetterCount = 2, - afternoteCount = 1 + private val receiverDetails: Map = + mapOf( + "receiver_1" to + ReceiverDummyDetail( + receiverId = "receiver_1", + name = "김지은", + relationship = "딸", + phoneNumber = "010-1234-1234", + email = "jieun01@naver.com", + dailyQuestionCount = 8, + timeLetterCount = 12, + afternoteCount = 4, + ), + "receiver_2" to + ReceiverDummyDetail( + receiverId = "receiver_2", + name = "김혜성", + relationship = "아들", + phoneNumber = "010-9876-5432", + email = "hyeseong02@gmail.com", + dailyQuestionCount = 1, + timeLetterCount = 0, + afternoteCount = 2, + ), + "receiver_3" to + ReceiverDummyDetail( + receiverId = "receiver_3", + name = "박서연", + relationship = "조카", + phoneNumber = "010-4567-8901", + email = "seoyeon03@kakao.com", + dailyQuestionCount = 0, + timeLetterCount = 3, + afternoteCount = 0, + ), + "receiver_4" to + ReceiverDummyDetail( + receiverId = "receiver_4", + name = "황은주", + relationship = "언니", + phoneNumber = "010-0000-0000", + email = "eunju04@outlook.com", + dailyQuestionCount = 21, + timeLetterCount = 6, + afternoteCount = 10, + ), + "receiver_5" to + ReceiverDummyDetail( + receiverId = "receiver_5", + name = "황은경", + relationship = "동생", + phoneNumber = "010-1111-2222", + email = "eunkyung05@naver.com", + dailyQuestionCount = 5, + timeLetterCount = 2, + afternoteCount = 1, + ), ) - ) - fun detailOf(receiverId: String): ReceiverDummyDetail = - receiverDetails[receiverId] ?: receiverDetails.getValue("receiver_1") + fun detailOf(receiverId: String): ReceiverDummyDetail = receiverDetails[receiverId] ?: receiverDetails.getValue("receiver_1") /** Default receiver name for receiver afternote main / preview. */ fun defaultReceiverTitle(): String = "박서연" @@ -112,22 +138,22 @@ object ReceiverDummies { serviceNameResId = null, serviceNameLiteral = "추모 가이드라인", date = "2025.12.01", - iconResId = R.drawable.img_logo + iconResId = R.drawable.img_logo, ), AfternoteListItemSeed( id = "2", serviceNameResId = null, serviceNameLiteral = "갤러리", date = "2025.12.02", - iconResId = R.drawable.img_insta_pattern + iconResId = R.drawable.img_insta_pattern, ), AfternoteListItemSeed( id = "3", serviceNameResId = null, serviceNameLiteral = "인스타그램", date = "2025.12.03", - iconResId = R.drawable.img_insta_pattern - ) + iconResId = R.drawable.img_insta_pattern, + ), ) /** @@ -141,29 +167,29 @@ object ReceiverDummies { serviceNameResId = R.string.receiver_afternote_item_instagram, serviceNameLiteral = null, date = "2026.2.6", - iconResId = R.drawable.img_insta_pattern + iconResId = R.drawable.img_insta_pattern, ), AfternoteListItemSeed( id = "gallery", serviceNameResId = R.string.receiver_afternote_item_gallery, serviceNameLiteral = null, date = "2026.2.7", - iconResId = R.drawable.ic_gallery_pattern + iconResId = R.drawable.ic_gallery_pattern, ), AfternoteListItemSeed( id = "memorial_guideline", serviceNameResId = R.string.receiver_afternote_item_memorial_guideline, serviceNameLiteral = null, date = "2026.2.8", - iconResId = R.drawable.ic_memorial_guideline + iconResId = R.drawable.ic_memorial_guideline, ), AfternoteListItemSeed( id = "naver_mail", serviceNameResId = R.string.receiver_afternote_item_naver_mail, serviceNameLiteral = null, date = "2026.2.9", - iconResId = R.drawable.img_naver_mail_pattern - ) + iconResId = R.drawable.img_naver_mail_pattern, + ), ) /** @@ -181,12 +207,13 @@ object ReceiverDummies { title = "채연아 20번째 생일을 축하해", content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", imageResId = if (index == 0) R.drawable.ic_test_block else null, - theme = when (index % 3) { - 0 -> LetterTheme.BLUE - 1 -> LetterTheme.YELLOW - else -> LetterTheme.PEACH - }, - createDate = "2026.11.24" + theme = + when (index % 3) { + 0 -> LetterTheme.BLUE + 1 -> LetterTheme.YELLOW + else -> LetterTheme.PEACH + }, + createDate = "2026.11.24", ) } } @@ -200,7 +227,7 @@ object ReceiverDummies { receiverId: String, question: String, answer: String, - dateText: String + dateText: String, ): List { val count = detailOf(receiverId).dailyQuestionCount return List(count) { @@ -221,7 +248,7 @@ object ReceiverDummies { content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", imageResId = R.drawable.ic_test_block, theme = LetterTheme.PEACH, - createDate = "2026.11.24" + createDate = "2026.11.24", ), TimeLetterItem( id = "2", @@ -231,7 +258,7 @@ object ReceiverDummies { content = "드디어 졸업이구나! 우리가 함께한 시간들이 정말 소중했어. 앞으로도 좋은 일만 가득하길...", imageResId = null, theme = LetterTheme.BLUE, - createDate = "2026.11.24" + createDate = "2026.11.24", ), TimeLetterItem( id = "3", @@ -241,7 +268,7 @@ object ReceiverDummies { content = "새해가 밝았어! 올해도 건강하고 행복하게 보내길 바라. 사랑해!", imageResId = R.drawable.ic_test_block, theme = LetterTheme.YELLOW, - createDate = "2026.11.24" + createDate = "2026.11.24", ), TimeLetterItem( id = "4", @@ -251,7 +278,7 @@ object ReceiverDummies { content = "정말 오랜만이다! 요즘 어떻게 지내? 다음에 시간 되면 같이 밥 먹자.", imageResId = null, theme = LetterTheme.PEACH, - createDate = "2026.11.24" - ) + createDate = "2026.11.24", + ), ) } diff --git a/app/src/main/java/com/kuit/afternote/core/ui/component/detail/ReceiversCard.kt b/app/src/main/java/com/kuit/afternote/core/ui/component/detail/ReceiversCard.kt index 3cdd31df..f35892ae 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/component/detail/ReceiversCard.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/component/detail/ReceiversCard.kt @@ -16,13 +16,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kuit.afternote.R -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme import com.kuit.afternote.ui.theme.Black import com.kuit.afternote.ui.theme.Gray8 @@ -36,7 +36,7 @@ import com.kuit.afternote.ui.theme.Sansneo @Composable fun ReceiversCard( modifier: Modifier = Modifier, - receivers: List + receivers: List, ) { if (receivers.isEmpty()) return @@ -44,67 +44,71 @@ fun ReceiversCard( modifier = modifier.fillMaxWidth(), content = { Column( - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + verticalArrangement = Arrangement.spacedBy(space = 8.dp), ) { Text( text = stringResource(R.string.afternote_detail_receivers_label), - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) receivers.forEach { receiver -> ReceiverDetailItem(receiver = receiver) } } - } + }, ) } @Composable private fun ReceiverDetailItem( modifier: Modifier = Modifier, - receiver: AfternoteEditReceiver + receiver: com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver, ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Box( - modifier = Modifier - .size(58.dp) - .clip(CircleShape) + modifier = + Modifier + .size(58.dp) + .clip(CircleShape), ) { Image( painter = painterResource(R.drawable.img_recipient_profile), contentDescription = "프로필 사진", - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) } Column { Text( text = receiver.name, - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Black - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Black, + ), ) Text( text = receiver.label, - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray8 - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray8, + ), ) } } @@ -115,10 +119,19 @@ private fun ReceiverDetailItem( private fun ReceiversCardPreview() { AfternoteLightTheme { ReceiversCard( - receivers = listOf( - AfternoteEditReceiver(id = "1", name = "황규운", label = "친구"), - AfternoteEditReceiver(id = "2", name = "김소희", label = "가족") - ) + receivers = + listOf( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "1", + name = "황규운", + label = "친구", + ), + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = "2", + name = "김소희", + label = "가족", + ), + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt index e41ab183..b4fd2218 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt @@ -31,7 +31,7 @@ import com.kuit.afternote.core.ui.component.detail.ProcessingMethodItem import com.kuit.afternote.core.ui.component.detail.ReceiversCard import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme import com.kuit.afternote.ui.theme.B1 import com.kuit.afternote.ui.theme.Gray5 @@ -47,10 +47,10 @@ data class GalleryDetailState( val serviceName: String = "갤러리", val userName: String = "서영", val finalWriteDate: String = "2025.11.26.", - val afternoteEditReceivers: List = emptyList(), + val afternoteEditReceivers: List = emptyList(), val informationProcessingMethod: String = "", val processingMethods: List = emptyList(), - val message: String = "" + val message: String = "", ) /** @@ -60,7 +60,7 @@ data class GalleryDetailState( data class GalleryDetailCallbacks( val onBackClick: () -> Unit, val onEditClick: () -> Unit, - val onDeleteConfirm: () -> Unit = {} + val onDeleteConfirm: () -> Unit = {}, ) /** @@ -82,14 +82,14 @@ fun GalleryDetailScreen( detailState: GalleryDetailState, callbacks: GalleryDetailCallbacks, isEditable: Boolean = true, - uiState: AfternoteDetailState = rememberAfternoteDetailState() + uiState: AfternoteDetailState = rememberAfternoteDetailState(), ) { GalleryDetailScaffold( modifier = modifier, detailState = detailState, callbacks = callbacks, isEditable = isEditable, - uiState = uiState + uiState = uiState, ) } @@ -99,7 +99,7 @@ private fun GalleryDetailScaffold( detailState: GalleryDetailState, callbacks: GalleryDetailCallbacks, isEditable: Boolean, - uiState: AfternoteDetailState + uiState: AfternoteDetailState, ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -107,28 +107,28 @@ private fun GalleryDetailScaffold( if (isEditable) { TopBar( onBackClick = callbacks.onBackClick, - onEditClick = uiState::toggleDropdownMenu + onEditClick = uiState::toggleDropdownMenu, ) } else { TopBar( title = "", - onBackClick = callbacks.onBackClick + onBackClick = callbacks.onBackClick, ) } }, bottomBar = { BottomNavigationBar( selectedItem = uiState.selectedBottomNavItem, - onItemSelected = uiState::onBottomNavItemSelected + onItemSelected = uiState::onBottomNavItemSelected, ) - } + }, ) { paddingValues -> GalleryDetailContent( modifier = Modifier.padding(paddingValues), detailState = detailState, callbacks = callbacks, isEditable = isEditable, - uiState = uiState + uiState = uiState, ) } } @@ -139,10 +139,10 @@ private fun GalleryDetailContent( detailState: GalleryDetailState, callbacks: GalleryDetailCallbacks, isEditable: Boolean, - uiState: AfternoteDetailState + uiState: AfternoteDetailState, ) { Box( - modifier = modifier.fillMaxSize() + modifier = modifier.fillMaxSize(), ) { Column(modifier = Modifier.fillMaxSize()) { GalleryDetailScrollableContent(detailState = detailState) @@ -150,15 +150,16 @@ private fun GalleryDetailContent( if (isEditable) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(end = 20.dp) + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(end = 20.dp), ) { EditDropdownMenu( expanded = uiState.showDropdownMenu, onDismissRequest = uiState::hideDropdownMenu, onEditClick = { callbacks.onEditClick() }, - onDeleteClick = { uiState.showDeleteDialog() } + onDeleteClick = { uiState.showDeleteDialog() }, ) } @@ -169,7 +170,7 @@ private fun GalleryDetailContent( onConfirm = { uiState.hideDeleteDialog() callbacks.onDeleteConfirm() - } + }, ) } } @@ -179,15 +180,16 @@ private fun GalleryDetailContent( @Composable private fun GalleryDetailScrollableContent(detailState: GalleryDetailState) { Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), ) { Spacer(modifier = Modifier.height(24.dp)) TitleSection( serviceName = detailState.serviceName, - userName = detailState.userName + userName = detailState.userName, ) Spacer(modifier = Modifier.height(24.dp)) CardSection(detailState = detailState) @@ -199,7 +201,7 @@ private fun CardSection(detailState: GalleryDetailState) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { DateAndMethodCard( finalWriteDate = detailState.finalWriteDate, - informationProcessingMethod = detailState.informationProcessingMethod + informationProcessingMethod = detailState.informationProcessingMethod, ) ReceiversCard(receivers = detailState.afternoteEditReceivers) ProcessingMethodsCard(processingMethods = detailState.processingMethods) @@ -210,29 +212,31 @@ private fun CardSection(detailState: GalleryDetailState) { @Composable private fun TitleSection( serviceName: String, - userName: String + userName: String, ) { Text( - text = buildAnnotatedString { - withStyle(style = SpanStyle(color = B1)) { - append(serviceName) - } - append("에 대한 ${userName}님의 기록") - }, - style = TextStyle( - fontSize = 18.sp, - lineHeight = 24.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Bold, - color = Gray9 - ) + text = + buildAnnotatedString { + withStyle(style = SpanStyle(color = B1)) { + append(serviceName) + } + append("에 대한 ${userName}님의 기록") + }, + style = + TextStyle( + fontSize = 18.sp, + lineHeight = 24.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Bold, + color = Gray9, + ), ) } @Composable private fun DateAndMethodCard( finalWriteDate: String, - informationProcessingMethod: String + informationProcessingMethod: String, ) { InfoCard( modifier = Modifier.fillMaxWidth(), @@ -240,19 +244,20 @@ private fun DateAndMethodCard( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "최종 작성일 $finalWriteDate", - style = TextStyle( - fontSize = 10.sp, - lineHeight = 16.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray6 - ) + style = + TextStyle( + fontSize = 10.sp, + lineHeight = 16.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray6, + ), ) InformationProcessingMethodText( - informationProcessingMethod = informationProcessingMethod + informationProcessingMethod = informationProcessingMethod, ) } - } + }, ) } @@ -267,23 +272,30 @@ private fun InformationProcessingMethodText(informationProcessingMethod: String) "TRANSFER", "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER", "ADDITIONAL", - "TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER" -> buildAnnotatedString { - withStyle(style = SpanStyle(color = B1)) { append("수신자") } - append("에게 정보 전달") + "TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER", + -> { + buildAnnotatedString { + withStyle(style = SpanStyle(color = B1)) { append("수신자") } + append("에게 정보 전달") + } } - else -> buildAnnotatedString { - append(informationProcessingMethod) + + else -> { + buildAnnotatedString { + append(informationProcessingMethod) + } } } Text( text = annotatedText, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) } @@ -297,13 +309,14 @@ private fun ProcessingMethodsCard(processingMethods: List) { Column { Text( text = "처리 방법", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(8.dp)) processingMethods.forEachIndexed { index, method -> @@ -313,7 +326,7 @@ private fun ProcessingMethodsCard(processingMethods: List) { ProcessingMethodItem(text = method) } } - } + }, ) } @@ -328,46 +341,50 @@ private fun MessageCard(message: String) { Column { Text( text = "남기신 말씀", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(8.dp)) Text( text = displayMessage, - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = textColor - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = textColor, + ), ) } - } + }, ) } @Preview( showBackground = true, - device = "spec:width=390dp,height=844dp,dpi=420,isRound=false" + device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", ) @Composable private fun GalleryDetailScreenPreview() { AfternoteLightTheme { GalleryDetailScreen( - detailState = GalleryDetailState( - informationProcessingMethod = "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER", - processingMethods = listOf("'엽사' 폴더 박선호에게 전송", "'흑역사' 폴더 삭제") - ), - callbacks = GalleryDetailCallbacks( - onBackClick = {}, - onEditClick = {} - ) + detailState = + GalleryDetailState( + informationProcessingMethod = "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER", + processingMethods = listOf("'엽사' 폴더 박선호에게 전송", "'흑역사' 폴더 삭제"), + ), + callbacks = + GalleryDetailCallbacks( + onBackClick = {}, + onEditClick = {}, + ), ) } } @@ -375,7 +392,7 @@ private fun GalleryDetailScreenPreview() { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "Gallery Detail Screen with Delete Dialog" + name = "Gallery Detail Screen with Delete Dialog", ) @Composable private fun GalleryDetailScreenWithDialogPreview() { @@ -384,15 +401,17 @@ private fun GalleryDetailScreenWithDialogPreview() { uiState.showDeleteDialog() GalleryDetailScreen( - detailState = GalleryDetailState( - informationProcessingMethod = "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER", - processingMethods = listOf("'엽사' 폴더 박선호에게 전송", "'흑역사' 폴더 삭제") - ), - callbacks = GalleryDetailCallbacks( - onBackClick = {}, - onEditClick = {} - ), - uiState = uiState + detailState = + GalleryDetailState( + informationProcessingMethod = "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER", + processingMethods = listOf("'엽사' 폴더 박선호에게 전송", "'흑역사' 폴더 삭제"), + ), + callbacks = + GalleryDetailCallbacks( + onBackClick = {}, + onEditClick = {}, + ), + uiState = uiState, ) } } diff --git a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt index 8e3af1ed..c5331517 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt @@ -52,7 +52,7 @@ import com.kuit.afternote.core.ui.component.list.AlbumCover import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme import com.kuit.afternote.ui.expand.horizontalFadingEdge import com.kuit.afternote.ui.theme.B1 @@ -73,7 +73,7 @@ data class MemorialGuidelineDetailState( val albumCovers: List = emptyList(), val songCount: Int = 0, val lastWish: String = "", - val afternoteEditReceivers: List = emptyList(), + val afternoteEditReceivers: List = emptyList(), val memorialVideoUrl: String? = null, val memorialThumbnailUrl: String? = null, ) @@ -85,7 +85,7 @@ data class MemorialGuidelineDetailState( data class MemorialGuidelineDetailCallbacks( val onBackClick: () -> Unit, val onEditClick: () -> Unit = {}, - val onDeleteConfirm: () -> Unit = {} + val onDeleteConfirm: () -> Unit = {}, ) private const val CATEGORY_NAME = "추모 가이드 라인" @@ -108,7 +108,7 @@ fun MemorialGuidelineDetailScreen( detailState: MemorialGuidelineDetailState, callbacks: MemorialGuidelineDetailCallbacks, isEditable: Boolean = true, - uiState: AfternoteDetailState = rememberAfternoteDetailState() + uiState: AfternoteDetailState = rememberAfternoteDetailState(), ) { if (isEditable && uiState.showDeleteDialog) { DeleteConfirmDialog( @@ -117,7 +117,7 @@ fun MemorialGuidelineDetailScreen( onConfirm = { uiState.hideDeleteDialog() callbacks.onDeleteConfirm() - } + }, ) } @@ -127,41 +127,43 @@ fun MemorialGuidelineDetailScreen( if (isEditable) { TopBar( onBackClick = callbacks.onBackClick, - onEditClick = uiState::toggleDropdownMenu + onEditClick = uiState::toggleDropdownMenu, ) } else { TopBar( title = "", - onBackClick = callbacks.onBackClick + onBackClick = callbacks.onBackClick, ) } }, bottomBar = { BottomNavigationBar( selectedItem = uiState.selectedBottomNavItem, - onItemSelected = uiState::onBottomNavItemSelected + onItemSelected = uiState::onBottomNavItemSelected, ) - } + }, ) { paddingValues -> Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), ) { Column(modifier = Modifier.fillMaxSize()) { MemorialGuidelineDetailScrollContent(detailState = detailState) } if (isEditable) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(end = 20.dp) + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(end = 20.dp), ) { EditDropdownMenu( expanded = uiState.showDropdownMenu, onDismissRequest = uiState::hideDropdownMenu, onEditClick = callbacks.onEditClick, - onDeleteClick = { uiState.showDeleteDialog() } + onDeleteClick = { uiState.showDeleteDialog() }, ) } } @@ -170,14 +172,13 @@ fun MemorialGuidelineDetailScreen( } @Composable -private fun MemorialGuidelineDetailScrollContent( - detailState: MemorialGuidelineDetailState -) { +private fun MemorialGuidelineDetailScrollContent(detailState: MemorialGuidelineDetailState) { Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), ) { Spacer(modifier = Modifier.height(24.dp)) TitleSection(userName = detailState.userName) @@ -189,19 +190,21 @@ private fun MemorialGuidelineDetailScrollContent( @Composable private fun TitleSection(userName: String) { Text( - text = buildAnnotatedString { - withStyle(style = SpanStyle(color = B1)) { - append(CATEGORY_NAME) - } - append("에 대한 ${userName}님의 기록") - }, - style = TextStyle( - fontSize = 18.sp, - lineHeight = 24.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Bold, - color = Gray9 - ) + text = + buildAnnotatedString { + withStyle(style = SpanStyle(color = B1)) { + append(CATEGORY_NAME) + } + append("에 대한 ${userName}님의 기록") + }, + style = + TextStyle( + fontSize = 18.sp, + lineHeight = 24.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Bold, + color = Gray9, + ), ) } @@ -210,12 +213,12 @@ private fun CardSection(detailState: MemorialGuidelineDetailState) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { PhotoCard( finalWriteDate = detailState.finalWriteDate, - profileImageUri = detailState.profileImageUri + profileImageUri = detailState.profileImageUri, ) ReceiversCard(receivers = detailState.afternoteEditReceivers) PlaylistCard( albumCovers = detailState.albumCovers, - songCount = detailState.songCount + songCount = detailState.songCount, ) LastWishCard(lastWish = detailState.lastWish) VideoCard( @@ -228,7 +231,7 @@ private fun CardSection(detailState: MemorialGuidelineDetailState) { @Composable private fun PhotoCard( finalWriteDate: String, - profileImageUri: String? + profileImageUri: String?, ) { InfoCard( modifier = Modifier.fillMaxWidth(), @@ -236,27 +239,28 @@ private fun PhotoCard( Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "최종 작성일 $finalWriteDate", modifier = Modifier.fillMaxWidth(), - style = TextStyle( - fontSize = 10.sp, - lineHeight = 16.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray6 - ) + style = + TextStyle( + fontSize = 10.sp, + lineHeight = 16.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray6, + ), ) ProfileImage( fallbackImageRes = R.drawable.img_default_profile_deceased, profileImageSize = 144.dp, isEditable = false, - displayImageUri = profileImageUri + displayImageUri = profileImageUri, ) } - } + }, ) } @@ -279,17 +283,18 @@ private fun VideoCard( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "장례식에 남길 영상", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) VideoThumbnail(thumbnailUrl = thumbnailUrl) } - } + }, ) } @@ -301,49 +306,57 @@ private fun VideoCard( @Composable private fun VideoThumbnail(thumbnailUrl: String?) { Box( - modifier = Modifier - .fillMaxWidth() - .height(183.dp) - .clip(RoundedCornerShape(16.dp)) + modifier = + Modifier + .fillMaxWidth() + .height(183.dp) + .clip(RoundedCornerShape(16.dp)), ) { // 썸네일 이미지 if (!thumbnailUrl.isNullOrBlank()) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(thumbnailUrl) - .httpHeaders( - NetworkHeaders.Builder().apply { - this["User-Agent"] = "Afternote Android App" - }.build() - ) - .build(), + model = + ImageRequest + .Builder(LocalContext.current) + .data(thumbnailUrl) + .httpHeaders( + NetworkHeaders + .Builder() + .apply { + this["User-Agent"] = "Afternote Android App" + }.build(), + ).build(), contentDescription = "장례식에 남길 영상 썸네일", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) } // 다크 그라데이션 오버레이 Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0x99757575), - Color(0x99222222) - ) - ) - ) + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color(0x99757575), + Color(0x99222222), + ), + ), + ), ) // 재생 아이콘 Image( painter = painterResource(R.drawable.ic_playback), contentDescription = "영상 재생", - modifier = Modifier - .align(Alignment.Center) - .size(32.dp) + modifier = + Modifier + .align(Alignment.Center) + .size(32.dp), ) } } @@ -358,7 +371,7 @@ private fun VideoThumbnail(thumbnailUrl: String?) { @Composable private fun PlaylistCard( albumCovers: List, - songCount: Int + songCount: Int, ) { InfoCard( modifier = Modifier.fillMaxWidth(), @@ -366,13 +379,14 @@ private fun PlaylistCard( Column { Text( text = "추모 플레이리스트", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(Modifier.height(7.dp)) if (albumCovers.isNotEmpty()) { @@ -381,26 +395,28 @@ private fun PlaylistCard( Spacer(Modifier.height(8.dp)) Text( text = "현재 ${songCount}개의 노래가 담겨 있습니다.", - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Black - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Black, + ), ) } - } + }, ) } @Composable private fun PlaylistAlbumRow(albumCovers: List) { LazyRow( - modifier = Modifier - .fillMaxWidth() - .horizontalFadingEdge(edgeWidth = 45.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp) + modifier = + Modifier + .fillMaxWidth() + .horizontalFadingEdge(edgeWidth = 45.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { itemsIndexed(albumCovers) { _, album -> AlbumCoverItem(album = album) @@ -412,26 +428,30 @@ private fun PlaylistAlbumRow(albumCovers: List) { private fun AlbumCoverItem(album: AlbumCover) { if (!album.imageUrl.isNullOrBlank()) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(album.imageUrl) - .httpHeaders( - NetworkHeaders.Builder().apply { - this["User-Agent"] = "Afternote Android App" - }.build() - ) - .build(), + model = + ImageRequest + .Builder(LocalContext.current) + .data(album.imageUrl) + .httpHeaders( + NetworkHeaders + .Builder() + .apply { + this["User-Agent"] = "Afternote Android App" + }.build(), + ).build(), contentDescription = album.title, modifier = Modifier.size(87.dp), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) } else { Box( - modifier = Modifier - .size(87.dp) - .background( - color = Color.LightGray, - shape = RoundedCornerShape(8.dp) - ) + modifier = + Modifier + .size(87.dp) + .background( + color = Color.LightGray, + shape = RoundedCornerShape(8.dp), + ), ) } } @@ -447,47 +467,51 @@ private fun LastWishCard(lastWish: String) { Column { Text( text = "남기고 싶은 당부", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(8.dp)) Text( text = displayText, - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = textColor - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = textColor, + ), ) } - } + }, ) } @Preview( showBackground = true, - device = "spec:width=390dp,height=844dp,dpi=420,isRound=false" + device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", ) @Composable private fun MemorialGuidelineDetailScreenPreview() { AfternoteLightTheme { MemorialGuidelineDetailScreen( - detailState = MemorialGuidelineDetailState( - songCount = 16, - albumCovers = AlbumDummies.list, - lastWish = "차분하고 조용하게 보내주세요." - ), - callbacks = MemorialGuidelineDetailCallbacks( - onBackClick = {}, - onEditClick = {} - ) + detailState = + MemorialGuidelineDetailState( + songCount = 16, + albumCovers = AlbumDummies.list, + lastWish = "차분하고 조용하게 보내주세요.", + ), + callbacks = + MemorialGuidelineDetailCallbacks( + onBackClick = {}, + onEditClick = {}, + ), ) } } @@ -495,27 +519,30 @@ private fun MemorialGuidelineDetailScreenPreview() { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "Memorial Guideline Detail - Delete Dialog" + name = "Memorial Guideline Detail - Delete Dialog", ) @Composable private fun MemorialGuidelineDetailScreenDeleteDialogPreview() { AfternoteLightTheme { - val stateWithDialog = remember { - AfternoteDetailState().apply { - showDeleteDialog() + val stateWithDialog = + remember { + AfternoteDetailState().apply { + showDeleteDialog() + } } - } MemorialGuidelineDetailScreen( - detailState = MemorialGuidelineDetailState( - songCount = 16, - albumCovers = AlbumDummies.list, - lastWish = "차분하고 조용하게 보내주세요.1" - ), - callbacks = MemorialGuidelineDetailCallbacks( - onBackClick = {}, - onEditClick = {} - ), - uiState = stateWithDialog + detailState = + MemorialGuidelineDetailState( + songCount = 16, + albumCovers = AlbumDummies.list, + lastWish = "차분하고 조용하게 보내주세요.1", + ), + callbacks = + MemorialGuidelineDetailCallbacks( + onBackClick = {}, + onEditClick = {}, + ), + uiState = stateWithDialog, ) } } @@ -523,24 +550,27 @@ private fun MemorialGuidelineDetailScreenDeleteDialogPreview() { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "Memorial Guideline Detail - Receiver Mode" + name = "Memorial Guideline Detail - Receiver Mode", ) @Composable private fun MemorialGuidelineDetailScreenReceiverModePreview() { AfternoteLightTheme { MemorialGuidelineDetailScreen( - detailState = MemorialGuidelineDetailState( - songCount = 16, - albumCovers = AlbumDummies.list, - lastWish = "차분하고 조용하게 보내주세요.2" - ), - callbacks = MemorialGuidelineDetailCallbacks( - onBackClick = {} - ), + detailState = + MemorialGuidelineDetailState( + songCount = 16, + albumCovers = AlbumDummies.list, + lastWish = "차분하고 조용하게 보내주세요.2", + ), + callbacks = + MemorialGuidelineDetailCallbacks( + onBackClick = {}, + ), isEditable = false, - uiState = rememberAfternoteDetailState( - defaultBottomNavItem = BottomNavItem.AFTERNOTE - ) + uiState = + rememberAfternoteDetailState( + defaultBottomNavItem = BottomNavItem.AFTERNOTE, + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/SocialNetworkDetailScreen.kt b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/SocialNetworkDetailScreen.kt index 934b7bd1..4306187a 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/SocialNetworkDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/SocialNetworkDetailScreen.kt @@ -33,12 +33,12 @@ import com.kuit.afternote.core.ui.component.detail.ReceiversCard import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.B1 import com.kuit.afternote.ui.theme.Gray5 import com.kuit.afternote.ui.theme.Gray6 import com.kuit.afternote.ui.theme.Gray9 -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver import com.kuit.afternote.ui.theme.Sansneo /** @@ -55,7 +55,7 @@ data class SocialNetworkDetailContent( val processingMethods: List = emptyList(), val message: String = "", val finalWriteDate: String = "2025.11.26.", - val afternoteEditReceivers: List = emptyList() + val afternoteEditReceivers: List = emptyList(), ) /** @@ -82,7 +82,7 @@ fun SocialNetworkDetailScreen( onBackClick: () -> Unit, onEditClick: () -> Unit = {}, onDeleteConfirm: () -> Unit = {}, - state: AfternoteDetailState = rememberAfternoteDetailState() + state: AfternoteDetailState = rememberAfternoteDetailState(), ) { if (isEditable && state.showDeleteDialog) { DeleteConfirmDialog( @@ -91,7 +91,7 @@ fun SocialNetworkDetailScreen( onConfirm = { state.hideDeleteDialog() onDeleteConfirm() - } + }, ) } @@ -101,41 +101,43 @@ fun SocialNetworkDetailScreen( if (isEditable) { TopBar( onBackClick = onBackClick, - onEditClick = state::toggleDropdownMenu + onEditClick = state::toggleDropdownMenu, ) } else { TopBar( title = "", - onBackClick = onBackClick + onBackClick = onBackClick, ) } }, bottomBar = { BottomNavigationBar( selectedItem = state.selectedBottomNavItem, - onItemSelected = state::onBottomNavItemSelected + onItemSelected = state::onBottomNavItemSelected, ) - } + }, ) { paddingValues -> Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), ) { Column(modifier = Modifier.fillMaxSize()) { SocialNetworkDetailScrollContent(content = content) } if (isEditable) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(end = 20.dp) + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(end = 20.dp), ) { EditDropdownMenu( expanded = state.showDropdownMenu, onDismissRequest = state::hideDropdownMenu, onEditClick = onEditClick, - onDeleteClick = { state.showDeleteDialog() } + onDeleteClick = { state.showDeleteDialog() }, ) } } @@ -146,26 +148,29 @@ fun SocialNetworkDetailScreen( @Composable private fun SocialNetworkDetailScrollContent(content: SocialNetworkDetailContent) { Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), ) { Spacer(modifier = Modifier.height(24.dp)) Text( - text = buildAnnotatedString { - withStyle(style = SpanStyle(color = B1)) { - append(content.serviceName) - } - append("에 대한 ${content.userName}님의 기록") - }, - style = TextStyle( - fontSize = 18.sp, - lineHeight = 24.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Bold, - color = Gray9 - ) + text = + buildAnnotatedString { + withStyle(style = SpanStyle(color = B1)) { + append(content.serviceName) + } + append("에 대한 ${content.userName}님의 기록") + }, + style = + TextStyle( + fontSize = 18.sp, + lineHeight = 24.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Bold, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(24.dp)) InfoCard( @@ -174,19 +179,20 @@ private fun SocialNetworkDetailScrollContent(content: SocialNetworkDetailContent Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "최종 작성일 ${content.finalWriteDate}", - style = TextStyle( - fontSize = 10.sp, - lineHeight = 16.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray6 - ) + style = + TextStyle( + fontSize = 10.sp, + lineHeight = 16.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray6, + ), ) AccountProcessingMethodText( - accountProcessingMethod = content.accountProcessingMethod + accountProcessingMethod = content.accountProcessingMethod, ) } - } + }, ) Spacer(modifier = Modifier.height(8.dp)) ReceiversCard(receivers = content.afternoteEditReceivers) @@ -197,20 +203,21 @@ private fun SocialNetworkDetailScrollContent(content: SocialNetworkDetailContent Column { Text( text = "기록에 대한 개인 정보", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(7.dp)) InfoRow(label = "아이디", value = content.accountId) Spacer(modifier = Modifier.height(8.dp)) InfoRow(label = "비밀번호", value = content.password) } - } + }, ) Spacer(modifier = Modifier.height(8.dp)) if (content.processingMethods.isNotEmpty()) { @@ -220,13 +227,14 @@ private fun SocialNetworkDetailScrollContent(content: SocialNetworkDetailContent Column { Text( text = "처리 방법", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(8.dp)) content.processingMethods.forEachIndexed { index, method -> @@ -234,7 +242,7 @@ private fun SocialNetworkDetailScrollContent(content: SocialNetworkDetailContent ProcessingMethodItem(text = method) } } - } + }, ) Spacer(modifier = Modifier.height(8.dp)) } @@ -244,29 +252,31 @@ private fun SocialNetworkDetailScrollContent(content: SocialNetworkDetailContent Column { Text( text = "남기신 말씀", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(8.dp)) val displayMessage = content.message.ifEmpty { "남기신 말씀이 없습니다." } val textColor = if (content.message.isNotEmpty()) Gray9 else Gray5 Text( text = displayMessage, - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = textColor - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = textColor, + ), ) } - } + }, ) } } @@ -281,56 +291,72 @@ private fun AccountProcessingMethodText(accountProcessingMethod: String) { // 클라이언트 enum 이름을 모두 처리한다. val annotatedText = when (accountProcessingMethod) { - "MEMORIAL" -> buildAnnotatedString { - append("사망 후 ") - withStyle(style = SpanStyle(color = B1)) { append("추모 계정") } - append("으로 전환") + "MEMORIAL" -> { + buildAnnotatedString { + append("사망 후 ") + withStyle(style = SpanStyle(color = B1)) { append("추모 계정") } + append("으로 전환") + } } - "DELETE" -> buildAnnotatedString { - append("사망 후 ") - withStyle(style = SpanStyle(color = B1)) { append("계정 영구 삭제") } + + "DELETE" -> { + buildAnnotatedString { + append("사망 후 ") + withStyle(style = SpanStyle(color = B1)) { append("계정 영구 삭제") } + } } - "TRANSFER", "RECEIVER" -> buildAnnotatedString { - withStyle(style = SpanStyle(color = B1)) { append("수신자") } - append("에게 정보 전달") + + "TRANSFER", "RECEIVER" -> { + buildAnnotatedString { + withStyle(style = SpanStyle(color = B1)) { append("수신자") } + append("에게 정보 전달") + } } - "ADDITIONAL" -> buildAnnotatedString { - withStyle(style = SpanStyle(color = B1)) { append("추가 수신자") } - append("에게 정보 전달") + + "ADDITIONAL" -> { + buildAnnotatedString { + withStyle(style = SpanStyle(color = B1)) { append("추가 수신자") } + append("에게 정보 전달") + } } - else -> buildAnnotatedString { - append(accountProcessingMethod) + + else -> { + buildAnnotatedString { + append(accountProcessingMethod) + } } } Text( text = annotatedText, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) } @Preview( showBackground = true, - device = "spec:width=390dp,height=844dp,dpi=420,isRound=false" + device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", ) @Composable private fun SocialNetworkDetailScreenPreview() { AfternoteTheme { SocialNetworkDetailScreen( - content = SocialNetworkDetailContent( - accountId = "qwerty123", - password = "qwerty123", - accountProcessingMethod = "MEMORIAL_ACCOUNT", - processingMethods = listOf("게시물 내리기", "추모 게시물 올리기", "추모 계정으로 전환하기"), - message = "이 계정에는 우리 가족 여행 사진이 많아.\n계정 삭제하지 말고 꼭 추모 계정으로 남겨줘!" - ), + content = + SocialNetworkDetailContent( + accountId = "qwerty123", + password = "qwerty123", + accountProcessingMethod = "MEMORIAL_ACCOUNT", + processingMethods = listOf("게시물 내리기", "추모 게시물 올리기", "추모 계정으로 전환하기"), + message = "이 계정에는 우리 가족 여행 사진이 많아.\n계정 삭제하지 말고 꼭 추모 계정으로 남겨줘!", + ), onBackClick = {}, - onEditClick = {} + onEditClick = {}, ) } } @@ -338,21 +364,22 @@ private fun SocialNetworkDetailScreenPreview() { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "SocialNetworkDetailScreen with Delete Dialog" + name = "SocialNetworkDetailScreen with Delete Dialog", ) @Composable private fun SocialNetworkDetailScreenWithDeleteDialogPreview() { AfternoteTheme { - val stateWithDialog = remember { - AfternoteDetailState().apply { - showDeleteDialog() + val stateWithDialog = + remember { + AfternoteDetailState().apply { + showDeleteDialog() + } } - } SocialNetworkDetailScreen( content = SocialNetworkDetailContent(), onBackClick = {}, onEditClick = {}, - state = stateWithDialog + state = stateWithDialog, ) } } @@ -360,21 +387,22 @@ private fun SocialNetworkDetailScreenWithDeleteDialogPreview() { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "SocialNetworkDetailScreen with Edit Dropdown Menu" + name = "SocialNetworkDetailScreen with Edit Dropdown Menu", ) @Composable private fun SocialNetworkDetailScreenWithEditDropdownMenuPreview() { AfternoteTheme { - val stateWithDropdown = remember { - AfternoteDetailState().apply { - toggleDropdownMenu() + val stateWithDropdown = + remember { + AfternoteDetailState().apply { + toggleDropdownMenu() + } } - } SocialNetworkDetailScreen( content = SocialNetworkDetailContent(), onBackClick = {}, onEditClick = {}, - state = stateWithDropdown + state = stateWithDropdown, ) } } @@ -382,7 +410,7 @@ private fun SocialNetworkDetailScreenWithEditDropdownMenuPreview() { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "SocialNetworkDetailScreen - Receiver Mode (Read Only)" + name = "SocialNetworkDetailScreen - Receiver Mode (Read Only)", ) @Composable private fun SocialNetworkDetailScreenReceiverModePreview() { @@ -391,9 +419,10 @@ private fun SocialNetworkDetailScreenReceiverModePreview() { content = SocialNetworkDetailContent(), isEditable = false, onBackClick = {}, - state = rememberAfternoteDetailState( - defaultBottomNavItem = BottomNavItem.AFTERNOTE - ) + state = + rememberAfternoteDetailState( + defaultBottomNavItem = BottomNavItem.AFTERNOTE, + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/data/remote/ApiResponse.kt b/app/src/main/java/com/kuit/afternote/data/BaseResponse.kt similarity index 63% rename from app/src/main/java/com/kuit/afternote/data/remote/ApiResponse.kt rename to app/src/main/java/com/kuit/afternote/data/BaseResponse.kt index 100dd3e7..eead00d5 100644 --- a/app/src/main/java/com/kuit/afternote/data/remote/ApiResponse.kt +++ b/app/src/main/java/com/kuit/afternote/data/BaseResponse.kt @@ -1,5 +1,7 @@ -package com.kuit.afternote.data.remote +package com.kuit.afternote.data +import com.kuit.afternote.data.service.ApiException +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -10,11 +12,11 @@ import kotlinx.serialization.Serializable * @param T `data` 필드의 타입. null인 경우 [Unit] 또는 [Nothing] 사용. */ @Serializable -data class ApiResponse( - val status: Int, - val code: Int, - val message: String, - val data: T? = null +data class BaseResponse( + @SerialName("status") val status: Int, + @SerialName("message") val message: String?, + @SerialName("data") val data: T? = null, + @SerialName("code") val code: Int, ) /** @@ -23,14 +25,12 @@ data class ApiResponse( * @return non-null data * @throws ApiException 서버가 에러 응답을 반환한 경우 */ -fun ApiResponse.requireData(): T & Any { - if (status != 200) { - throw ApiException(status = status, code = code, message = message) - } +fun BaseResponse.requireData(): T & Any { + requireStatus() return data ?: throw ApiException( status = status, code = code, - message = message.ifBlank { "data is null" } + message = message ?: "데이터 필드 없음", ) } @@ -41,8 +41,8 @@ fun ApiResponse.requireData(): T & Any { * * @throws ApiException 서버가 에러 응답을 반환한 경우 */ -fun ApiResponse<*>.requireSuccess() { +fun BaseResponse<*>.requireStatus() { if (status != 200) { - throw ApiException(status = status, code = code, message = message) + throw ApiException(status = status, code = code, message = message ?: "Status가 200이 아님") } } diff --git a/app/src/main/java/com/kuit/afternote/data/di/PhotoUploadRepositoryModule.kt b/app/src/main/java/com/kuit/afternote/data/di/PhotoUploadRepositoryModule.kt new file mode 100644 index 00000000..c967e182 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/di/PhotoUploadRepositoryModule.kt @@ -0,0 +1,17 @@ +package com.kuit.afternote.data.di + +import com.kuit.afternote.data.repositoryimpl.PhotoUploadRepositoryImpl +import com.kuit.afternote.domain.repository.PhotoUploadRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class PhotoUploadRepositoryModule { + @Binds + @Singleton + abstract fun bindPhotoUploadRepository(impl: PhotoUploadRepositoryImpl): PhotoUploadRepository +} diff --git a/app/src/main/java/com/kuit/afternote/data/dto/MusicTrack.kt b/app/src/main/java/com/kuit/afternote/data/dto/MusicTrack.kt new file mode 100644 index 00000000..1fab6fe1 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/dto/MusicTrack.kt @@ -0,0 +1,14 @@ +package com.kuit.afternote.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Single track in music search response. + */ +@Serializable +data class MusicTrack( + @SerialName("artist") val artist: String, + @SerialName("title") val title: String, + @SerialName("albumImageUrl") val albumImageUrl: String? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/data/dto/response/MusicSearchResponse.kt b/app/src/main/java/com/kuit/afternote/data/dto/response/MusicSearchResponse.kt new file mode 100644 index 00000000..a508a420 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/dto/response/MusicSearchResponse.kt @@ -0,0 +1,14 @@ +package com.kuit.afternote.data.dto.response + +import com.kuit.afternote.data.dto.MusicTrack +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Response body for GET /music/search?keyword=. + * Server returns 200 with tracks array; 400 when keyword is missing. + */ +@Serializable +data class MusicSearchResponse( + @SerialName("tracks") val tracks: List = emptyList(), +) diff --git a/app/src/main/java/com/kuit/afternote/data/local/PostDeliveryConditionPreferences.kt b/app/src/main/java/com/kuit/afternote/data/local/PostDeliveryConditionPreferences.kt deleted file mode 100644 index 1ccc39dd..00000000 --- a/app/src/main/java/com/kuit/afternote/data/local/PostDeliveryConditionPreferences.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.kuit.afternote.data.local - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - -private val Context.postDeliveryDataStore: DataStore by - preferencesDataStore(name = "post_delivery_condition") - -/** - * 사후 전달 조건 설정을 로컬에 저장/조회합니다. - * - * @param deliveryMethod "AUTOMATIC_TRANSFER" | "RECEIVER_APPROVAL_TRANSFER" - * @param triggerCondition "APP_INACTIVITY" | "SPECIFIC_DATE" | "RECEIVER_REQUEST" - * @param triggerDateIso "yyyy-MM-dd" or null (when trigger is SPECIFIC_DATE) - */ -@Singleton -class PostDeliveryConditionPreferences -@Inject -constructor( - @ApplicationContext private val context: Context -) { - private val deliveryMethodKey = stringPreferencesKey("delivery_method") - private val triggerConditionKey = stringPreferencesKey("trigger_condition") - private val triggerDateIsoKey = stringPreferencesKey("trigger_date_iso") - - val deliveryMethodFlow: Flow = - context.postDeliveryDataStore.data.map { it[deliveryMethodKey] } - val triggerConditionFlow: Flow = - context.postDeliveryDataStore.data.map { it[triggerConditionKey] } - val triggerDateIsoFlow: Flow = - context.postDeliveryDataStore.data.map { it[triggerDateIsoKey] } - - suspend fun getDeliveryMethod(): String? = - context.postDeliveryDataStore.data.first()[deliveryMethodKey] - - suspend fun getTriggerCondition(): String? = - context.postDeliveryDataStore.data.first()[triggerConditionKey] - - suspend fun getTriggerDateIso(): String? = - context.postDeliveryDataStore.data.first()[triggerDateIsoKey] - - suspend fun save( - deliveryMethod: String, - triggerCondition: String, - triggerDateIso: String? - ) { - context.postDeliveryDataStore.edit { prefs -> - prefs[deliveryMethodKey] = deliveryMethod - prefs[triggerConditionKey] = triggerCondition - if (triggerDateIso != null) { - prefs[triggerDateIsoKey] = triggerDateIso - } else { - prefs.remove(triggerDateIsoKey) - } - } - } -} diff --git a/app/src/main/java/com/kuit/afternote/data/provider/DataProviderSwitch.kt b/app/src/main/java/com/kuit/afternote/data/provider/DataProviderSwitch.kt deleted file mode 100644 index 48260c88..00000000 --- a/app/src/main/java/com/kuit/afternote/data/provider/DataProviderSwitch.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.kuit.afternote.data.provider - -import android.content.Context -import androidx.core.content.edit -import com.kuit.afternote.BuildConfig -import com.kuit.afternote.domain.provider.AfternoteEditDataProvider -import com.kuit.afternote.domain.provider.ReceiverDataProvider -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -private const val PREFS_NAME = "data_provider_prefs" -private const val KEY_USE_FAKE = "use_fake_data" - -/** - * Holds the runtime choice between Fake (mock) and Real data providers. - * Persists the choice so it survives process death. Defaults to [BuildConfig.DEBUG] on first run. - */ -@Singleton -class DataProviderSwitch @Inject constructor( - private val fakeAfternote: FakeAfternoteEditDataProvider, - private val realAfternote: RealAfternoteEditDataProvider, - private val fakeReceiver: FakeReceiverDataProvider, - private val realReceiver: RealReceiverDataProvider, - @ApplicationContext private val context: Context -) { - private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - - private val _useFakeState = MutableStateFlow( - prefs.getBoolean(KEY_USE_FAKE, BuildConfig.DEBUG) - ) - val useFakeState: StateFlow = _useFakeState.asStateFlow() - - /** - * Initial value for useFake (from persisted prefs). Use this for Compose initialValue - * instead of reading [useFakeState].value inside composition. - */ - fun getInitialUseFake(): Boolean = prefs.getBoolean(KEY_USE_FAKE, BuildConfig.DEBUG) - - /** - * Sets whether to use Fake (mock) or Real data. Persists the value. - */ - fun setUseFake(useFake: Boolean) { - prefs.edit { putBoolean(KEY_USE_FAKE, useFake) } - _useFakeState.value = useFake - } - - fun getCurrentAfternoteEditDataProvider(): AfternoteEditDataProvider = - if (_useFakeState.value) fakeAfternote else realAfternote - - fun getCurrentReceiverDataProvider(): ReceiverDataProvider = - if (_useFakeState.value) fakeReceiver else realReceiver -} diff --git a/app/src/main/java/com/kuit/afternote/data/provider/FakeAfternoteEditDataProvider.kt b/app/src/main/java/com/kuit/afternote/data/provider/FakeAfternoteEditDataProvider.kt deleted file mode 100644 index 270621cc..00000000 --- a/app/src/main/java/com/kuit/afternote/data/provider/FakeAfternoteEditDataProvider.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.kuit.afternote.data.provider - -import com.kuit.afternote.core.dummy.afternote.AfternoteEditDummies -import com.kuit.afternote.core.dummy.afternote.AfternoteListDummies -import com.kuit.afternote.core.dummy.album.AlbumDummies -import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.domain.provider.AfternoteEditDataProvider -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Song -import javax.inject.Inject - -/** - * Fake implementation. Only place that imports and uses *Dummies. - * Initial receivers are empty; Gallery uses Settings receiver list (GET /users/receivers). - */ -class FakeAfternoteEditDataProvider @Inject constructor() : AfternoteEditDataProvider { - override fun getSongs(): List = - AfternoteEditDummies.defaultSongs() - override fun getAfternoteEditReceivers(): List = - emptyList() - override fun getDefaultAfternoteItems(): List> = - AfternoteListDummies.defaultAfternoteList() - override fun getAlbumCovers(): List = - AlbumDummies.list - override fun getAddSongSearchResults(): List = - AfternoteEditDummies.defaultAddSongSearchResults() -} diff --git a/app/src/main/java/com/kuit/afternote/data/provider/FakeReceiverDataProvider.kt b/app/src/main/java/com/kuit/afternote/data/provider/FakeReceiverDataProvider.kt deleted file mode 100644 index b931a5ee..00000000 --- a/app/src/main/java/com/kuit/afternote/data/provider/FakeReceiverDataProvider.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.kuit.afternote.data.provider - -import com.kuit.afternote.core.dummy.receiver.ReceiverDummies -import com.kuit.afternote.domain.provider.ReceiverDataProvider -import javax.inject.Inject - -/** - * Fake implementation. Only place that imports and uses ReceiverDummies. - */ -class FakeReceiverDataProvider @Inject constructor() : ReceiverDataProvider { - override fun getReceiverList() = ReceiverDummies.receiverList - override fun getDefaultReceiverTitle(): String = ReceiverDummies.defaultReceiverTitle() - override fun getAfternoteListSeedsForReceiverList() = ReceiverDummies.defaultAfternoteListSeedsForReceiverList() - override fun getAfternoteListSeedsForReceiverDetail() = ReceiverDummies.defaultAfternoteListSeedsForReceiverDetail() - override fun getTimeLetterItemsForPreview() = ReceiverDummies.sampleTimeLetterItemsForPreview -} diff --git a/app/src/main/java/com/kuit/afternote/data/provider/RealAfternoteEditDataProvider.kt b/app/src/main/java/com/kuit/afternote/data/provider/RealAfternoteEditDataProvider.kt deleted file mode 100644 index 0966e633..00000000 --- a/app/src/main/java/com/kuit/afternote/data/provider/RealAfternoteEditDataProvider.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.kuit.afternote.data.provider - -import com.kuit.afternote.core.ui.component.list.AlbumCover -import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.domain.provider.AfternoteEditDataProvider -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Song -import javax.inject.Inject - -/** - * Real implementation. Returns empty/placeholder until API is available. - */ -class RealAfternoteEditDataProvider @Inject constructor() : AfternoteEditDataProvider { - override fun getSongs(): List = emptyList() - override fun getAfternoteEditReceivers(): List = emptyList() - override fun getDefaultAfternoteItems(): List> = emptyList() - override fun getAlbumCovers(): List = emptyList() - override fun getAddSongSearchResults(): List = emptyList() -} diff --git a/app/src/main/java/com/kuit/afternote/data/provider/RealReceiverDataProvider.kt b/app/src/main/java/com/kuit/afternote/data/provider/RealReceiverDataProvider.kt deleted file mode 100644 index 6453f9c0..00000000 --- a/app/src/main/java/com/kuit/afternote/data/provider/RealReceiverDataProvider.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.kuit.afternote.data.provider - -import com.kuit.afternote.core.dummy.receiver.AfternoteListItemSeed -import com.kuit.afternote.domain.provider.ReceiverDataProvider -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem -import javax.inject.Inject - -/** - * Real implementation. Returns empty/placeholder until API is available. - */ -class RealReceiverDataProvider @Inject constructor() : ReceiverDataProvider { - override fun getReceiverList(): List = emptyList() - override fun getDefaultReceiverTitle(): String = "" - override fun getAfternoteListSeedsForReceiverList(): List = emptyList() - override fun getAfternoteListSeedsForReceiverDetail(): List = emptyList() - override fun getTimeLetterItemsForPreview(): List = emptyList() -} diff --git a/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MemorialThumbnailUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MemorialThumbnailUploadRepositoryImpl.kt new file mode 100644 index 00000000..1447d6aa --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MemorialThumbnailUploadRepositoryImpl.kt @@ -0,0 +1,61 @@ +package com.kuit.afternote.data.repositoryimpl + +import com.kuit.afternote.data.requireData +import com.kuit.afternote.domain.repository.MemorialThumbnailUploadRepository +import com.kuit.afternote.feature.user.data.api.ImageApiService +import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject +import javax.inject.Named + +private const val DIRECTORY_AFTERNOTES = "afternotes" +private const val EXTENSION_JPG = "jpg" +private const val CONTENT_TYPE_JPEG = "image/jpeg" + +/** + * Uploads memorial thumbnail via POST /files/presigned-url (directory "afternotes") then S3 PUT. + */ +class MemorialThumbnailUploadRepositoryImpl + @Inject + constructor( + private val imageApi: ImageApiService, + @Named("S3Upload") private val okHttpClient: OkHttpClient, + @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher, + ) : MemorialThumbnailUploadRepository { + override suspend fun uploadThumbnail(jpegBytes: ByteArray): Result = + runCatching { + val presigned = + imageApi + .getPresignedUrl( + PresignedUrlRequestDto( + directory = DIRECTORY_AFTERNOTES, + extension = EXTENSION_JPG, + ), + ).requireData() + ?: throw IllegalStateException("Presigned URL response data is null") + + val contentType = presigned.contentType.ifBlank { CONTENT_TYPE_JPEG } + val requestBody = jpegBytes.toRequestBody(contentType.toMediaType()) + val putRequest = + Request + .Builder() + .url(presigned.presignedUrl) + .put(requestBody) + .header("Content-Type", contentType) + .build() + + withContext(ioDispatcher) { + okHttpClient.newCall(putRequest).execute().use { response -> + check(response.isSuccessful) { + "S3 upload failed: ${response.code} ${response.message}" + } + } + } + presigned.fileUrl + } + } diff --git a/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MemorialVideoUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MemorialVideoUploadRepositoryImpl.kt new file mode 100644 index 00000000..8ad4b654 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MemorialVideoUploadRepositoryImpl.kt @@ -0,0 +1,96 @@ +package com.kuit.afternote.data.repositoryimpl + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.kuit.afternote.data.requireData +import com.kuit.afternote.domain.repository.MemorialVideoUploadRepository +import com.kuit.afternote.feature.user.data.api.ImageApiService +import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject +import javax.inject.Named + +private const val DIRECTORY_AFTERNOTES = "afternotes" +private const val DEFAULT_VIDEO_EXTENSION = "mp4" + +/** + * Uploads memorial video via POST /files/presigned-url (directory "afternotes") then S3 PUT. + * Reads from content URI (e.g. Photo Picker) and returns the HTTPS fileUrl so the backend + * stores a usable URL, not a local content:// URI. + */ +class MemorialVideoUploadRepositoryImpl + @Inject + constructor( + @ApplicationContext private val context: Context, + private val imageApi: ImageApiService, + @Named("S3Upload") private val okHttpClient: OkHttpClient, + @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher, + ) : MemorialVideoUploadRepository { + override suspend fun uploadVideo(contentUriString: String): Result = + runCatching { + val uri = contentUriString.toUri() + val extension = videoExtensionFromUri(uri) + val presigned = + imageApi + .getPresignedUrl( + PresignedUrlRequestDto( + directory = DIRECTORY_AFTERNOTES, + extension = extension, + ), + ).requireData() + + val tempFile = + withContext(ioDispatcher) { + context.contentResolver.openInputStream(uri)?.use { input -> + val file = File.createTempFile("memorial_video_", ".$extension") + file.outputStream().use { output -> + input.copyTo(output) + } + file + } ?: throw IllegalStateException("Could not read video from URI") + } + + try { + val contentType = presigned.contentType.ifBlank { "video/$extension" } + val requestBody = tempFile.asRequestBody(contentType.toMediaType()) + val putRequest = + Request + .Builder() + .url(presigned.presignedUrl) + .put(requestBody) + .header("Content-Type", contentType) + .build() + + withContext(ioDispatcher) { + okHttpClient.newCall(putRequest).execute().use { response -> + check(response.isSuccessful) { + "S3 video upload failed: ${response.code} ${response.message}" + } + } + } + presigned.fileUrl + } finally { + if (!tempFile.delete()) { + // Temp file cleanup failed; file may remain until system cleanup + } + } + } + + private fun videoExtensionFromUri(uri: Uri): String { + val mime = context.contentResolver.getType(uri) ?: return DEFAULT_VIDEO_EXTENSION + return when { + mime == "video/mp4" -> "mp4" + mime == "video/quicktime" -> "mov" + mime.startsWith("video/") -> mime.removePrefix("video/").takeIf { it.isNotBlank() } ?: DEFAULT_VIDEO_EXTENSION + else -> DEFAULT_VIDEO_EXTENSION + } + } + } diff --git a/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MusicSearchRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MusicSearchRepositoryImpl.kt new file mode 100644 index 00000000..3e4997af --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/MusicSearchRepositoryImpl.kt @@ -0,0 +1,35 @@ +package com.kuit.afternote.data.repositoryimpl + +import com.kuit.afternote.core.uimodel.PlaylistSongDisplay +import com.kuit.afternote.data.dto.MusicTrack +import com.kuit.afternote.data.service.MusicApiService +import com.kuit.afternote.domain.repository.MusicSearchRepository +import javax.inject.Inject + +/** + * Data layer: calls music search API and maps DTO to [com.kuit.afternote.core.uimodel.PlaylistSongDisplay]. + * API returns raw { "tracks": [...] } (no BaseResponse wrapper). + */ +class MusicSearchRepositoryImpl + @Inject + constructor( + private val api: MusicApiService, + ) : MusicSearchRepository { + override suspend fun search(keyword: String): Result> = + runCatching { + val trimmed = keyword.trim() + if (trimmed.isEmpty()) return@runCatching emptyList() + val response = api.search(keyword = trimmed) + response.tracks.mapIndexed { index, dto -> dto.toPlaylistSongDisplay(index) } + } + + private fun MusicTrack.toPlaylistSongDisplay(index: Int): PlaylistSongDisplay { + val id = "$artist|$title|$index" + return PlaylistSongDisplay( + id = id, + title = title, + artist = artist, + albumImageUrl = albumImageUrl, + ) + } + } diff --git a/app/src/main/java/com/kuit/afternote/data/repositoryimpl/PhotoUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/PhotoUploadRepositoryImpl.kt new file mode 100644 index 00000000..a7098a64 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/repositoryimpl/PhotoUploadRepositoryImpl.kt @@ -0,0 +1,86 @@ +package com.kuit.afternote.data.repositoryimpl + +import android.content.Context +import android.net.Uri +import com.kuit.afternote.data.requireData +import com.kuit.afternote.domain.repository.PhotoUploadRepository +import com.kuit.afternote.feature.user.data.api.ImageApiService +import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject +import javax.inject.Named + +private const val DEFAULT_EXTENSION = "jpg" + +/** + * Shared implementation: uploads image from content URI to the given directory + * via POST /files/presigned-url and S3 PUT. Used for profile photo, memorial photo, etc. + */ +class PhotoUploadRepositoryImpl + @Inject + constructor( + @ApplicationContext private val context: Context, + private val imageApi: ImageApiService, + @Named("S3Upload") private val okHttpClient: OkHttpClient, + @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher, + ) : PhotoUploadRepository { + override suspend fun upload( + uriString: String, + directory: String, + ): Result = + runCatching { + val uri = Uri.parse(uriString) + val extension = extensionFromUri(uri) + val presigned = + imageApi + .getPresignedUrl( + PresignedUrlRequestDto( + directory = directory, + extension = extension, + ), + ).requireData() + ?: throw IllegalStateException("Presigned URL response data is null") + + val bytes = + withContext(ioDispatcher) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalStateException("Could not read image from URI") + } + + val contentType = presigned.contentType.ifBlank { "image/jpeg" } + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + val putRequest = + Request + .Builder() + .url(presigned.presignedUrl) + .put(requestBody) + .header("Content-Type", contentType) + .build() + + withContext(ioDispatcher) { + okHttpClient.newCall(putRequest).execute().use { response -> + check(response.isSuccessful) { + "S3 upload failed: ${response.code} ${response.message}" + } + } + } + presigned.fileUrl + } + + private fun extensionFromUri(uri: Uri): String { + val mime = context.contentResolver.getType(uri) ?: return DEFAULT_EXTENSION + return when { + mime == "image/jpeg" || mime == "image/jpg" -> "jpg" + mime == "image/png" -> "png" + mime == "image/webp" -> "webp" + mime == "image/gif" -> "gif" + else -> DEFAULT_EXTENSION + } + } + } diff --git a/app/src/main/java/com/kuit/afternote/data/remote/ApiException.kt b/app/src/main/java/com/kuit/afternote/data/service/ApiException.kt similarity index 82% rename from app/src/main/java/com/kuit/afternote/data/remote/ApiException.kt rename to app/src/main/java/com/kuit/afternote/data/service/ApiException.kt index 0be91e3f..1e6829ca 100644 --- a/app/src/main/java/com/kuit/afternote/data/remote/ApiException.kt +++ b/app/src/main/java/com/kuit/afternote/data/service/ApiException.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.data.remote +package com.kuit.afternote.data.service /** * API 오류 시 사용하는 예외. @@ -12,5 +12,5 @@ package com.kuit.afternote.data.remote class ApiException( val status: Int? = null, val code: Int? = null, - override val message: String + override val message: String, ) : Exception(message) diff --git a/app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt b/app/src/main/java/com/kuit/afternote/data/service/AuthInterceptor.kt similarity index 83% rename from app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt rename to app/src/main/java/com/kuit/afternote/data/service/AuthInterceptor.kt index c682578b..ebeb42c7 100644 --- a/app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt +++ b/app/src/main/java/com/kuit/afternote/data/service/AuthInterceptor.kt @@ -1,7 +1,6 @@ -package com.kuit.afternote.data.remote +package com.kuit.afternote.data.service import android.util.Log -import com.kuit.afternote.data.local.TokenManager import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -23,7 +22,7 @@ import javax.inject.Inject class AuthInterceptor @Inject constructor( - private val tokenManager: TokenManager + private val tokenManager: TokenManager, ) : Interceptor { companion object { private const val TAG = "AuthInterceptor" @@ -41,21 +40,23 @@ class AuthInterceptor /** * 인증이 필요 없는 경로 목록. */ - private val NO_AUTH_PATHS = listOf( - "/auth/email/send", - "/auth/email/verify", - "/auth/sign-up", - "/auth/login", - "/auth/social/login", - "/auth/reissue" - ) + private val NO_AUTH_PATHS = + listOf( + "/auth/email/send", + "/auth/email/verify", + "/auth/sign-up", + "/auth/login", + "/auth/social/login", + "/auth/reissue", + ) } - private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - isLenient = true - } + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + isLenient = true + } // 토큰 재발급용 별도 OkHttpClient (인터셉터 없이) private val refreshClient: OkHttpClient by lazy { @@ -105,10 +106,11 @@ class AuthInterceptor return proceedAndLog(chain, originalRequest) } - val authenticatedRequest = originalRequest - .newBuilder() - .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$accessToken") - .build() + val authenticatedRequest = + originalRequest + .newBuilder() + .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$accessToken") + .build() Log.d(TAG, "Auth: TOKEN ADDED") val response = proceedAndLog(chain, authenticatedRequest) @@ -145,10 +147,11 @@ class AuthInterceptor // 다른 스레드가 이미 토큰을 갱신함 → refresh 없이 새 토큰으로 재시도 Log.d(TAG, "TokenRefresh: Already refreshed by another thread, retrying") originalResponse.close() - val newRequest = originalRequest - .newBuilder() - .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$currentAccessToken") - .build() + val newRequest = + originalRequest + .newBuilder() + .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$currentAccessToken") + .build() return proceedAndLog(chain, newRequest) } @@ -171,16 +174,17 @@ class AuthInterceptor runBlocking { tokenManager.updateTokens( accessToken = newTokens.accessToken ?: "", - refreshToken = newTokens.refreshToken ?: refreshToken + refreshToken = newTokens.refreshToken ?: refreshToken, ) } originalResponse.close() - val newRequest = originalRequest - .newBuilder() - .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX${newTokens.accessToken}") - .build() + val newRequest = + originalRequest + .newBuilder() + .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX${newTokens.accessToken}") + .build() Log.d(TAG, "TokenRefresh: Retrying original request with new token") proceedAndLog(chain, newRequest) @@ -199,17 +203,19 @@ class AuthInterceptor * Refresh token을 사용하여 새 access token을 발급받습니다. */ private fun refreshAccessToken(refreshToken: String): ReissueResponseData? { - val requestBody = json - .encodeToString( - ReissueRequestBody.serializer(), - ReissueRequestBody(refreshToken) - ).toRequestBody("application/json".toMediaType()) - - val request = Request - .Builder() - .url("$BASE_URL$REISSUE_ENDPOINT") - .post(requestBody) - .build() + val requestBody = + json + .encodeToString( + ReissueRequestBody.serializer(), + ReissueRequestBody(refreshToken), + ).toRequestBody("application/json".toMediaType()) + + val request = + Request + .Builder() + .url("$BASE_URL$REISSUE_ENDPOINT") + .post(requestBody) + .build() return try { val response = refreshClient.newCall(request).execute() @@ -231,7 +237,7 @@ class AuthInterceptor private fun proceedAndLog( chain: Interceptor.Chain, - request: Request + request: Request, ): Response { val response = chain.proceed(request) Log.d(TAG, "========== RESPONSE ==========") @@ -248,7 +254,7 @@ class AuthInterceptor */ @Serializable private data class ReissueRequestBody( - val refreshToken: String + val refreshToken: String, ) /** @@ -259,7 +265,7 @@ private data class ReissueApiResponse( val status: Int? = null, val code: Int? = null, val message: String? = null, - val data: ReissueResponseData? = null + val data: ReissueResponseData? = null, ) /** @@ -268,5 +274,5 @@ private data class ReissueApiResponse( @Serializable private data class ReissueResponseData( val accessToken: String? = null, - val refreshToken: String? = null + val refreshToken: String? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/api/MusicApiService.kt b/app/src/main/java/com/kuit/afternote/data/service/MusicApiService.kt similarity index 64% rename from app/src/main/java/com/kuit/afternote/feature/afternote/data/api/MusicApiService.kt rename to app/src/main/java/com/kuit/afternote/data/service/MusicApiService.kt index 705d366d..163f0b84 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/api/MusicApiService.kt +++ b/app/src/main/java/com/kuit/afternote/data/service/MusicApiService.kt @@ -1,6 +1,6 @@ -package com.kuit.afternote.feature.afternote.data.api +package com.kuit.afternote.data.service -import com.kuit.afternote.feature.afternote.data.dto.MusicSearchResponseDto +import com.kuit.afternote.data.dto.response.MusicSearchResponse import retrofit2.http.GET import retrofit2.http.Query @@ -11,9 +11,8 @@ import retrofit2.http.Query * 400 when keyword is missing. */ fun interface MusicApiService { - @GET("music/search") suspend fun search( - @Query("keyword") keyword: String - ): MusicSearchResponseDto + @Query("keyword") keyword: String, + ): MusicSearchResponse } diff --git a/app/src/main/java/com/kuit/afternote/data/remote/NetworkModule.kt b/app/src/main/java/com/kuit/afternote/data/service/NetworkModule.kt similarity index 69% rename from app/src/main/java/com/kuit/afternote/data/remote/NetworkModule.kt rename to app/src/main/java/com/kuit/afternote/data/service/NetworkModule.kt index fc687e9a..ce1a6a12 100644 --- a/app/src/main/java/com/kuit/afternote/data/remote/NetworkModule.kt +++ b/app/src/main/java/com/kuit/afternote/data/service/NetworkModule.kt @@ -1,6 +1,6 @@ -package com.kuit.afternote.data.remote +package com.kuit.afternote.data.service -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.feature.afternote.data.service.AfternoteApiService import com.kuit.afternote.feature.dailyrecord.data.api.DailyRecordApiService import dagger.Module import dagger.Provides @@ -41,14 +41,15 @@ object NetworkModule { @Provides @Singleton fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { - val builder = OkHttpClient - .Builder() - .connectTimeout(CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS) - .readTimeout(READ_TIMEOUT_SEC, TimeUnit.SECONDS) - .addInterceptor(authInterceptor) - .addInterceptor( - HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } - ) + val builder = + OkHttpClient + .Builder() + .connectTimeout(CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SEC, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }, + ) return builder.build() } @@ -66,16 +67,14 @@ object NetworkModule { .connectTimeout(CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT_SEC, TimeUnit.SECONDS) .addInterceptor( - HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } - ) - .build() - + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }, + ).build() @Provides @Singleton fun provideRetrofit( json: Json, - okHttpClient: OkHttpClient + okHttpClient: OkHttpClient, ): Retrofit = Retrofit .Builder() @@ -86,8 +85,13 @@ object NetworkModule { @Provides @Singleton - fun provideDailyRecordApiService(retrofit: Retrofit): DailyRecordApiService { - return retrofit.create(DailyRecordApiService::class.java) - } -} + fun provideAfternoteApiService(retrofit: Retrofit): AfternoteApiService = retrofit.create(AfternoteApiService::class.java) + + @Provides + @Singleton + fun provideMusicApiService(retrofit: Retrofit): MusicApiService = retrofit.create(MusicApiService::class.java) + @Provides + @Singleton + fun provideDailyRecordApiService(retrofit: Retrofit): DailyRecordApiService = retrofit.create(DailyRecordApiService::class.java) +} diff --git a/app/src/main/java/com/kuit/afternote/data/service/PostDeliveryConditionPreferences.kt b/app/src/main/java/com/kuit/afternote/data/service/PostDeliveryConditionPreferences.kt new file mode 100644 index 00000000..75a49084 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/service/PostDeliveryConditionPreferences.kt @@ -0,0 +1,64 @@ +package com.kuit.afternote.data.service + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.postDeliveryDataStore: DataStore by + preferencesDataStore(name = "post_delivery_condition") + +/** + * 사후 전달 조건 설정을 로컬에 저장/조회합니다. + * + * @param deliveryMethod "AUTOMATIC_TRANSFER" | "RECEIVER_APPROVAL_TRANSFER" + * @param triggerCondition "APP_INACTIVITY" | "SPECIFIC_DATE" | "RECEIVER_REQUEST" + * @param triggerDateIso "yyyy-MM-dd" or null (when trigger is SPECIFIC_DATE) + */ +@Singleton +class PostDeliveryConditionPreferences + @Inject + constructor( + @ApplicationContext private val context: Context, + ) { + private val deliveryMethodKey = stringPreferencesKey("delivery_method") + private val triggerConditionKey = stringPreferencesKey("trigger_condition") + private val triggerDateIsoKey = stringPreferencesKey("trigger_date_iso") + + val deliveryMethodFlow: Flow = + context.postDeliveryDataStore.data.map { it[deliveryMethodKey] } + val triggerConditionFlow: Flow = + context.postDeliveryDataStore.data.map { it[triggerConditionKey] } + val triggerDateIsoFlow: Flow = + context.postDeliveryDataStore.data.map { it[triggerDateIsoKey] } + + suspend fun getDeliveryMethod(): String? = context.postDeliveryDataStore.data.first()[deliveryMethodKey] + + suspend fun getTriggerCondition(): String? = context.postDeliveryDataStore.data.first()[triggerConditionKey] + + suspend fun getTriggerDateIso(): String? = context.postDeliveryDataStore.data.first()[triggerDateIsoKey] + + suspend fun save( + deliveryMethod: String, + triggerCondition: String, + triggerDateIso: String?, + ) { + context.postDeliveryDataStore.edit { prefs -> + prefs[deliveryMethodKey] = deliveryMethod + prefs[triggerConditionKey] = triggerCondition + if (triggerDateIso != null) { + prefs[triggerDateIsoKey] = triggerDateIso + } else { + prefs.remove(triggerDateIsoKey) + } + } + } + } diff --git a/app/src/main/java/com/kuit/afternote/data/local/TokenManager.kt b/app/src/main/java/com/kuit/afternote/data/service/TokenManager.kt similarity index 83% rename from app/src/main/java/com/kuit/afternote/data/local/TokenManager.kt rename to app/src/main/java/com/kuit/afternote/data/service/TokenManager.kt index c80f3fda..fc749783 100644 --- a/app/src/main/java/com/kuit/afternote/data/local/TokenManager.kt +++ b/app/src/main/java/com/kuit/afternote/data/service/TokenManager.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.data.local +package com.kuit.afternote.data.service import android.content.Context import android.util.Log @@ -26,7 +26,7 @@ private val Context.dataStore: DataStore by preferencesDataStore(na class TokenManager @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, ) { private val accessTokenKey = stringPreferencesKey("access_token") private val refreshTokenKey = stringPreferencesKey("refresh_token") @@ -35,30 +35,34 @@ class TokenManager /** * Access Token Flow. */ - val accessTokenFlow: Flow = context.dataStore.data.map { preferences -> - preferences[accessTokenKey] - } + val accessTokenFlow: Flow = + context.dataStore.data.map { preferences -> + preferences[accessTokenKey] + } /** * Refresh Token Flow. */ - val refreshTokenFlow: Flow = context.dataStore.data.map { preferences -> - preferences[refreshTokenKey] - } + val refreshTokenFlow: Flow = + context.dataStore.data.map { preferences -> + preferences[refreshTokenKey] + } /** * User Email Flow. */ - val userEmailFlow: Flow = context.dataStore.data.map { preferences -> - preferences[userEmailKey] - } + val userEmailFlow: Flow = + context.dataStore.data.map { preferences -> + preferences[userEmailKey] + } /** * 로그인 상태 Flow. */ - val isLoggedInFlow: Flow = context.dataStore.data.map { preferences -> - !preferences[accessTokenKey].isNullOrEmpty() - } + val isLoggedInFlow: Flow = + context.dataStore.data.map { preferences -> + !preferences[accessTokenKey].isNullOrEmpty() + } /** * Access Token을 동기적으로 가져옵니다. @@ -80,7 +84,7 @@ class TokenManager suspend fun saveTokens( accessToken: String, refreshToken: String, - email: String? = null + email: String? = null, ) { context.dataStore.edit { preferences -> preferences[accessTokenKey] = accessToken @@ -97,7 +101,7 @@ class TokenManager */ suspend fun updateTokens( accessToken: String, - refreshToken: String + refreshToken: String, ) { context.dataStore.edit { preferences -> preferences[accessTokenKey] = accessToken diff --git a/app/src/main/java/com/kuit/afternote/data/upload/MemorialThumbnailUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/upload/MemorialThumbnailUploadRepositoryImpl.kt deleted file mode 100644 index 1ab1f014..00000000 --- a/app/src/main/java/com/kuit/afternote/data/upload/MemorialThumbnailUploadRepositoryImpl.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.kuit.afternote.data.upload - -import com.kuit.afternote.data.remote.requireData -import com.kuit.afternote.feature.afternote.domain.repository.MemorialThumbnailUploadRepository -import com.kuit.afternote.feature.user.data.api.ImageApiService -import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import javax.inject.Inject -import javax.inject.Named - -private const val DIRECTORY_AFTERNOTES = "afternotes" -private const val EXTENSION_JPG = "jpg" -private const val CONTENT_TYPE_JPEG = "image/jpeg" - -/** - * Uploads memorial thumbnail via POST /files/presigned-url (directory "afternotes") then S3 PUT. - */ -class MemorialThumbnailUploadRepositoryImpl - @Inject - constructor( - private val imageApi: ImageApiService, - @Named("S3Upload") private val okHttpClient: OkHttpClient, - @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher - ) : MemorialThumbnailUploadRepository { - - override suspend fun uploadThumbnail(jpegBytes: ByteArray): Result = - runCatching { - val presigned = imageApi.getPresignedUrl( - PresignedUrlRequestDto( - directory = DIRECTORY_AFTERNOTES, - extension = EXTENSION_JPG - ) - ).requireData() - ?: throw IllegalStateException("Presigned URL response data is null") - - val contentType = presigned.contentType.ifBlank { CONTENT_TYPE_JPEG } - val requestBody = jpegBytes.toRequestBody(contentType.toMediaType()) - val putRequest = - Request.Builder() - .url(presigned.presignedUrl) - .put(requestBody) - .header("Content-Type", contentType) - .build() - - withContext(ioDispatcher) { - okHttpClient.newCall(putRequest).execute().use { response -> - check(response.isSuccessful) { - "S3 upload failed: ${response.code} ${response.message}" - } - } - } - presigned.fileUrl - } -} diff --git a/app/src/main/java/com/kuit/afternote/data/upload/MemorialVideoUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/upload/MemorialVideoUploadRepositoryImpl.kt deleted file mode 100644 index 50545d39..00000000 --- a/app/src/main/java/com/kuit/afternote/data/upload/MemorialVideoUploadRepositoryImpl.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.kuit.afternote.data.upload - -import android.content.Context -import android.net.Uri -import androidx.core.net.toUri -import com.kuit.afternote.data.remote.requireData -import com.kuit.afternote.feature.afternote.domain.repository.MemorialVideoUploadRepository -import com.kuit.afternote.feature.user.data.api.ImageApiService -import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.asRequestBody -import java.io.File -import javax.inject.Inject -import javax.inject.Named - -private const val DIRECTORY_AFTERNOTES = "afternotes" -private const val DEFAULT_VIDEO_EXTENSION = "mp4" - -/** - * Uploads memorial video via POST /files/presigned-url (directory "afternotes") then S3 PUT. - * Reads from content URI (e.g. Photo Picker) and returns the HTTPS fileUrl so the backend - * stores a usable URL, not a local content:// URI. - */ -class MemorialVideoUploadRepositoryImpl - @Inject - constructor( - @ApplicationContext private val context: Context, - private val imageApi: ImageApiService, - @Named("S3Upload") private val okHttpClient: OkHttpClient, - @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher - ) : MemorialVideoUploadRepository { - - override suspend fun uploadVideo(contentUriString: String): Result = - runCatching { - val uri = contentUriString.toUri() - val extension = videoExtensionFromUri(uri) - val presigned = imageApi.getPresignedUrl( - PresignedUrlRequestDto( - directory = DIRECTORY_AFTERNOTES, - extension = extension - ) - ).requireData() - - val tempFile = withContext(ioDispatcher) { - context.contentResolver.openInputStream(uri)?.use { input -> - val file = File.createTempFile("memorial_video_", ".$extension") - file.outputStream().use { output -> - input.copyTo(output) - } - file - } ?: throw IllegalStateException("Could not read video from URI") - } - - try { - val contentType = presigned.contentType.ifBlank { "video/$extension" } - val requestBody = tempFile.asRequestBody(contentType.toMediaType()) - val putRequest = - Request.Builder() - .url(presigned.presignedUrl) - .put(requestBody) - .header("Content-Type", contentType) - .build() - - withContext(ioDispatcher) { - okHttpClient.newCall(putRequest).execute().use { response -> - check(response.isSuccessful) { - "S3 video upload failed: ${response.code} ${response.message}" - } - } - } - presigned.fileUrl - } finally { - if (!tempFile.delete()) { - // Temp file cleanup failed; file may remain until system cleanup - } - } - } - - private fun videoExtensionFromUri(uri: Uri): String { - val mime = context.contentResolver.getType(uri) ?: return DEFAULT_VIDEO_EXTENSION - return when { - mime == "video/mp4" -> "mp4" - mime == "video/quicktime" -> "mov" - mime.startsWith("video/") -> mime.removePrefix("video/").takeIf { it.isNotBlank() } ?: DEFAULT_VIDEO_EXTENSION - else -> DEFAULT_VIDEO_EXTENSION - } - } -} diff --git a/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepositoryImpl.kt deleted file mode 100644 index 0dab0673..00000000 --- a/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepositoryImpl.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.kuit.afternote.data.upload - -import android.content.Context -import android.net.Uri -import com.kuit.afternote.data.remote.requireData -import com.kuit.afternote.feature.user.data.api.ImageApiService -import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import javax.inject.Inject -import javax.inject.Named - -private const val DEFAULT_EXTENSION = "jpg" - -/** - * Shared implementation: uploads image from content URI to the given directory - * via POST /files/presigned-url and S3 PUT. Used for profile photo, memorial photo, etc. - */ -class PhotoUploadRepositoryImpl - @Inject - constructor( - @ApplicationContext private val context: Context, - private val imageApi: ImageApiService, - @Named("S3Upload") private val okHttpClient: OkHttpClient, - @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher - ) : PhotoUploadRepository { - - override suspend fun upload(uriString: String, directory: String): Result = - runCatching { - val uri = Uri.parse(uriString) - val extension = extensionFromUri(uri) - val presigned = imageApi.getPresignedUrl( - PresignedUrlRequestDto( - directory = directory, - extension = extension - ) - ).requireData() - ?: throw IllegalStateException("Presigned URL response data is null") - - val bytes = withContext(ioDispatcher) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IllegalStateException("Could not read image from URI") - } - - val contentType = presigned.contentType.ifBlank { "image/jpeg" } - val requestBody = bytes.toRequestBody(contentType.toMediaType()) - val putRequest = - Request.Builder() - .url(presigned.presignedUrl) - .put(requestBody) - .header("Content-Type", contentType) - .build() - - withContext(ioDispatcher) { - okHttpClient.newCall(putRequest).execute().use { response -> - check(response.isSuccessful) { - "S3 upload failed: ${response.code} ${response.message}" - } - } - } - presigned.fileUrl - } - - private fun extensionFromUri(uri: Uri): String { - val mime = context.contentResolver.getType(uri) ?: return DEFAULT_EXTENSION - return when { - mime == "image/jpeg" || mime == "image/jpg" -> "jpg" - mime == "image/png" -> "png" - mime == "image/webp" -> "webp" - mime == "image/gif" -> "gif" - else -> DEFAULT_EXTENSION - } - } -} diff --git a/app/src/main/java/com/kuit/afternote/domain/provider/AfternoteEditDataProvider.kt b/app/src/main/java/com/kuit/afternote/domain/provider/AfternoteEditDataProvider.kt index 717857c5..0e5a9184 100644 --- a/app/src/main/java/com/kuit/afternote/domain/provider/AfternoteEditDataProvider.kt +++ b/app/src/main/java/com/kuit/afternote/domain/provider/AfternoteEditDataProvider.kt @@ -2,17 +2,20 @@ package com.kuit.afternote.domain.provider import com.kuit.afternote.core.ui.component.list.AlbumCover import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Song +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.Song /** * Provides data for afternote edit flows (songs, receivers, album covers, afternote items). * Implementation (real vs dummy-backed) is decided at DI; consumers use this interface only. */ interface AfternoteEditDataProvider { - fun getSongs(): List - fun getAfternoteEditReceivers(): List + fun getSongs(): List + + fun getAfternoteEditReceivers(): List + fun getDefaultAfternoteItems(): List> + fun getAlbumCovers(): List /** diff --git a/app/src/main/java/com/kuit/afternote/data/provider/DataProviderModule.kt b/app/src/main/java/com/kuit/afternote/domain/provider/DataProviderModule.kt similarity index 83% rename from app/src/main/java/com/kuit/afternote/data/provider/DataProviderModule.kt rename to app/src/main/java/com/kuit/afternote/domain/provider/DataProviderModule.kt index 26791961..300c8b28 100644 --- a/app/src/main/java/com/kuit/afternote/data/provider/DataProviderModule.kt +++ b/app/src/main/java/com/kuit/afternote/domain/provider/DataProviderModule.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.data.provider +package com.kuit.afternote.domain.provider import dagger.Module import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/kuit/afternote/domain/provider/DataProviderSwitch.kt b/app/src/main/java/com/kuit/afternote/domain/provider/DataProviderSwitch.kt new file mode 100644 index 00000000..43e611a4 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/domain/provider/DataProviderSwitch.kt @@ -0,0 +1,55 @@ +package com.kuit.afternote.domain.provider + +import android.content.Context +import androidx.core.content.edit +import com.kuit.afternote.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +private const val PREFS_NAME = "data_provider_prefs" +private const val KEY_USE_FAKE = "use_fake_data" + +/** + * Holds the runtime choice between Fake (mock) and Real data providers. + * Persists the choice so it survives process death. Defaults to [BuildConfig.DEBUG] on first run. + */ +@Singleton +class DataProviderSwitch + @Inject + constructor( + private val fakeAfternote: FakeAfternoteEditDataProvider, + private val realAfternote: RealAfternoteEditDataProvider, + private val fakeReceiver: FakeReceiverDataProvider, + private val realReceiver: RealReceiverDataProvider, + @ApplicationContext private val context: Context, + ) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val _useFakeState = + MutableStateFlow( + prefs.getBoolean(KEY_USE_FAKE, BuildConfig.DEBUG), + ) + val useFakeState: StateFlow = _useFakeState.asStateFlow() + + /** + * Initial value for useFake (from persisted prefs). Use this for Compose initialValue + * instead of reading [useFakeState].value inside composition. + */ + fun getInitialUseFake(): Boolean = prefs.getBoolean(KEY_USE_FAKE, BuildConfig.DEBUG) + + /** + * Sets whether to use Fake (mock) or Real data. Persists the value. + */ + fun setUseFake(useFake: Boolean) { + prefs.edit { putBoolean(KEY_USE_FAKE, useFake) } + _useFakeState.value = useFake + } + + fun getCurrentAfternoteEditDataProvider(): AfternoteEditDataProvider = if (_useFakeState.value) fakeAfternote else realAfternote + + fun getCurrentReceiverDataProvider(): ReceiverDataProvider = if (_useFakeState.value) fakeReceiver else realReceiver + } diff --git a/app/src/main/java/com/kuit/afternote/domain/provider/FakeAfternoteEditDataProvider.kt b/app/src/main/java/com/kuit/afternote/domain/provider/FakeAfternoteEditDataProvider.kt new file mode 100644 index 00000000..be161247 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/domain/provider/FakeAfternoteEditDataProvider.kt @@ -0,0 +1,30 @@ +package com.kuit.afternote.domain.provider + +import com.kuit.afternote.core.dummy.afternote.AfternoteEditDummies +import com.kuit.afternote.core.dummy.afternote.AfternoteListDummies +import com.kuit.afternote.core.dummy.album.AlbumDummies +import com.kuit.afternote.core.ui.component.list.AlbumCover +import com.kuit.afternote.core.uimodel.PlaylistSongDisplay +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.Song +import javax.inject.Inject + +/** + * Fake implementation. Only place that imports and uses *Dummies. + * Initial receivers are empty; Gallery uses Settings receiver list (GET /users/receivers). + */ +class FakeAfternoteEditDataProvider + @Inject + constructor() : AfternoteEditDataProvider { + override fun getSongs(): List = + AfternoteEditDummies.defaultSongs() + + override fun getAfternoteEditReceivers(): List = + emptyList() + + override fun getDefaultAfternoteItems(): List> = AfternoteListDummies.defaultAfternoteList() + + override fun getAlbumCovers(): List = AlbumDummies.list + + override fun getAddSongSearchResults(): List = AfternoteEditDummies.defaultAddSongSearchResults() + } diff --git a/app/src/main/java/com/kuit/afternote/domain/provider/FakeReceiverDataProvider.kt b/app/src/main/java/com/kuit/afternote/domain/provider/FakeReceiverDataProvider.kt new file mode 100644 index 00000000..d267f5ed --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/domain/provider/FakeReceiverDataProvider.kt @@ -0,0 +1,21 @@ +package com.kuit.afternote.domain.provider + +import com.kuit.afternote.core.dummy.receiver.ReceiverDummies +import javax.inject.Inject + +/** + * Fake implementation. Only place that imports and uses ReceiverDummies. + */ +class FakeReceiverDataProvider + @Inject + constructor() : ReceiverDataProvider { + override fun getReceiverList() = ReceiverDummies.receiverList + + override fun getDefaultReceiverTitle(): String = ReceiverDummies.defaultReceiverTitle() + + override fun getAfternoteListSeedsForReceiverList() = ReceiverDummies.defaultAfternoteListSeedsForReceiverList() + + override fun getAfternoteListSeedsForReceiverDetail() = ReceiverDummies.defaultAfternoteListSeedsForReceiverDetail() + + override fun getTimeLetterItemsForPreview() = ReceiverDummies.sampleTimeLetterItemsForPreview + } diff --git a/app/src/main/java/com/kuit/afternote/domain/provider/RealAfternoteEditDataProvider.kt b/app/src/main/java/com/kuit/afternote/domain/provider/RealAfternoteEditDataProvider.kt new file mode 100644 index 00000000..5b86bb27 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/domain/provider/RealAfternoteEditDataProvider.kt @@ -0,0 +1,25 @@ +package com.kuit.afternote.domain.provider + +import com.kuit.afternote.core.ui.component.list.AlbumCover +import com.kuit.afternote.core.uimodel.PlaylistSongDisplay +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.Song +import javax.inject.Inject + +/** + * Real implementation. Returns empty/placeholder until API is available. + */ +class RealAfternoteEditDataProvider + @Inject + constructor() : AfternoteEditDataProvider { + override fun getSongs(): List = emptyList() + + override fun getAfternoteEditReceivers(): List = + emptyList() + + override fun getDefaultAfternoteItems(): List> = emptyList() + + override fun getAlbumCovers(): List = emptyList() + + override fun getAddSongSearchResults(): List = emptyList() + } diff --git a/app/src/main/java/com/kuit/afternote/domain/provider/RealReceiverDataProvider.kt b/app/src/main/java/com/kuit/afternote/domain/provider/RealReceiverDataProvider.kt new file mode 100644 index 00000000..458345ea --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/domain/provider/RealReceiverDataProvider.kt @@ -0,0 +1,24 @@ +package com.kuit.afternote.domain.provider + +import com.kuit.afternote.core.dummy.receiver.AfternoteListItemSeed +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem +import javax.inject.Inject + +/** + * Real implementation. Returns empty/placeholder until API is available. + */ +class RealReceiverDataProvider + @Inject + constructor() : ReceiverDataProvider { + override fun getReceiverList(): List = + emptyList() + + override fun getDefaultReceiverTitle(): String = "" + + override fun getAfternoteListSeedsForReceiverList(): List = emptyList() + + override fun getAfternoteListSeedsForReceiverDetail(): List = emptyList() + + override fun getTimeLetterItemsForPreview(): List = emptyList() + } diff --git a/app/src/main/java/com/kuit/afternote/domain/provider/ReceiverDataProvider.kt b/app/src/main/java/com/kuit/afternote/domain/provider/ReceiverDataProvider.kt index c8867544..140b6259 100644 --- a/app/src/main/java/com/kuit/afternote/domain/provider/ReceiverDataProvider.kt +++ b/app/src/main/java/com/kuit/afternote/domain/provider/ReceiverDataProvider.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.domain.provider import com.kuit.afternote.core.dummy.receiver.AfternoteListItemSeed -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem /** @@ -9,9 +9,13 @@ import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem * Implementation (real vs dummy-backed) is decided at DI; consumers use this interface only. */ interface ReceiverDataProvider { - fun getReceiverList(): List + fun getReceiverList(): List + fun getDefaultReceiverTitle(): String + fun getAfternoteListSeedsForReceiverList(): List + fun getAfternoteListSeedsForReceiverDetail(): List + fun getTimeLetterItemsForPreview(): List } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/MemorialThumbnailUploadRepository.kt b/app/src/main/java/com/kuit/afternote/domain/repository/MemorialThumbnailUploadRepository.kt similarity index 89% rename from app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/MemorialThumbnailUploadRepository.kt rename to app/src/main/java/com/kuit/afternote/domain/repository/MemorialThumbnailUploadRepository.kt index ea85dc12..f2a988cb 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/MemorialThumbnailUploadRepository.kt +++ b/app/src/main/java/com/kuit/afternote/domain/repository/MemorialThumbnailUploadRepository.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.domain.repository +package com.kuit.afternote.domain.repository /** * Memorial (playlist) thumbnail upload via POST /files/presigned-url and S3 PUT. diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/MemorialVideoUploadRepository.kt b/app/src/main/java/com/kuit/afternote/domain/repository/MemorialVideoUploadRepository.kt similarity index 89% rename from app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/MemorialVideoUploadRepository.kt rename to app/src/main/java/com/kuit/afternote/domain/repository/MemorialVideoUploadRepository.kt index 011098ef..3a0ca467 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/MemorialVideoUploadRepository.kt +++ b/app/src/main/java/com/kuit/afternote/domain/repository/MemorialVideoUploadRepository.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.domain.repository +package com.kuit.afternote.domain.repository /** * Memorial (playlist) video upload via POST /files/presigned-url and S3 PUT. diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/MusicSearchRepository.kt b/app/src/main/java/com/kuit/afternote/domain/repository/MusicSearchRepository.kt similarity index 89% rename from app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/MusicSearchRepository.kt rename to app/src/main/java/com/kuit/afternote/domain/repository/MusicSearchRepository.kt index 745e2b05..37d720f4 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/MusicSearchRepository.kt +++ b/app/src/main/java/com/kuit/afternote/domain/repository/MusicSearchRepository.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.domain.repository.iface +package com.kuit.afternote.domain.repository import com.kuit.afternote.core.uimodel.PlaylistSongDisplay @@ -7,7 +7,6 @@ import com.kuit.afternote.core.uimodel.PlaylistSongDisplay * API: GET /music/search?keyword= — 200 success, 400 when keyword is missing. */ fun interface MusicSearchRepository { - /** * Searches for tracks by keyword. Call only when [keyword] is non-blank (API returns 400 otherwise). * diff --git a/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepository.kt b/app/src/main/java/com/kuit/afternote/domain/repository/PhotoUploadRepository.kt similarity index 77% rename from app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepository.kt rename to app/src/main/java/com/kuit/afternote/domain/repository/PhotoUploadRepository.kt index c7780265..c3dae8b6 100644 --- a/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepository.kt +++ b/app/src/main/java/com/kuit/afternote/domain/repository/PhotoUploadRepository.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.data.upload +package com.kuit.afternote.domain.repository /** * Core photo upload: content URI → presigned URL → S3 PUT → file URL. @@ -11,5 +11,8 @@ fun interface PhotoUploadRepository { * @param directory target directory for the file (e.g. "profiles", "afternotes"). * @return Success with image URL (https) or failure. */ - suspend fun upload(uriString: String, directory: String): Result + suspend fun upload( + uriString: String, + directory: String, + ): Result } diff --git a/app/src/main/java/com/kuit/afternote/feature/admin/data/api/AdminApiService.kt b/app/src/main/java/com/kuit/afternote/feature/admin/data/api/AdminApiService.kt index 8105d024..544208d1 100644 --- a/app/src/main/java/com/kuit/afternote/feature/admin/data/api/AdminApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/admin/data/api/AdminApiService.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.admin.data.api -import com.kuit.afternote.data.remote.ApiResponse +import com.kuit.afternote.data.BaseResponse import com.kuit.afternote.feature.admin.data.dto.AdminVerificationResponseDto import com.kuit.afternote.feature.admin.data.dto.ApproveVerificationRequestDto import com.kuit.afternote.feature.admin.data.dto.RejectVerificationRequestDto @@ -18,7 +18,6 @@ import retrofit2.http.Path * - POST /api/admin/verifications/{id}/reject: 인증 요청 거절 */ interface AdminApiService { - /** * 대기 중인(PENDING) 사망확인 인증 요청 목록을 조회합니다. * @@ -27,7 +26,7 @@ interface AdminApiService { * @return data: 인증 요청 목록 (발신자/수신자 정보, Presigned URL, 제출 일시, 관리자 메모) */ @GET("api/admin/verifications") - suspend fun getPendingVerifications(): ApiResponse?> + suspend fun getPendingVerifications(): BaseResponse?> /** * 관리자가 특정 인증 요청의 상세 정보를 조회합니다. @@ -39,7 +38,9 @@ interface AdminApiService { * @return data: 인증 요청 상세 (발신자/수신자 정보, Presigned URL, 상태, 관리자 메모, 생성일시) */ @GET("api/admin/verifications/{id}") - suspend fun getVerificationDetail(@Path("id") id: Long): ApiResponse + suspend fun getVerificationDetail( + @Path("id") id: Long, + ): BaseResponse /** * 관리자가 사망확인 인증 요청을 승인합니다. @@ -55,8 +56,8 @@ interface AdminApiService { @POST("api/admin/verifications/{id}/approve") suspend fun approveVerification( @Path("id") id: Long, - @Body body: ApproveVerificationRequestDto - ): ApiResponse + @Body body: ApproveVerificationRequestDto, + ): BaseResponse /** * 관리자가 사망확인 인증 요청을 거절합니다. @@ -72,6 +73,6 @@ interface AdminApiService { @POST("api/admin/verifications/{id}/reject") suspend fun rejectVerification( @Path("id") id: Long, - @Body body: RejectVerificationRequestDto - ): ApiResponse + @Body body: RejectVerificationRequestDto, + ): BaseResponse } diff --git a/app/src/main/java/com/kuit/afternote/feature/admin/data/repository/impl/AdminRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/admin/data/repository/impl/AdminRepositoryImpl.kt index 6fa30305..a05fa316 100644 --- a/app/src/main/java/com/kuit/afternote/feature/admin/data/repository/impl/AdminRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/admin/data/repository/impl/AdminRepositoryImpl.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.admin.data.repository.impl -import com.kuit.afternote.data.remote.requireData +import com.kuit.afternote.data.requireData import com.kuit.afternote.feature.admin.data.api.AdminApiService import com.kuit.afternote.feature.admin.data.dto.AdminVerificationResponseDto import com.kuit.afternote.feature.admin.data.dto.ApproveVerificationRequestDto @@ -14,32 +14,31 @@ import javax.inject.Inject class AdminRepositoryImpl @Inject constructor( - private val api: AdminApiService + private val api: AdminApiService, ) : AdminRepository { + override suspend fun getPendingVerifications(): Result> = + runCatching { + api.getPendingVerifications().requireData() ?: emptyList() + } - override suspend fun getPendingVerifications(): Result> = - runCatching { - api.getPendingVerifications().requireData() ?: emptyList() - } + override suspend fun getVerificationDetail(id: Long): Result = + runCatching { + api.getVerificationDetail(id).requireData() + } - override suspend fun getVerificationDetail(id: Long): Result = - runCatching { - api.getVerificationDetail(id).requireData() - } + override suspend fun approveVerification( + id: Long, + request: ApproveVerificationRequestDto, + ): Result = + runCatching { + api.approveVerification(id = id, body = request).requireData() + } - override suspend fun approveVerification( - id: Long, - request: ApproveVerificationRequestDto - ): Result = - runCatching { - api.approveVerification(id = id, body = request).requireData() - } - - override suspend fun rejectVerification( - id: Long, - request: RejectVerificationRequestDto - ): Result = - runCatching { - api.rejectVerification(id = id, body = request).requireData() - } -} + override suspend fun rejectVerification( + id: Long, + request: RejectVerificationRequestDto, + ): Result = + runCatching { + api.rejectVerification(id = id, body = request).requireData() + } + } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/api/AfternoteApiService.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/api/AfternoteApiService.kt deleted file mode 100644 index b9134427..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/api/AfternoteApiService.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.kuit.afternote.feature.afternote.data.api - -import com.kuit.afternote.data.remote.ApiResponse -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateGalleryRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreatePlaylistRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateSocialRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteDetailResponseDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteIdResponseDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteListResponseDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto -import retrofit2.http.Body -import retrofit2.http.DELETE -import retrofit2.http.GET -import retrofit2.http.PATCH -import retrofit2.http.POST -import retrofit2.http.Path -import retrofit2.http.Query - -/** - * Afternote API (Swagger / API spec). - * - * - GET /afternotes — list (category, page, size) - * - GET /afternotes/{afternoteId} — detail - * - POST /afternotes — create (SOCIAL / GALLERY / PLAYLIST) - * - PATCH /afternotes/{afternoteId} — update - * - DELETE /afternotes/{afternoteId} — delete - */ -interface AfternoteApiService { - - @GET("api/afternotes") - suspend fun getAfternotes( - @Query("category") category: String? = null, - @Query("page") page: Int = 0, - @Query("size") size: Int = 10 - ): ApiResponse - - @GET("api/afternotes/{afternoteId}") - suspend fun getAfternoteDetail( - @Path("afternoteId") afternoteId: Long - ): ApiResponse - - @POST("api/afternotes") - suspend fun createAfternoteSocial( - @Body body: AfternoteCreateSocialRequestDto - ): ApiResponse - - @POST("api/afternotes") - suspend fun createAfternoteGallery( - @Body body: AfternoteCreateGalleryRequestDto - ): ApiResponse - - @POST("api/afternotes") - suspend fun createAfternotePlaylist( - @Body body: AfternoteCreatePlaylistRequestDto - ): ApiResponse - - @PATCH("api/afternotes/{afternoteId}") - suspend fun updateAfternote( - @Path("afternoteId") afternoteId: Long, - @Body body: AfternoteUpdateRequestDto - ): ApiResponse - - @DELETE("api/afternotes/{afternoteId}") - suspend fun deleteAfternote( - @Path("afternoteId") afternoteId: Long - ): ApiResponse -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/di/AfternoteRepositoryModule.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/di/AfternoteRepositoryModule.kt deleted file mode 100644 index 5d8bffd7..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/di/AfternoteRepositoryModule.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.kuit.afternote.feature.afternote.data.di - -import com.kuit.afternote.data.upload.MemorialThumbnailUploadRepositoryImpl -import com.kuit.afternote.data.upload.PhotoUploadRepository -import com.kuit.afternote.data.upload.PhotoUploadRepositoryImpl -import com.kuit.afternote.data.upload.MemorialVideoUploadRepositoryImpl -import com.kuit.afternote.feature.afternote.data.api.AfternoteApiService -import com.kuit.afternote.feature.afternote.data.api.MusicApiService -import com.kuit.afternote.feature.afternote.data.repository.AfternoteRepositoryImpl -import com.kuit.afternote.feature.afternote.data.repository.MusicSearchRepositoryImpl -import com.kuit.afternote.feature.afternote.domain.repository.MemorialThumbnailUploadRepository -import com.kuit.afternote.feature.afternote.domain.repository.MemorialVideoUploadRepository -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository -import com.kuit.afternote.feature.afternote.domain.repository.iface.MusicSearchRepository -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import retrofit2.Retrofit -import javax.inject.Singleton - -/** - * Afternote Repository + ApiService DI 설정. - * - * - AfternoteRepositoryImpl ↔ AfternoteRepository - * - MusicSearchRepositoryImpl ↔ MusicSearchRepository - * - Retrofit → AfternoteApiService, MusicApiService - */ -@Module -@InstallIn(SingletonComponent::class) -abstract class AfternoteRepositoryModule { - - // 사용 위치가 Hilt가 생성하는 코드 안에만 있기 때문에 IDE에서 'never used'로 표시됩니다. - @Suppress("unused") - @Binds - @Singleton - abstract fun bindAfternoteRepository( - impl: AfternoteRepositoryImpl - ): AfternoteRepository - - @Suppress("unused") - @Binds - @Singleton - abstract fun bindMusicSearchRepository( - impl: MusicSearchRepositoryImpl - ): MusicSearchRepository - - @Suppress("unused") - @Binds - @Singleton - abstract fun bindMemorialThumbnailUploadRepository( - impl: MemorialThumbnailUploadRepositoryImpl - ): MemorialThumbnailUploadRepository - - @Suppress("unused") - @Binds - @Singleton - abstract fun bindPhotoUploadRepository( - impl: PhotoUploadRepositoryImpl - ): PhotoUploadRepository - - @Binds - @Singleton - abstract fun bindMemorialVideoUploadRepository( - impl: MemorialVideoUploadRepositoryImpl - ): MemorialVideoUploadRepository - - companion object { - @Provides - @Singleton - fun provideAfternoteApiService(retrofit: Retrofit): AfternoteApiService = - retrofit.create(AfternoteApiService::class.java) - - @Provides - @Singleton - fun provideMusicApiService(retrofit: Retrofit): MusicApiService = - retrofit.create(MusicApiService::class.java) - } -} - diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteCredentials.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteCredentials.kt new file mode 100644 index 00000000..2d30774d --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteCredentials.kt @@ -0,0 +1,10 @@ +package com.kuit.afternote.feature.afternote.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AfternoteCredentials( + @SerialName("id") val id: String? = null, + @SerialName("password") val password: String? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDetailReceiver.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDetailReceiver.kt new file mode 100644 index 00000000..fd5ab4f3 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDetailReceiver.kt @@ -0,0 +1,29 @@ +package com.kuit.afternote.feature.afternote.data.dto + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +/** + * Receiver in GET /afternotes/{id} response (GALLERY). + * API currently returns only receiverId; name/relation are resolved via GET /users/receivers. + * Accepts optional name/relation/phone if API adds them later. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class AfternoteDetailReceiver( + @SerialName("receiverId") + @JsonNames("receiver_id") val receiverId: Long? = null, + @SerialName("name") + @JsonNames("receiverName", "receiver_name") val name: String? = null, + @SerialName("relation") + @JsonNames("receiverRelation", "receiver_relation", "relationship") val relation: String? = null, + @SerialName("phone") + @JsonNames( + "receiverPhone", + "receiver_phone", + "phoneNumber", + "phone_number", + ) val phone: String? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDto.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDto.kt deleted file mode 100644 index 6eaf4a1e..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDto.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.kuit.afternote.feature.afternote.data.dto - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonNames - -/** - * Server response form for GET /afternotes. - * Matches API: data.content[], page, size, hasNext. - */ -@Serializable -data class AfternoteListResponseDto( - val content: List = emptyList(), - val page: Int = 0, - val size: Int = 10, - val hasNext: Boolean = false -) - -/** - * Single item in list response. Server form: afternoteId, title, category, createdAt. - */ -@Serializable -data class AfternoteListItemDto( - val afternoteId: Long, - val title: String, - val category: String, - val createdAt: String -) - -/** - * Server request form for POST /afternotes (SOCIAL category). - * Matches API: category, title, processMethod, actions, leaveMessage, credentials, receivers. - */ -@Serializable -data class AfternoteCreateSocialRequestDto( - val category: String = "SOCIAL", - val title: String, - val processMethod: String, - val actions: List, - val leaveMessage: String? = null, - val credentials: AfternoteCredentialsDto? = null, - val receivers: List = emptyList() -) - -/** - * Server request form for POST /afternotes (GALLERY category). - * receivers: 수신자 목록 (모든 카테고리에서 가능). - */ -@Serializable -data class AfternoteCreateGalleryRequestDto( - val category: String = "GALLERY", - val title: String, - val processMethod: String, - val actions: List, - val leaveMessage: String? = null, - val receivers: List -) - -@Serializable -data class AfternoteCredentialsDto( - val id: String? = null, - val password: String? = null -) - -/** - * 수신자 참조 (API ReceiverRequest). 수신자 목록은 모든 카테고리에서 사용 가능. - */ -@Serializable -data class AfternoteReceiverRefDto( - val receiverId: Long -) - -/** - * Server response data for POST /afternotes and PATCH /afternotes/{id}. - * Accepts both snake_case (afternote_id) and camelCase (afternoteId) from API. - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class AfternoteIdResponseDto( - @JsonNames("afternote_id") val afternoteId: Long -) - -// --- GET /afternotes/{afternoteId} (detail) --- - -/** - * Server response for GET /afternotes/{afternoteId}. - * Common fields always present; category-specific fields (credentials, receivers, playlist) are null when not applicable. - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class AfternoteDetailResponseDto( - val afternoteId: Long, - val category: String, - val title: String, - @JsonNames("created_at", "createdAt") val createdAt: String = "", - @JsonNames("updated_at", "updatedAt") val updatedAt: String = "", - val credentials: AfternoteCredentialsDto? = null, - val receivers: List? = null, - val processMethod: String? = null, - val actions: List? = null, - val leaveMessage: String? = null, - val playlist: AfternotePlaylistDto? = null -) - -/** - * Receiver in GET /afternotes/{id} response (GALLERY). - * API currently returns only receiverId; name/relation are resolved via GET /users/receivers. - * Accepts optional name/relation/phone if API adds them later. - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class AfternoteDetailReceiverDto( - @JsonNames("receiver_id") val receiverId: Long? = null, - @JsonNames("receiverName", "receiver_name") val name: String? = null, - @JsonNames("receiverRelation", "receiver_relation", "relationship") val relation: String? = null, - @JsonNames("receiverPhone", "receiver_phone", "phoneNumber", "phone_number") val phone: String? = null -) - -@Serializable -data class AfternotePlaylistDto( - val profilePhoto: String? = null, - val atmosphere: String? = null, - val memorialPhotoUrl: String? = null, - val songs: List = emptyList(), - val memorialVideo: AfternoteMemorialVideoDto? = null -) - -@Serializable -data class AfternoteSongDto( - val id: Long? = null, - val title: String, - val artist: String, - val coverUrl: String? = null -) - -@Serializable -data class AfternoteMemorialVideoDto( - val videoUrl: String? = null, - val thumbnailUrl: String? = null -) - -// --- POST /afternotes (PLAYLIST category) --- - -/** - * Server request form for POST /afternotes (PLAYLIST category). - * receivers: 수신자 목록 (모든 카테고리에서 가능). - */ -@Serializable -data class AfternoteCreatePlaylistRequestDto( - val category: String = "PLAYLIST", - val title: String, - val playlist: AfternotePlaylistDto, - val receivers: List = emptyList() -) - -// --- PATCH /afternotes/{afternoteId} (partial update) --- - -/** - * Partial update request. Title and category are mandatory per server API. - * Send only fields valid for the afternote's category (SOCIAL: credentials, etc.; GALLERY: receivers; PLAYLIST: playlist). - */ -@Serializable -data class AfternoteUpdateRequestDto( - val category: String, - val title: String, - val processMethod: String? = null, - val actions: List? = null, - val leaveMessage: String? = null, - val credentials: AfternoteCredentialsDto? = null, - val receivers: List? = null, - val playlist: AfternotePlaylistDto? = null -) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteListItem.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteListItem.kt new file mode 100644 index 00000000..e4f93d4d --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteListItem.kt @@ -0,0 +1,15 @@ +package com.kuit.afternote.feature.afternote.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Single item in list response. Server form: afternoteId, title, category, createdAt. + */ +@Serializable +data class AfternoteListItem( + @SerialName("afternoteId") val afternoteId: Long, + @SerialName("title") val title: String, + @SerialName("category") val category: String, + @SerialName("createdAt") val createdAt: String, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteMemorialVideo.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteMemorialVideo.kt new file mode 100644 index 00000000..bfa16ac0 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteMemorialVideo.kt @@ -0,0 +1,10 @@ +package com.kuit.afternote.feature.afternote.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AfternoteMemorialVideo( + @SerialName("videoUrl") val videoUrl: String? = null, + @SerialName("thumbnailUrl") val thumbnailUrl: String? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternotePlaylist.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternotePlaylist.kt new file mode 100644 index 00000000..cd45e510 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternotePlaylist.kt @@ -0,0 +1,13 @@ +package com.kuit.afternote.feature.afternote.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AfternotePlaylist( + @SerialName("profilePhoto") val profilePhoto: String? = null, + @SerialName("atmosphere") val atmosphere: String? = null, + @SerialName("memorialPhotoUrl") val memorialPhotoUrl: String? = null, + @SerialName("songs") val songs: List = emptyList(), + @SerialName("memorialVideo") val memorialVideo: AfternoteMemorialVideo? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteReceiverRef.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteReceiverRef.kt new file mode 100644 index 00000000..7ad861e3 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteReceiverRef.kt @@ -0,0 +1,12 @@ +package com.kuit.afternote.feature.afternote.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * 수신자 참조 (API ReceiverRequest). 수신자 목록은 모든 카테고리에서 사용 가능. + */ +@Serializable +data class AfternoteReceiverRef( + @SerialName("receiverId") val receiverId: Long, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteSong.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteSong.kt new file mode 100644 index 00000000..10d0bf8f --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteSong.kt @@ -0,0 +1,12 @@ +package com.kuit.afternote.feature.afternote.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AfternoteSong( + @SerialName("id") val id: Long? = null, + @SerialName("title") val title: String, + @SerialName("artist") val artist: String, + @SerialName("coverUrl") val coverUrl: String? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/MusicSearchDto.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/MusicSearchDto.kt deleted file mode 100644 index 8582b403..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/MusicSearchDto.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.kuit.afternote.feature.afternote.data.dto - -import kotlinx.serialization.Serializable - -/** - * Response body for GET /music/search?keyword=. - * Server returns 200 with tracks array; 400 when keyword is missing. - */ -@Serializable -data class MusicSearchResponseDto( - val tracks: List = emptyList() -) - -/** - * Single track in music search response. - */ -@Serializable -data class MusicTrackDto( - val artist: String, - val title: String, - val albumImageUrl: String? = null -) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreateGalleryRequest.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreateGalleryRequest.kt new file mode 100644 index 00000000..a09f9afa --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreateGalleryRequest.kt @@ -0,0 +1,19 @@ +package com.kuit.afternote.feature.afternote.data.dto.request + +import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRef +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Server request form for POST /afternotes (GALLERY category). + * receivers: 수신자 목록 (모든 카테고리에서 가능). + */ +@Serializable +data class AfternoteCreateGalleryRequest( + @SerialName("category") val category: String = "GALLERY", + @SerialName("title") val title: String, + @SerialName("processMethod") val processMethod: String, + @SerialName("actions") val actions: List, + @SerialName("leaveMessage") val leaveMessage: String? = null, + @SerialName("receivers") val receivers: List, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreatePlaylistRequest.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreatePlaylistRequest.kt new file mode 100644 index 00000000..4f584079 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreatePlaylistRequest.kt @@ -0,0 +1,18 @@ +package com.kuit.afternote.feature.afternote.data.dto.request + +import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylist +import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRef +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Server request form for POST /afternotes (PLAYLIST category). + * receivers: 수신자 목록 (모든 카테고리에서 가능). + */ +@Serializable +data class AfternoteCreatePlaylistRequest( + @SerialName("category") val category: String = "PLAYLIST", + @SerialName("title") val title: String, + @SerialName("playlist") val playlist: AfternotePlaylist, + @SerialName("receivers") val receivers: List = emptyList(), +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreateSocialRequest.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreateSocialRequest.kt new file mode 100644 index 00000000..e233e6d8 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteCreateSocialRequest.kt @@ -0,0 +1,21 @@ +package com.kuit.afternote.feature.afternote.data.dto.request + +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentials +import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRef +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Server request form for POST /afternotes (SOCIAL category). + * Matches API: category, title, processMethod, actions, leaveMessage, credentials, receivers. + */ +@Serializable +data class AfternoteCreateSocialRequest( + @SerialName("category") val category: String = "SOCIAL", + @SerialName("title") val title: String, + @SerialName("processMethod") val processMethod: String, + @SerialName("actions") val actions: List, + @SerialName("leaveMessage") val leaveMessage: String? = null, + @SerialName("credentials") val credentials: AfternoteCredentials? = null, + @SerialName("receivers") val receivers: List = emptyList(), +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteUpdateRequest.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteUpdateRequest.kt new file mode 100644 index 00000000..95ce7837 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/request/AfternoteUpdateRequest.kt @@ -0,0 +1,23 @@ +package com.kuit.afternote.feature.afternote.data.dto.request + +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentials +import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylist +import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRef +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Partial update request. Title and category are mandatory per server API. + * Send only fields valid for the afternote's category (SOCIAL: credentials, etc.; GALLERY: receivers; PLAYLIST: playlist). + */ +@Serializable +data class AfternoteUpdateRequest( + @SerialName("category") val category: String, + @SerialName("title") val title: String, + @SerialName("processMethod") val processMethod: String? = null, + @SerialName("actions") val actions: List? = null, + @SerialName("leaveMessage") val leaveMessage: String? = null, + @SerialName("credentials") val credentials: AfternoteCredentials? = null, + @SerialName("receivers") val receivers: List? = null, + @SerialName("playlist") val playlist: AfternotePlaylist? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteDetailResponse.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteDetailResponse.kt new file mode 100644 index 00000000..a34bfe52 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteDetailResponse.kt @@ -0,0 +1,31 @@ +package com.kuit.afternote.feature.afternote.data.dto.response + +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentials +import com.kuit.afternote.feature.afternote.data.dto.AfternoteDetailReceiver +import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylist +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +/** + * Server response for GET /afternotes/{afternoteId}. + * Common fields always present; category-specific fields (credentials, receivers, playlist) are null when not applicable. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class AfternoteDetailResponse( + @SerialName("afternoteId") val afternoteId: Long, + @SerialName("category") val category: String, + @SerialName("title") val title: String, + @SerialName("createdAt") + @JsonNames("created_at", "createdAt") val createdAt: String = "", + @SerialName("updatedAt") + @JsonNames("updated_at", "updatedAt") val updatedAt: String = "", + @SerialName("credentials") val credentials: AfternoteCredentials? = null, + @SerialName("receivers") val receivers: List? = null, + @SerialName("processMethod") val processMethod: String? = null, + @SerialName("actions") val actions: List? = null, + @SerialName("leaveMessage") val leaveMessage: String? = null, + @SerialName("playlist") val playlist: AfternotePlaylist? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteIdResponse.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteIdResponse.kt new file mode 100644 index 00000000..30eef956 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteIdResponse.kt @@ -0,0 +1,18 @@ +package com.kuit.afternote.feature.afternote.data.dto.response + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +/** + * Server response data for POST /afternotes and PATCH /afternotes/{id}. + * Accepts both snake_case (afternote_id) and camelCase (afternoteId) from API. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class AfternoteIdResponse( + @SerialName("afternoteId") + @JsonNames("afternote_id") val afternoteId: Long, +) + diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteListResponse.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteListResponse.kt new file mode 100644 index 00000000..539a88d0 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/response/AfternoteListResponse.kt @@ -0,0 +1,17 @@ +package com.kuit.afternote.feature.afternote.data.dto.response + +import com.kuit.afternote.feature.afternote.data.dto.AfternoteListItem +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Server response form for GET /afternotes. + * Matches API: data.content[], page, size, hasNext. + */ +@Serializable +data class AfternoteListResponse( + @SerialName("content") val content: List = emptyList(), + @SerialName("page") val page: Int = 0, + @SerialName("size") val size: Int = 10, + @SerialName("hasNext") val hasNext: Boolean = false, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteInputMapper.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteInputMapper.kt new file mode 100644 index 00000000..c5bd3085 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteInputMapper.kt @@ -0,0 +1,60 @@ +package com.kuit.afternote.feature.afternote.data.mapper + +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentials +import com.kuit.afternote.feature.afternote.data.dto.AfternoteMemorialVideo +import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylist +import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRef +import com.kuit.afternote.feature.afternote.data.dto.AfternoteSong +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteUpdateRequest +import com.kuit.afternote.feature.afternote.domain.model.AfternoteUpdateRequestInput +import com.kuit.afternote.feature.afternote.domain.model.CredentialsInput +import com.kuit.afternote.feature.afternote.domain.model.ReceiverRefInput +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistInput +import com.kuit.afternote.feature.afternote.domain.model.playlist.MemorialVideoInput +import com.kuit.afternote.feature.afternote.domain.model.playlist.SongInput + +fun AfternotePlaylistInput.toDto() = + AfternotePlaylist( + profilePhoto = profilePhoto, + atmosphere = atmosphere, + memorialPhotoUrl = memorialPhotoUrl, + songs = songs.map { it.toDto() }, + memorialVideo = memorialVideo?.toDto(), + ) + +fun MemorialVideoInput.toDto() = + AfternoteMemorialVideo( + videoUrl = videoUrl, + thumbnailUrl = thumbnailUrl, + ) + +fun SongInput.toDto() = + AfternoteSong( + id = id, + title = title, + artist = artist, + coverUrl = coverUrl, + ) + +fun AfternoteUpdateRequestInput.toDto() = + AfternoteUpdateRequest( + category = category, + title = title, + processMethod = processMethod, + actions = actions, + leaveMessage = leaveMessage, + credentials = credentials?.toDto(), + receivers = receivers?.map { it.toDto() }, + playlist = playlist?.toDto(), + ) + +fun CredentialsInput.toDto() = + AfternoteCredentials( + id = id, + password = password, + ) + +fun ReceiverRefInput.toDto() = + AfternoteReceiverRef( + receiverId = receiverId, + ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt index 8504f25d..4329c5ed 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt @@ -1,87 +1,109 @@ package com.kuit.afternote.feature.afternote.data.mapper -import com.kuit.afternote.feature.afternote.data.dto.AfternoteDetailResponseDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteListItemDto +import com.kuit.afternote.core.domain.model.AfternoteServiceType +import com.kuit.afternote.feature.afternote.data.dto.AfternoteListItem +import com.kuit.afternote.feature.afternote.data.dto.response.AfternoteDetailResponse +import com.kuit.afternote.feature.afternote.data.dto.response.AfternoteListResponse import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail +import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailCredentials +import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailProcessing import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailReceiver -import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailSong +import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailTimestamps import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem -import com.kuit.afternote.feature.afternote.domain.model.AfternotePlaylistDetail -import com.kuit.afternote.core.domain.model.AfternoteServiceType +import com.kuit.afternote.feature.afternote.domain.model.PagedAfternotes +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternoteDetailSong +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistDetail +import com.kuit.afternote.feature.afternote.domain.model.playlist.PlaylistDetailMemorialMedia /** * Maps server DTOs to domain models at the boundary only. */ object AfternoteMapper { - // -- List mapping -- - fun toDomain(dto: AfternoteListItemDto): AfternoteItem = + fun toDomain(dto: AfternoteListItem): AfternoteItem = AfternoteItem( id = dto.afternoteId.toString(), serviceName = dto.title, date = formatDateFromServer(dto.createdAt), type = categoryToServiceType(dto.category), - processingMethods = emptyList(), - galleryProcessingMethods = emptyList() ) - fun toDomainList(dtos: List): List = - dtos.map { toDomain(it) } + fun toDomainList(dtos: List): List = dtos.map { toDomain(it) } // -- Detail mapping -- - fun toDetailDomain(dto: AfternoteDetailResponseDto): AfternoteDetail = + fun toDetailDomain(dto: AfternoteDetailResponse): AfternoteDetail = AfternoteDetail( id = dto.afternoteId, category = dto.category, title = dto.title, - createdAt = formatDateFromServer(dto.createdAt), - updatedAt = formatDateFromServer(dto.updatedAt), + timestamps = + AfternoteDetailTimestamps( + createdAt = formatDateFromServer(dto.createdAt), + updatedAt = formatDateFromServer(dto.createdAt), + ), type = categoryToServiceType(dto.category), - credentialsId = dto.credentials?.id, - credentialsPassword = dto.credentials?.password, - receivers = dto.receivers?.map { r -> - AfternoteDetailReceiver( - receiverId = r.receiverId, - name = r.name ?: "", - relation = r.relation ?: "", - phone = r.phone ?: "" - ) - } ?: emptyList(), - processMethod = dto.processMethod, - actions = dto.actions ?: emptyList(), - leaveMessage = dto.leaveMessage, - playlist = dto.playlist?.let { p -> - AfternotePlaylistDetail( - profilePhoto = p.profilePhoto, - atmosphere = p.atmosphere, - memorialPhotoUrl = p.memorialPhotoUrl ?: p.profilePhoto, - songs = p.songs.map { s -> - AfternoteDetailSong( - id = s.id, - title = s.title, - artist = s.artist, - coverUrl = s.coverUrl - ) - }, - memorialVideoUrl = p.memorialVideo?.videoUrl, - memorialThumbnailUrl = p.memorialVideo?.thumbnailUrl - ) - } + credentials = + AfternoteDetailCredentials( + id = dto.credentials?.id, + password = dto.credentials?.password, + ), + receivers = + dto.receivers?.map { r -> + AfternoteDetailReceiver( + receiverId = r.receiverId, + name = r.name ?: "", + relation = r.relation ?: "", + phone = r.phone ?: "", + ) + } ?: emptyList(), + processing = + AfternoteDetailProcessing( + method = dto.processMethod, + actions = dto.actions ?: emptyList(), + leaveMessage = dto.leaveMessage, + ), + playlist = + dto.playlist?.let { p -> + AfternotePlaylistDetail( + profilePhoto = p.profilePhoto, + atmosphere = p.atmosphere, + songs = + p.songs.map { s -> + AfternoteDetailSong( + id = s.id, + title = s.title, + artist = s.artist, + coverUrl = s.coverUrl, + ) + }, + playlistDetailMemorialMedia = + PlaylistDetailMemorialMedia( + photoUrl = p.memorialPhotoUrl ?: p.profilePhoto, + videoUrl = p.memorialVideo?.videoUrl, + thumbnailUrl = p.memorialVideo?.thumbnailUrl, + ), + ) + }, + ) + + fun toPagedNotes(data: AfternoteListResponse) = + PagedAfternotes( + items = toDomainList(data.content), + hasNext = data.hasNext, ) // -- Internal helpers -- - private fun formatDateFromServer(createdAt: String): String { - return try { + private fun formatDateFromServer(createdAt: String): String = + try { // Server returns "2025-11-26T14:30:00" -> display "2025.11.26" val datePart = createdAt.substringBefore('T') datePart.replace('-', '.') } catch (_: Exception) { createdAt } - } internal fun categoryToServiceType(category: String): AfternoteServiceType = when (category.uppercase()) { diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/repository/AfternoteRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/repository/AfternoteRepositoryImpl.kt deleted file mode 100644 index 67fde061..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/repository/AfternoteRepositoryImpl.kt +++ /dev/null @@ -1,201 +0,0 @@ -package com.kuit.afternote.feature.afternote.data.repository - -import android.util.Log -import com.kuit.afternote.data.remote.requireData -import com.kuit.afternote.data.remote.requireSuccess -import com.kuit.afternote.feature.afternote.data.api.AfternoteApiService -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateGalleryRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreatePlaylistRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateSocialRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentialsDto -import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylistDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto -import com.kuit.afternote.feature.afternote.data.mapper.AfternoteMapper -import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail -import com.kuit.afternote.feature.afternote.domain.model.PagedAfternotes -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository -import javax.inject.Inject - -private const val TAG = "AfternoteRepo" - -/** - * Data layer: calls Afternote API, maps DTO → domain at boundary. - * - * API spec: GET/POST /afternotes, GET/PATCH/DELETE /afternotes/{id}. - */ -class AfternoteRepositoryImpl - @Inject - constructor( - private val api: AfternoteApiService - ) : AfternoteRepository { - - override suspend fun getAfternotes( - category: String?, - page: Int, - size: Int - ): Result = runCatching { - val response = api.getAfternotes(category = category, page = page, size = size) - val data = response.requireData() - val content = data?.content.orEmpty() - val hasNext = data?.hasNext ?: false - PagedAfternotes(items = AfternoteMapper.toDomainList(content), hasNext = hasNext) - } - - override suspend fun createSocial( - title: String, - processMethod: String, - actions: List, - leaveMessage: String?, - credentialsId: String?, - credentialsPassword: String?, - receiverIds: List - ): Result = runCatching { - val body = AfternoteCreateSocialRequestDto( - category = "SOCIAL", - title = title, - processMethod = processMethod, - actions = actions, - leaveMessage = leaveMessage, - credentials = when { - credentialsId != null || credentialsPassword != null -> AfternoteCredentialsDto( - id = credentialsId, - password = credentialsPassword - ) - else -> null - }, - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) } - ) - Log.d( - TAG, - "createSocial: title=${body.title}, processMethod=${body.processMethod}, " + - "actions=${body.actions}, hasCredentials=${body.credentials != null}" - ) - val response = api.createAfternoteSocial(body) - Log.d( - TAG, - "createSocial response: status=${response.status}, code=${response.code}, " + - "message=${response.message}, data=${response.data}" - ) - response.requireData().afternoteId - }.also { result -> - result.onFailure { e -> - if (e is retrofit2.HttpException) { - val errorBody = e.response()?.errorBody()?.string() - Log.e(TAG, "createSocial: FAILED ${e.code()} body=$errorBody", e) - } else { - Log.e(TAG, "createSocial: FAILED", e) - } - } - } - - override suspend fun createGallery( - title: String, - processMethod: String, - actions: List, - leaveMessage: String?, - receiverIds: List - ): Result = runCatching { - val body = AfternoteCreateGalleryRequestDto( - category = "GALLERY", - title = title, - processMethod = processMethod, - actions = actions, - leaveMessage = leaveMessage, - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) } - ) - Log.d( - TAG, - "createGallery: title=${body.title}, processMethod=${body.processMethod}, " + - "actions=${body.actions}, receiverIds=$receiverIds" - ) - val response = api.createAfternoteGallery(body) - Log.d( - TAG, - "createGallery response: status=${response.status}, code=${response.code}, " + - "message=${response.message}, data=${response.data}" - ) - response.requireData().afternoteId - }.also { result -> - result.onFailure { e -> - if (e is retrofit2.HttpException) { - val errorBody = e.response()?.errorBody()?.string() - Log.e(TAG, "createGallery: FAILED ${e.code()} body=$errorBody", e) - } else { - Log.e(TAG, "createGallery: FAILED", e) - } - } - } - - /** - * GET /afternotes/{afternoteId} — 상세 조회. DTO → domain 매핑 포함. - */ - override suspend fun getAfternoteDetail(afternoteId: Long): Result = - runCatching { - val response = api.getAfternoteDetail(afternoteId = afternoteId) - AfternoteMapper.toDetailDomain(response.requireData()) - } - - /** - * POST /afternotes (PLAYLIST category). - */ - override suspend fun createPlaylist( - title: String, - playlist: AfternotePlaylistDto, - receiverIds: List - ): Result = runCatching { - val body = AfternoteCreatePlaylistRequestDto( - category = "PLAYLIST", - title = title, - playlist = playlist, - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) } - ) - Log.d( - TAG, - "createPlaylist: title=${body.title}, songsCount=${playlist.songs.size}" - ) - val response = api.createAfternotePlaylist(body) - Log.d( - TAG, - "createPlaylist response: status=${response.status}, code=${response.code}, " + - "message=${response.message}, data=${response.data}" - ) - response.requireData().afternoteId - }.also { result -> - result.onFailure { e -> - Log.e(TAG, "createPlaylist: FAILED", e) - } - } - - /** - * PATCH /afternotes/{afternoteId} — 부분 수정 (수정할 필드만 전송). - */ - override suspend fun updateAfternote( - afternoteId: Long, - body: AfternoteUpdateRequestDto - ): Result = runCatching { - Log.d( - TAG, - "updateAfternote: id=$afternoteId, title=${body.title}, " + - "processMethod=${body.processMethod}, actions=${body.actions}" - ) - val response = api.updateAfternote(afternoteId = afternoteId, body = body) - Log.d( - TAG, - "updateAfternote response: status=${response.status}, code=${response.code}, " + - "message=${response.message}, data=${response.data}" - ) - response.requireData().afternoteId - }.also { result -> - result.onFailure { e -> - Log.e(TAG, "updateAfternote: FAILED, id=$afternoteId", e) - } - } - - /** - * DELETE /afternotes/{afternoteId}. - */ - override suspend fun deleteAfternote(afternoteId: Long): Result = runCatching { - api.deleteAfternote(afternoteId = afternoteId).requireSuccess() - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/repository/MusicSearchRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/repository/MusicSearchRepositoryImpl.kt deleted file mode 100644 index 25f8aa1c..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/repository/MusicSearchRepositoryImpl.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.kuit.afternote.feature.afternote.data.repository - -import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.feature.afternote.data.api.MusicApiService -import com.kuit.afternote.feature.afternote.data.dto.MusicTrackDto -import com.kuit.afternote.feature.afternote.domain.repository.iface.MusicSearchRepository -import javax.inject.Inject - -/** - * Data layer: calls music search API and maps DTO to [PlaylistSongDisplay]. - * API returns raw { "tracks": [...] } (no ApiResponse wrapper). - */ -class MusicSearchRepositoryImpl - @Inject - constructor( - private val api: MusicApiService - ) : MusicSearchRepository { - - override suspend fun search(keyword: String): Result> = runCatching { - val trimmed = keyword.trim() - if (trimmed.isEmpty()) return@runCatching emptyList() - val response = api.search(keyword = trimmed) - response.tracks.mapIndexed { index, dto -> dto.toPlaylistSongDisplay(index) } - } - - private fun MusicTrackDto.toPlaylistSongDisplay(index: Int): PlaylistSongDisplay { - val id = "${artist}|$title|$index" - return PlaylistSongDisplay( - id = id, - title = title, - artist = artist, - albumImageUrl = albumImageUrl - ) - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/repositoryimpl/AfternoteRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/repositoryimpl/AfternoteRepositoryImpl.kt new file mode 100644 index 00000000..62f31aa8 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/repositoryimpl/AfternoteRepositoryImpl.kt @@ -0,0 +1,161 @@ +package com.kuit.afternote.feature.afternote.data.repositoryimpl + +import android.util.Log +import com.kuit.afternote.data.requireData +import com.kuit.afternote.data.requireStatus +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentials +import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRef +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteCreateGalleryRequest +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteCreatePlaylistRequest +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteCreateSocialRequest +import com.kuit.afternote.feature.afternote.data.dto.response.AfternoteIdResponse +import com.kuit.afternote.feature.afternote.data.mapper.AfternoteMapper +import com.kuit.afternote.feature.afternote.data.mapper.toDto +import com.kuit.afternote.feature.afternote.data.service.AfternoteApiService +import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail +import com.kuit.afternote.feature.afternote.domain.model.AfternoteUpdateRequestInput +import com.kuit.afternote.feature.afternote.domain.model.PagedAfternotes +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistInput +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository +import javax.inject.Inject + +/** + * Data layer: calls Afternote API, maps DTO → domain at boundary. + * + * API spec: GET/POST /afternotes, GET/PATCH/DELETE /afternotes/{id}. + */ +class AfternoteRepositoryImpl + @Inject + constructor( + private val api: AfternoteApiService, + ) : AfternoteRepository { + override suspend fun getAfternotes( + category: String?, + page: Int, + size: Int, + ): Result = + runCatching { + val response = api.getAfternotes(category = category, page = page, size = size) + val data = response.requireData() + AfternoteMapper.toPagedNotes(data) + }.logFailure() + + override suspend fun createSocial( + title: String, + processMethod: String, + actions: List, + leaveMessage: String?, + credentialsId: String?, + credentialsPassword: String?, + receiverIds: List, + ): Result = + runCatching { + val request = + AfternoteCreateSocialRequest( + category = "SOCIAL", + title = title, + processMethod = processMethod, + actions = actions, + leaveMessage = leaveMessage, + credentials = afternoteCredentials(credentialsId, credentialsPassword), + receivers = receiverIds.map { AfternoteReceiverRef(receiverId = it) }, + ) + val response = api.createAfternoteSocial(request) + val data = response.requireData() + getAfternoteId(data) + }.logFailure() + + override suspend fun createGallery( + title: String, + processMethod: String, + actions: List, + leaveMessage: String?, + receiverIds: List, + ): Result = + runCatching { + val request = + AfternoteCreateGalleryRequest( + category = "GALLERY", + title = title, + processMethod = processMethod, + actions = actions, + leaveMessage = leaveMessage, + receivers = receiverIds.map { AfternoteReceiverRef(receiverId = it) }, + ) + val response = api.createAfternoteGallery(request) + val data = response.requireData() + getAfternoteId(data) + }.logFailure() + + /** + * GET /afternotes/{afternoteId} — 상세 조회. DTO → domain 매핑 포함. + */ + override suspend fun getAfternoteDetail(afternoteId: Long): Result = + runCatching { + val response = api.getAfternoteDetail(afternoteId = afternoteId) + val data = response.requireData() + AfternoteMapper.toDetailDomain(data) + }.logFailure() + + /** + * POST /afternotes (PLAYLIST category). + */ + override suspend fun createPlaylist( + title: String, + playlist: AfternotePlaylistInput, + receiverIds: List, + ): Result = + runCatching { + val request = + AfternoteCreatePlaylistRequest( + category = "PLAYLIST", + title = title, + playlist = playlist.toDto(), + receivers = receiverIds.map { AfternoteReceiverRef(receiverId = it) }, + ) + val response = api.createAfternotePlaylist(request) + val data = response.requireData() + getAfternoteId(data) + }.logFailure() + + /** + * PATCH /afternotes/{afternoteId} — 부분 수정 (수정할 필드만 전송). + */ + override suspend fun updateAfternote( + afternoteId: Long, + input: AfternoteUpdateRequestInput, + ): Result = + runCatching { + val response = api.updateAfternote(afternoteId = afternoteId, request = input.toDto()) + val data = response.requireData() + getAfternoteId(data) + }.logFailure() + + /** + * DELETE /afternotes/{afternoteId}. + */ + override suspend fun deleteAfternote(afternoteId: Long): Result = + runCatching { + val response = api.deleteAfternote(afternoteId = afternoteId) + response.requireStatus() + }.logFailure() + } + +private fun getAfternoteId(data: AfternoteIdResponse) = data.afternoteId + +private fun Result.logFailure() = + onFailure { error -> + val message = error.message + val msg = message.toString() + Log.e("AfternoteRepository", msg) + } + +private fun afternoteCredentials( + credentialsId: String?, + credentialsPassword: String?, +): AfternoteCredentials? { + if (credentialsId != null || credentialsPassword != null) { + return AfternoteCredentials(id = credentialsId, password = credentialsPassword) + } + return null +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/api/.gitkeep b/app/src/main/java/com/kuit/afternote/feature/afternote/data/service/.gitkeep similarity index 100% rename from app/src/main/java/com/kuit/afternote/feature/afternote/data/api/.gitkeep rename to app/src/main/java/com/kuit/afternote/feature/afternote/data/service/.gitkeep diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/service/AfternoteApiService.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/service/AfternoteApiService.kt new file mode 100644 index 00000000..030ee6b1 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/service/AfternoteApiService.kt @@ -0,0 +1,57 @@ +package com.kuit.afternote.feature.afternote.data.service + +import com.kuit.afternote.data.BaseResponse +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteCreateGalleryRequest +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteCreatePlaylistRequest +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteCreateSocialRequest +import com.kuit.afternote.feature.afternote.data.dto.request.AfternoteUpdateRequest +import com.kuit.afternote.feature.afternote.data.dto.response.AfternoteDetailResponse +import com.kuit.afternote.feature.afternote.data.dto.response.AfternoteIdResponse +import com.kuit.afternote.feature.afternote.data.dto.response.AfternoteListResponse +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface AfternoteApiService { + @GET("api/afternotes") + suspend fun getAfternotes( + @Query("category") category: String? = null, + @Query("page") page: Int = 0, + @Query("size") size: Int = 10, + ): BaseResponse + + @GET("api/afternotes/{afternoteId}") + suspend fun getAfternoteDetail( + @Path("afternoteId") afternoteId: Long, + ): BaseResponse + + @POST("api/afternotes") + suspend fun createAfternoteSocial( + @Body request: AfternoteCreateSocialRequest, + ): BaseResponse + + @POST("api/afternotes") + suspend fun createAfternoteGallery( + @Body request: AfternoteCreateGalleryRequest, + ): BaseResponse + + @POST("api/afternotes") + suspend fun createAfternotePlaylist( + @Body request: AfternoteCreatePlaylistRequest, + ): BaseResponse + + @PATCH("api/afternotes/{afternoteId}") + suspend fun updateAfternote( + @Path("afternoteId") afternoteId: Long, + @Body request: AfternoteUpdateRequest, + ): BaseResponse + + @DELETE("api/afternotes/{afternoteId}") + suspend fun deleteAfternote( + @Path("afternoteId") afternoteId: Long, + ): BaseResponse +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/di/AfternoteRepositoryModule.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/di/AfternoteRepositoryModule.kt new file mode 100644 index 00000000..a9a2abb1 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/di/AfternoteRepositoryModule.kt @@ -0,0 +1,47 @@ +package com.kuit.afternote.feature.afternote.di + +import com.kuit.afternote.data.repositoryimpl.MemorialThumbnailUploadRepositoryImpl +import com.kuit.afternote.data.repositoryimpl.MemorialVideoUploadRepositoryImpl +import com.kuit.afternote.data.repositoryimpl.MusicSearchRepositoryImpl +import com.kuit.afternote.domain.repository.MemorialThumbnailUploadRepository +import com.kuit.afternote.domain.repository.MemorialVideoUploadRepository +import com.kuit.afternote.domain.repository.MusicSearchRepository +import com.kuit.afternote.feature.afternote.data.repositoryimpl.AfternoteRepositoryImpl +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Afternote Repository + ApiService DI 설정. + * + * - AfternoteRepositoryImpl ↔ AfternoteRepository + * - MusicSearchRepositoryImpl ↔ MusicSearchRepository + * - Retrofit → AfternoteApiService, MusicApiService + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class AfternoteRepositoryModule { + // 사용 위치가 Hilt가 생성하는 코드 안에만 있기 때문에 IDE에서 'never used'로 표시됩니다. + @Suppress("unused") + @Binds + @Singleton + abstract fun bindAfternoteRepository(impl: AfternoteRepositoryImpl): AfternoteRepository + + @Suppress("unused") + @Binds + @Singleton + abstract fun bindMusicSearchRepository(impl: MusicSearchRepositoryImpl): MusicSearchRepository + + @Suppress("unused") + @Binds + @Singleton + abstract fun bindMemorialThumbnailUploadRepository(impl: MemorialThumbnailUploadRepositoryImpl): MemorialThumbnailUploadRepository + + @Suppress("unused") + @Binds + @Singleton + abstract fun bindMemorialVideoUploadRepository(impl: MemorialVideoUploadRepositoryImpl): MemorialVideoUploadRepository +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/entity/.gitkeep b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/entity/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt index cc67dcbe..e0ea51d5 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt @@ -1,6 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.model import com.kuit.afternote.core.domain.model.AfternoteServiceType +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistDetail /** * 애프터노트 상세 도메인 모델. @@ -15,47 +16,26 @@ data class AfternoteDetail( val id: Long, val category: String, val title: String, - val createdAt: String, - val updatedAt: String, + val timestamps: AfternoteDetailTimestamps, val type: AfternoteServiceType, - val credentialsId: String?, - val credentialsPassword: String?, + val credentials: AfternoteDetailCredentials?, val receivers: List, - val processMethod: String?, - val actions: List, - val leaveMessage: String?, - val playlist: AfternotePlaylistDetail? + val processing: AfternoteDetailProcessing?, + val playlist: AfternotePlaylistDetail?, ) -/** - * 갤러리 카테고리의 수신자 정보. - * receiverId: from API; name/relation may be resolved from GET /users/receivers when API returns only IDs. - */ -data class AfternoteDetailReceiver( - val receiverId: Long? = null, - val name: String, - val relation: String, - val phone: String +data class AfternoteDetailTimestamps( + val createdAt: String, + val updatedAt: String, ) -/** - * 추모 가이드라인 카테고리의 플레이리스트 상세. - */ -data class AfternotePlaylistDetail( - val profilePhoto: String?, - val atmosphere: String?, - val memorialPhotoUrl: String?, - val songs: List, - val memorialVideoUrl: String?, - val memorialThumbnailUrl: String? +data class AfternoteDetailCredentials( + val id: String?, + val password: String?, ) -/** - * 플레이리스트 내 개별 곡 정보. - */ -data class AfternoteDetailSong( - val id: Long?, - val title: String, - val artist: String, - val coverUrl: String? +data class AfternoteDetailProcessing( + val method: String?, + val actions: List, + val leaveMessage: String?, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetailReceiver.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetailReceiver.kt new file mode 100644 index 00000000..15913b2e --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetailReceiver.kt @@ -0,0 +1,12 @@ +package com.kuit.afternote.feature.afternote.domain.model + +/** + * 갤러리 카테고리의 수신자 정보. + * receiverId: from API; name/relation may be resolved from GET /users/receivers when API returns only IDs. + */ +data class AfternoteDetailReceiver( + val receiverId: Long? = null, + val name: String, + val relation: String, + val phone: String, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt index 3ce02ed3..73f9e509 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.model +import android.R.id.message import com.kuit.afternote.core.domain.model.AfternoteServiceType -import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiModel /** * 애프터노트 아이템 도메인 모델 @@ -10,51 +10,26 @@ import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiM * @param serviceName 서비스명 * @param date 날짜 (yyyy.MM.dd 형식) * @param type 서비스 타입 (필터링용) - * @param accountId 계정 아이디 - * @param password 계정 비밀번호 * @param message 남기실 말씀 - * @param accountProcessingMethod 계정 처리 방법 (추모 계정 전환 / 영구 삭제) - * @param informationProcessingMethod 정보 처리 방법 (갤러리/파일: 수신자 전달 / 추가 수신자 전달) - * @param processingMethods 소셜네트워크/비즈니스 등 처리 방법 목록 - * @param galleryProcessingMethods 갤러리/파일 처리 방법 목록 */ data class AfternoteItem( val id: String, val serviceName: String, val date: String, val type: AfternoteServiceType, - val accountId: String = "", - val password: String = "", - val message: String = "", - val accountProcessingMethod: String = "", - val informationProcessingMethod: String = "", - val processingMethods: List = emptyList(), - val galleryProcessingMethods: List = emptyList() + val account: AfternoteAccount = AfternoteAccount(), + val processing: AfternoteItemProcessing = AfternoteItemProcessing(), ) -fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel { - // 1. 날짜 포맷팅 로직 (안정성을 위해 java.time 사용) - val inputFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy.MM.dd") - val isoFormatter = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd - - val parsedDate = runCatching { - java.time.LocalDate.parse(this.date, inputFormatter) - }.getOrElse { - java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) - } - - val originalDateStr = parsedDate.format(isoFormatter) // "2026-02-06" - val formattedDateStr = "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" +data class AfternoteAccount( + val id: String = "", + val password: String = "", +) - return MindRecordUiModel( - // String ID를 Long으로 변환 (UI용 unique key 확보) - id = this.id.hashCode().toLong(), - title = this.serviceName, - formattedDate = formattedDateStr, - draftLabel = "완료", // 애프터노트는 도메인상 완료된 데이터로 간주 - content = this.message, - type = this.type.name, - category = null, // 필요 시 mapping 로직 추가 - originalDate = originalDateStr - ) -} +data class AfternoteItemProcessing( + val message: String = "", + val accountMethod: String = "", + val informationMethod: String = "", + val methods: List = emptyList(), + val galleryMethods: List = emptyList(), +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteUpdateRequestInput.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteUpdateRequestInput.kt new file mode 100644 index 00000000..3045e6e1 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteUpdateRequestInput.kt @@ -0,0 +1,23 @@ +package com.kuit.afternote.feature.afternote.domain.model + +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistInput + +data class AfternoteUpdateRequestInput( + val category: String, + val title: String, + val processMethod: String? = null, + val actions: List? = null, + val leaveMessage: String? = null, + val credentials: CredentialsInput? = null, + val receivers: List? = null, + val playlist: AfternotePlaylistInput? = null, +) + +data class CredentialsInput( + val id: String? = null, + val password: String? = null, +) + +data class ReceiverRefInput( + val receiverId: Long, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/playlist/AfternotePlaylistDetail.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/playlist/AfternotePlaylistDetail.kt new file mode 100644 index 00000000..9f1de4af --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/playlist/AfternotePlaylistDetail.kt @@ -0,0 +1,27 @@ +package com.kuit.afternote.feature.afternote.domain.model.playlist + +/** + * 추모 가이드라인 카테고리의 플레이리스트 상세. + */ +data class AfternotePlaylistDetail( + val profilePhoto: String?, + val atmosphere: String?, + val songs: List, + val playlistDetailMemorialMedia: PlaylistDetailMemorialMedia, +) + +data class PlaylistDetailMemorialMedia( + val photoUrl: String?, + val videoUrl: String?, + val thumbnailUrl: String?, +) + +/** + * 플레이리스트 내 개별 곡 정보. + */ +data class AfternoteDetailSong( + val id: Long?, + val title: String, + val artist: String, + val coverUrl: String?, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/playlist/AfternotePlaylistInput.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/playlist/AfternotePlaylistInput.kt new file mode 100644 index 00000000..ce093297 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/playlist/AfternotePlaylistInput.kt @@ -0,0 +1,21 @@ +package com.kuit.afternote.feature.afternote.domain.model.playlist + +data class AfternotePlaylistInput( + val profilePhoto: String? = null, + val atmosphere: String? = null, + val memorialPhotoUrl: String? = null, + val songs: List = emptyList(), + val memorialVideo: MemorialVideoInput? = null, +) + +data class MemorialVideoInput( + val videoUrl: String? = null, + val thumbnailUrl: String? = null, +) + +data class SongInput( + val id: Long? = null, + val title: String, + val artist: String, + val coverUrl: String? = null, +) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/AfternoteRepository.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/AfternoteRepository.kt similarity index 73% rename from app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/AfternoteRepository.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/AfternoteRepository.kt index 14dac679..0f7af8f0 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/AfternoteRepository.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/AfternoteRepository.kt @@ -1,9 +1,9 @@ -package com.kuit.afternote.feature.afternote.domain.repository.iface +package com.kuit.afternote.feature.afternote.domain.repository -import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylistDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail +import com.kuit.afternote.feature.afternote.domain.model.AfternoteUpdateRequestInput import com.kuit.afternote.feature.afternote.domain.model.PagedAfternotes +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistInput /** * Afternote 도메인 Repository 인터페이스. @@ -16,11 +16,10 @@ import com.kuit.afternote.feature.afternote.domain.model.PagedAfternotes * - DELETE /afternotes/{afternoteId} (삭제) */ interface AfternoteRepository { - suspend fun getAfternotes( category: String?, page: Int, - size: Int + size: Int, ): Result suspend fun createSocial( @@ -30,7 +29,7 @@ interface AfternoteRepository { leaveMessage: String?, credentialsId: String?, credentialsPassword: String?, - receiverIds: List = emptyList() + receiverIds: List = emptyList(), ): Result suspend fun createGallery( @@ -38,22 +37,21 @@ interface AfternoteRepository { processMethod: String, actions: List, leaveMessage: String?, - receiverIds: List + receiverIds: List, ): Result suspend fun getAfternoteDetail(afternoteId: Long): Result suspend fun createPlaylist( title: String, - playlist: AfternotePlaylistDto, - receiverIds: List = emptyList() + playlist: AfternotePlaylistInput, + receiverIds: List = emptyList(), ): Result suspend fun updateAfternote( afternoteId: Long, - body: AfternoteUpdateRequestDto + input: AfternoteUpdateRequestInput, ): Result suspend fun deleteAfternote(afternoteId: Long): Result } - diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/.gitkeep b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/iface/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/impl/.gitkeep b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/repository/impl/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCase.kt index e8e23c92..f7109d05 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import javax.inject.Inject /** @@ -11,19 +11,20 @@ import javax.inject.Inject class CreateGalleryAfternoteUseCase @Inject constructor( - private val repository: AfternoteRepository + private val repository: AfternoteRepository, ) { suspend operator fun invoke( title: String, processMethod: String, actions: List, leaveMessage: String? = null, - receiverIds: List = emptyList() - ): Result = repository.createGallery( - title = title, - processMethod = processMethod, - actions = actions, - leaveMessage = leaveMessage, - receiverIds = receiverIds - ) + receiverIds: List = emptyList(), + ): Result = + repository.createGallery( + title = title, + processMethod = processMethod, + actions = actions, + leaveMessage = leaveMessage, + receiverIds = receiverIds, + ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCase.kt index 824bdd6e..0d2a0482 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCase.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylistDto -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistInput +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import javax.inject.Inject /** @@ -12,15 +12,16 @@ import javax.inject.Inject class CreatePlaylistAfternoteUseCase @Inject constructor( - private val repository: AfternoteRepository + private val repository: AfternoteRepository, ) { suspend operator fun invoke( title: String, - playlist: AfternotePlaylistDto, - receiverIds: List = emptyList() - ): Result = repository.createPlaylist( - title = title, - playlist = playlist, - receiverIds = receiverIds - ) + playlist: AfternotePlaylistInput, + receiverIds: List = emptyList(), + ): Result = + repository.createPlaylist( + title = title, + playlist = playlist, + receiverIds = receiverIds, + ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCase.kt index d186a2b2..152df070 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import javax.inject.Inject /** @@ -11,7 +11,7 @@ import javax.inject.Inject class CreateSocialAfternoteUseCase @Inject constructor( - private val repository: AfternoteRepository + private val repository: AfternoteRepository, ) { suspend operator fun invoke( title: String, @@ -20,14 +20,15 @@ class CreateSocialAfternoteUseCase leaveMessage: String? = null, credentialsId: String? = null, credentialsPassword: String? = null, - receiverIds: List = emptyList() - ): Result = repository.createSocial( - title = title, - processMethod = processMethod, - actions = actions, - leaveMessage = leaveMessage, - credentialsId = credentialsId, - credentialsPassword = credentialsPassword, - receiverIds = receiverIds - ) + receiverIds: List = emptyList(), + ): Result = + repository.createSocial( + title = title, + processMethod = processMethod, + actions = actions, + leaveMessage = leaveMessage, + credentialsId = credentialsId, + credentialsPassword = credentialsPassword, + receiverIds = receiverIds, + ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCase.kt index ecd34888..7bc769fd 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import javax.inject.Inject /** @@ -11,8 +11,7 @@ import javax.inject.Inject class DeleteAfternoteUseCase @Inject constructor( - private val repository: AfternoteRepository + private val repository: AfternoteRepository, ) { - suspend operator fun invoke(afternoteId: Long): Result = - repository.deleteAfternote(afternoteId = afternoteId) + suspend operator fun invoke(afternoteId: Long): Result = repository.deleteAfternote(afternoteId = afternoteId) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCase.kt index 718dc8d1..fb734152 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCase.kt @@ -2,7 +2,7 @@ package com.kuit.afternote.feature.afternote.domain.usecase import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailReceiver -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import com.kuit.afternote.feature.user.domain.model.ReceiverListItem import com.kuit.afternote.feature.user.domain.usecase.GetReceiversUseCase import com.kuit.afternote.feature.user.domain.usecase.GetUserIdUseCase @@ -19,7 +19,7 @@ class GetAfternoteDetailUseCase constructor( private val repository: AfternoteRepository, private val getReceiversUseCase: GetReceiversUseCase, - private val getUserIdUseCase: GetUserIdUseCase + private val getUserIdUseCase: GetUserIdUseCase, ) { suspend operator fun invoke(afternoteId: Long): Result { val detailResult = repository.getAfternoteDetail(afternoteId = afternoteId) @@ -27,23 +27,24 @@ class GetAfternoteDetailUseCase val userId = getUserIdUseCase() ?: return detailResult val receiversListResult = getReceiversUseCase(userId = userId) val receiversList = receiversListResult.getOrElse { return detailResult } - val resolvedReceivers = resolveReceiverNames( - receivers = detail.receivers, - receiversList = receiversList - ) + val resolvedReceivers = + resolveReceiverNames( + receivers = detail.receivers, + receiversList = receiversList, + ) return Result.success(detail.copy(receivers = resolvedReceivers)) } private fun resolveReceiverNames( receivers: List, - receiversList: List + receiversList: List, ): List = receivers.map { r -> if (r.receiverId != null) { val fromList = receiversList.find { it.receiverId == r.receiverId } r.copy( name = fromList?.name ?: r.name, - relation = fromList?.relation ?: r.relation + relation = fromList?.relation ?: r.relation, ) } else { r diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternotesUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternotesUseCase.kt index 3e391bd3..82951b15 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternotesUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternotesUseCase.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.usecase import com.kuit.afternote.feature.afternote.domain.model.PagedAfternotes -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import javax.inject.Inject /** @@ -12,12 +12,11 @@ import javax.inject.Inject class GetAfternotesUseCase @Inject constructor( - private val repository: AfternoteRepository + private val repository: AfternoteRepository, ) { suspend operator fun invoke( category: String? = null, page: Int = 0, - size: Int = 10 + size: Int = 10, ): Result = repository.getAfternotes(category = category, page = page, size = size) } - diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/SearchMusicUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/SearchMusicUseCase.kt index f7ad08b1..f64b4956 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/SearchMusicUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/SearchMusicUseCase.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.usecase import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.feature.afternote.domain.repository.iface.MusicSearchRepository +import com.kuit.afternote.domain.repository.MusicSearchRepository import javax.inject.Inject /** @@ -10,14 +10,12 @@ import javax.inject.Inject * GET /music/search?keyword= — keyword가 비어 있으면 빈 목록 반환 (API 400 방지). */ class SearchMusicUseCase -@Inject -constructor( - private val repository: MusicSearchRepository -) { - - /** - * @param keyword 검색할 가수명 또는 노래 제목 (blank면 빈 목록 반환) - */ - suspend operator fun invoke(keyword: String): Result> = - repository.search(keyword) -} + @Inject + constructor( + private val repository: MusicSearchRepository, + ) { + /** + * @param keyword 검색할 가수명 또는 노래 제목 (blank면 빈 목록 반환) + */ + suspend operator fun invoke(keyword: String): Result> = repository.search(keyword) + } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCase.kt index 06750e76..d57fff1a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCase.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.model.AfternoteUpdateRequestInput +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import javax.inject.Inject /** @@ -12,13 +12,14 @@ import javax.inject.Inject class UpdateAfternoteUseCase @Inject constructor( - private val repository: AfternoteRepository + private val repository: AfternoteRepository, ) { suspend operator fun invoke( afternoteId: Long, - body: AfternoteUpdateRequestDto - ): Result = repository.updateAfternote( - afternoteId = afternoteId, - body = body - ) + body: AfternoteUpdateRequestInput, + ): Result = + repository.updateAfternote( + afternoteId = afternoteId, + input = body, + ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt index 062a266d..49c5f7ec 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.data.upload.PhotoUploadRepository +import com.kuit.afternote.domain.repository.PhotoUploadRepository import javax.inject.Inject private const val DIRECTORY_AFTERNOTES = "afternotes" @@ -12,8 +12,7 @@ private const val DIRECTORY_AFTERNOTES = "afternotes" class UploadMemorialPhotoUseCase @Inject constructor( - private val photoUploadRepository: PhotoUploadRepository + private val photoUploadRepository: PhotoUploadRepository, ) { - suspend operator fun invoke(uriString: String): Result = - photoUploadRepository.upload(uriString, DIRECTORY_AFTERNOTES) -} + suspend operator fun invoke(uriString: String): Result = photoUploadRepository.upload(uriString, DIRECTORY_AFTERNOTES) + } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialThumbnailUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialThumbnailUseCase.kt index d960fdb6..e268474c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialThumbnailUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialThumbnailUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.MemorialThumbnailUploadRepository +import com.kuit.afternote.domain.repository.MemorialThumbnailUploadRepository import javax.inject.Inject /** @@ -10,8 +10,7 @@ import javax.inject.Inject class UploadMemorialThumbnailUseCase @Inject constructor( - private val repository: MemorialThumbnailUploadRepository + private val repository: MemorialThumbnailUploadRepository, ) { - suspend operator fun invoke(jpegBytes: ByteArray): Result = - repository.uploadThumbnail(jpegBytes) + suspend operator fun invoke(jpegBytes: ByteArray): Result = repository.uploadThumbnail(jpegBytes) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialVideoUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialVideoUseCase.kt index d4150303..a82f7a0f 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialVideoUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialVideoUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.MemorialVideoUploadRepository +import com.kuit.afternote.domain.repository.MemorialVideoUploadRepository import javax.inject.Inject /** @@ -10,8 +10,7 @@ import javax.inject.Inject class UploadMemorialVideoUseCase @Inject constructor( - private val repository: MemorialVideoUploadRepository + private val repository: MemorialVideoUploadRepository, ) { - suspend operator fun invoke(contentUriString: String): Result = - repository.uploadVideo(contentUriString) + suspend operator fun invoke(contentUriString: String): Result = repository.uploadVideo(contentUriString) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteItemMapper.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/AfternoteItemMapper.kt similarity index 61% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteItemMapper.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/AfternoteItemMapper.kt index be2e8b53..6f0ebf6e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteItemMapper.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/AfternoteItemMapper.kt @@ -1,8 +1,10 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation import com.kuit.afternote.core.domain.model.AfternoteServiceCatalog import com.kuit.afternote.core.domain.model.AfternoteServiceType +import com.kuit.afternote.feature.afternote.domain.model.AfternoteAccount import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem +import com.kuit.afternote.feature.afternote.domain.model.AfternoteItemProcessing import com.kuit.afternote.feature.afternote.domain.model.AfternoteProcessingMethod /** @@ -10,7 +12,6 @@ import com.kuit.afternote.feature.afternote.domain.model.AfternoteProcessingMeth * 레거시 데이터 형식과의 호환성을 위한 헬퍼 */ object AfternoteItemMapper { - /** * List를 List으로 변환하며 **안정적인 id**를 부여. * 목록/상세/편집에서 동일한 id로 조회할 수 있도록 더미 목록용. @@ -25,13 +26,19 @@ object AfternoteItemMapper { serviceName = serviceName, date = date, type = serviceType, - accountId = dummyData.accountId, - password = dummyData.password, - message = dummyData.message, - accountProcessingMethod = dummyData.accountProcessingMethod, - informationProcessingMethod = dummyData.informationProcessingMethod, - processingMethods = dummyData.processingMethods, - galleryProcessingMethods = dummyData.galleryProcessingMethods + account = + AfternoteAccount( + id = dummyData.accountId, + password = dummyData.password, + ), + processing = + AfternoteItemProcessing( + message = dummyData.message, + accountMethod = dummyData.accountProcessingMethod, + informationMethod = dummyData.informationProcessingMethod, + methods = dummyData.processingMethods, + galleryMethods = dummyData.galleryProcessingMethods, + ), ) } @@ -51,8 +58,7 @@ object AfternoteItemMapper { * Only for dummy/legacy data (e.g. toAfternoteItemsWithStableIds pairs). Do not use for * user-defined titles; category/type must come from API or list item when available. */ - private fun inferServiceType(serviceName: String): AfternoteServiceType = - AfternoteServiceCatalog.serviceTypeFor(serviceName) + private fun inferServiceType(serviceName: String): AfternoteServiceType = AfternoteServiceCatalog.serviceTypeFor(serviceName) /** * 더미 데이터: 각 Detail 화면에 하드코딩된 값과 동일하게 맞춤. @@ -69,27 +75,38 @@ object AfternoteItemMapper { DummyItemFields( accountId = "qwerty123", password = "qwerty123", - message = "이 계정에는 우리 가족 여행 사진이 많아.\n" + - "계정 삭제하지 말고 꼭 추모 계정으로 남겨줘!", + message = + "이 계정에는 우리 가족 여행 사진이 많아.\n" + + "계정 삭제하지 말고 꼭 추모 계정으로 남겨줘!", accountProcessingMethod = "MEMORIAL_ACCOUNT", - processingMethods = listOf( - AfternoteProcessingMethod("1", "게시물 내리기"), - AfternoteProcessingMethod("2", "추모 게시물 올리기"), - AfternoteProcessingMethod("3", "추모 계정으로 전환하기") - ) + processingMethods = + listOf( + AfternoteProcessingMethod("1", "게시물 내리기"), + AfternoteProcessingMethod("2", "추모 게시물 올리기"), + AfternoteProcessingMethod("3", "추모 계정으로 전환하기"), + ), ) private fun dummyDataForServiceType(type: AfternoteServiceType): DummyItemFields = when (type) { - AfternoteServiceType.SOCIAL_NETWORK -> defaultSocialOrBusinessDummyFields() - AfternoteServiceType.GALLERY_AND_FILES -> DummyItemFields( - informationProcessingMethod = "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER", - galleryProcessingMethods = listOf( - AfternoteProcessingMethod("1", "'엽사' 폴더 박선호에게 전송"), - AfternoteProcessingMethod("2", "'흑역사' 폴더 삭제") + AfternoteServiceType.SOCIAL_NETWORK -> { + defaultSocialOrBusinessDummyFields() + } + + AfternoteServiceType.GALLERY_AND_FILES -> { + DummyItemFields( + informationProcessingMethod = "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER", + galleryProcessingMethods = + listOf( + AfternoteProcessingMethod("1", "'엽사' 폴더 박선호에게 전송"), + AfternoteProcessingMethod("2", "'흑역사' 폴더 삭제"), + ), ) - ) - AfternoteServiceType.MEMORIAL -> DummyItemFields() + } + + AfternoteServiceType.MEMORIAL -> { + DummyItemFields() + } } private data class DummyItemFields( @@ -99,6 +116,6 @@ object AfternoteItemMapper { val accountProcessingMethod: String = "", val informationProcessingMethod: String = "", val processingMethods: List = emptyList(), - val galleryProcessingMethods: List = emptyList() + val galleryProcessingMethods: List = emptyList(), ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteSaveState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/AfternoteSaveState.kt similarity index 80% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteSaveState.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/AfternoteSaveState.kt index 2c2fb38d..f4331234 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteSaveState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/AfternoteSaveState.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation import androidx.annotation.StringRes import com.kuit.afternote.R @@ -7,21 +7,27 @@ import com.kuit.afternote.R * Validation failure type for required-field checks before save. * UI should show [messageResId] via stringResource(). */ + /** * Exception carrying a validation error for async validation (e.g. GALLERY receivers from API). */ -class AfternoteValidationException(val validationError: AfternoteValidationError) : Exception() +class AfternoteValidationException( + val validationError: AfternoteValidationError, +) : Exception() -enum class AfternoteValidationError(@StringRes val messageResId: Int) { +enum class AfternoteValidationError( + @StringRes val messageResId: Int, +) { TITLE_REQUIRED(R.string.afternote_validation_title_required), SOCIAL_CREDENTIALS_REQUIRED(R.string.afternote_validation_social_credentials_required), SOCIAL_PROCESS_METHOD_REQUIRED(R.string.afternote_validation_social_process_method_required), SOCIAL_ACTIONS_REQUIRED(R.string.afternote_validation_social_actions_required), GALLERY_ACTIONS_REQUIRED(R.string.afternote_validation_gallery_actions_required), + /** 수신자 최소 1명 필요 (모든 카테고리). API 400/475와 동일 메시지. */ RECEIVERS_REQUIRED(R.string.afternote_validation_receivers_required), GALLERY_RECEIVERS_REQUIRED(R.string.afternote_validation_gallery_receivers_required), - PLAYLIST_SONGS_REQUIRED(R.string.afternote_validation_playlist_songs_required) + PLAYLIST_SONGS_REQUIRED(R.string.afternote_validation_playlist_songs_required), } /** @@ -32,5 +38,5 @@ data class AfternoteSaveState( val saveSuccess: Boolean = false, val savedId: Long? = null, val error: String? = null, - val validationError: AfternoteValidationError? = null + val validationError: AfternoteValidationError? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/MemorialPlaylistRouteScreen.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/MemorialPlaylistRouteScreen.kt similarity index 53% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/MemorialPlaylistRouteScreen.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/MemorialPlaylistRouteScreen.kt index 09aad25d..879bc7eb 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/MemorialPlaylistRouteScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/MemorialPlaylistRouteScreen.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -27,14 +27,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kuit.afternote.R +import com.kuit.afternote.app.compositionlocal.DataProviderLocals import com.kuit.afternote.core.ui.component.icon.ArrowIconSpec import com.kuit.afternote.core.ui.component.icon.RightArrowIcon import com.kuit.afternote.core.ui.component.list.SongPlaylistScreen import com.kuit.afternote.core.ui.component.list.SongPlaylistScreenManagementContent import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.app.compositionlocal.DataProviderLocals -import com.kuit.afternote.data.provider.FakeAfternoteEditDataProvider +import com.kuit.afternote.domain.provider.FakeAfternoteEditDataProvider +import com.kuit.afternote.feature.afternote.presentation.edit.MemorialPlaylistStateHolder import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme import com.kuit.afternote.ui.theme.B1 import com.kuit.afternote.ui.theme.B3 @@ -54,44 +55,46 @@ fun MemorialPlaylistRouteScreen( playlistStateHolder: MemorialPlaylistStateHolder, onBackClick: () -> Unit, onNavigateToAddSongScreen: () -> Unit, - initialSelectedSongIds: Set? = null + initialSelectedSongIds: Set? = null, ) { - val songs = playlistStateHolder.songs.map { s -> - PlaylistSongDisplay( - id = s.id, - title = s.title, - artist = s.artist, - albumImageUrl = s.albumCoverUrl - ) - } + val songs = + playlistStateHolder.songs.map { s -> + PlaylistSongDisplay( + id = s.id, + title = s.title, + artist = s.artist, + albumImageUrl = s.albumCoverUrl, + ) + } SongPlaylistScreen( modifier = modifier, title = "추모 플레이리스트", onBackClick = onBackClick, songs = songs, - managementContent = SongPlaylistScreenManagementContent( - leadingContent = { selectedIds -> - MemorialPlaylistListHeader( - songCount = songs.size, - isSelectionMode = selectedIds.isNotEmpty(), - onAddSongClick = onNavigateToAddSongScreen - ) - }, - selectionBottomBar = { selectedIds, onClearSelection -> - MemorialPlaylistActionBar( - onDeleteAllClick = { - playlistStateHolder.clearAllSongs() - onClearSelection() - }, - onDeleteSelectedClick = { - playlistStateHolder.removeSongs(selectedIds) - onClearSelection() - } - ) - } - ), + managementContent = + SongPlaylistScreenManagementContent( + leadingContent = { selectedIds -> + MemorialPlaylistListHeader( + songCount = songs.size, + isSelectionMode = selectedIds.isNotEmpty(), + onAddSongClick = onNavigateToAddSongScreen, + ) + }, + selectionBottomBar = { selectedIds, onClearSelection -> + MemorialPlaylistActionBar( + onDeleteAllClick = { + playlistStateHolder.clearAllSongs() + onClearSelection() + }, + onDeleteSelectedClick = { + playlistStateHolder.removeSongs(selectedIds) + onClearSelection() + }, + ) + }, + ), defaultBottomNavItem = BottomNavItem.AFTERNOTE, - initialSelectedSongIds = initialSelectedSongIds + initialSelectedSongIds = initialSelectedSongIds, ) } @@ -103,24 +106,25 @@ private fun MemorialPlaylistListHeader( modifier: Modifier = Modifier, songCount: Int, isSelectionMode: Boolean, - onAddSongClick: () -> Unit + onAddSongClick: () -> Unit, ) { Row( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { if (isSelectionMode) { Column { Spacer(modifier = Modifier.height(24.dp)) Text( text = "현재 플레이리스트", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(16.dp)) } @@ -128,13 +132,14 @@ private fun MemorialPlaylistListHeader( Spacer(modifier = Modifier.height(25.dp)) Text( text = "총 ${songCount}곡", - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9 - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(17.dp)) } @@ -143,25 +148,27 @@ private fun MemorialPlaylistListHeader( Spacer(modifier = Modifier.height(25.dp)) Text( text = "총 ${songCount}곡", - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Color(color = 0xFF000000) - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Color(color = 0xFF000000), + ), ) Spacer(modifier = Modifier.height(16.dp)) } Column { Spacer(modifier = Modifier.height(16.dp)) Row( - modifier = Modifier - .background( - color = B3, - shape = RoundedCornerShape(20.dp) - ).clickable(onClick = onAddSongClick), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .background( + color = B3, + shape = RoundedCornerShape(20.dp), + ).clickable(onClick = onAddSongClick), + verticalAlignment = Alignment.CenterVertically, ) { Spacer(modifier = Modifier.width(16.dp)) Column { @@ -169,22 +176,24 @@ private fun MemorialPlaylistListHeader( Row { Text( text = "노래 추가하기", - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Spacer(modifier = Modifier.width(4.dp)) RightArrowIcon( - iconSpec = ArrowIconSpec( - iconRes = R.drawable.ic_arrow_right_playlist, - contentDescription = "추가" - ), + iconSpec = + ArrowIconSpec( + iconRes = R.drawable.ic_arrow_right_playlist, + contentDescription = "추가", + ), backgroundColor = B1, - size = 12.dp + size = 12.dp, ) } Spacer(modifier = Modifier.height(8.dp)) @@ -201,65 +210,71 @@ private fun MemorialPlaylistListHeader( private fun MemorialPlaylistActionBar( modifier: Modifier = Modifier, onDeleteAllClick: () -> Unit, - onDeleteSelectedClick: () -> Unit + onDeleteSelectedClick: () -> Unit, ) { val actionBarShape = RoundedCornerShape(8.dp) Row( - modifier = modifier - .fillMaxWidth() - .shadow( - elevation = 5.dp, - shape = actionBarShape, - clip = false, - ambientColor = Color(0x26000000), - spotColor = Color(0x26000000) - ).background(color = Color.White, shape = actionBarShape) - .clip(actionBarShape), + modifier = + modifier + .fillMaxWidth() + .shadow( + elevation = 5.dp, + shape = actionBarShape, + clip = false, + ambientColor = Color(0x26000000), + spotColor = Color(0x26000000), + ).background(color = Color.White, shape = actionBarShape) + .clip(actionBarShape), horizontalArrangement = Arrangement.spacedBy(0.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Column( - modifier = Modifier - .weight(1f) - .clickable(onClick = onDeleteAllClick) - .padding(vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .weight(1f) + .clickable(onClick = onDeleteAllClick) + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "전체 삭제", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9, - textAlign = TextAlign.Center - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + textAlign = TextAlign.Center, + ), ) } Box( - modifier = Modifier - .width(1.dp) - .height(20.dp) - .background(Color(0xFFE0E0E0)) + modifier = + Modifier + .width(1.dp) + .height(20.dp) + .background(Color(0xFFE0E0E0)), ) Column( - modifier = Modifier - .weight(1f) - .clickable(onClick = onDeleteSelectedClick) - .padding(vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .weight(1f) + .clickable(onClick = onDeleteSelectedClick) + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "선택 삭제", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9, - textAlign = TextAlign.Center - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + textAlign = TextAlign.Center, + ), ) } } @@ -270,16 +285,17 @@ private fun MemorialPlaylistActionBar( private fun MemorialPlaylistRouteScreenPreview() { AfternoteLightTheme { CompositionLocalProvider( - DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider() + DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider(), ) { val provider = DataProviderLocals.LocalAfternoteEditDataProvider.current - val holder = MemorialPlaylistStateHolder().apply { - initializeSongs(provider.getSongs().take(3)) - } + val holder = + MemorialPlaylistStateHolder().apply { + initializeSongs(provider.getSongs().take(3)) + } MemorialPlaylistRouteScreen( playlistStateHolder = holder, onBackClick = {}, - onNavigateToAddSongScreen = {} + onNavigateToAddSongScreen = {}, ) } } @@ -290,17 +306,18 @@ private fun MemorialPlaylistRouteScreenPreview() { private fun MemorialPlaylistRouteScreenSelectionModePreview() { AfternoteLightTheme { CompositionLocalProvider( - DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider() + DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider(), ) { val provider = DataProviderLocals.LocalAfternoteEditDataProvider.current - val holder = MemorialPlaylistStateHolder().apply { - initializeSongs(provider.getSongs().take(4)) - } + val holder = + MemorialPlaylistStateHolder().apply { + initializeSongs(provider.getSongs().take(4)) + } MemorialPlaylistRouteScreen( playlistStateHolder = holder, onBackClick = {}, onNavigateToAddSongScreen = {}, - initialSelectedSongIds = setOf("1", "3") + initialSelectedSongIds = setOf("1", "3"), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/PlaceholderDrawables.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/PlaceholderDrawables.kt new file mode 100644 index 00000000..cfa12633 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/PlaceholderDrawables.kt @@ -0,0 +1,30 @@ +package com.kuit.afternote.feature.afternote.presentation + +import com.kuit.afternote.R + +/** + * 더미/개발용 플레이리스트 앨범 커버 placeholder 이미지 (img_placeholder_1 ~ 12) 순환 반환. + * 실제 앨범 아트 연동 전까지 사용. + */ +object PlaceholderDrawables { + private val LIST = + listOf( + R.drawable.img_placeholder_1, + R.drawable.img_placeholder_2, + R.drawable.img_placeholder_3, + R.drawable.img_placeholder_4, + R.drawable.img_placeholder_5, + R.drawable.img_placeholder_6, + R.drawable.img_placeholder_7, + R.drawable.img_placeholder_8, + R.drawable.img_placeholder_9, + R.drawable.img_placeholder_10, + R.drawable.img_placeholder_11, + R.drawable.img_placeholder_12, + ) + + /** + * 0-based index에 맞는 placeholder drawable id. + */ + fun forZeroBasedIndex(index: Int): Int = LIST[index % LIST.size] +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/ProcessingMethodManager.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/ProcessingMethodManager.kt new file mode 100644 index 00000000..9f83ea97 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/ProcessingMethodManager.kt @@ -0,0 +1,77 @@ +package com.kuit.afternote.feature.afternote.presentation + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem + +/** + * Holds processing method list state and actions for the afternote edit screen. + * Extracted from [com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditState] to keep function count under the detekt threshold. + */ +@Stable +class ProcessingMethodManager { + var processingMethods by mutableStateOf>( + emptyList(), + ) + private set + var galleryProcessingMethods by mutableStateOf>( + emptyList(), + ) + private set + + fun replaceProcessingMethods(list: List) { + processingMethods = list + } + + fun replaceGalleryProcessingMethods(list: List) { + galleryProcessingMethods = list + } + + fun editProcessingMethod( + itemId: String, + newText: String, + ) { + processingMethods = + processingMethods.map { item -> + if (item.id == itemId) item.copy(text = newText) else item + } + } + + fun deleteProcessingMethod(itemId: String) { + processingMethods = processingMethods.filter { it.id != itemId } + } + + fun addProcessingMethod(text: String) { + val newItem = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + id = (processingMethods.size + 1).toString(), + text = text, + ) + processingMethods = processingMethods + newItem + } + + fun editGalleryProcessingMethod( + itemId: String, + newText: String, + ) { + galleryProcessingMethods = + galleryProcessingMethods.map { item -> + if (item.id == itemId) item.copy(text = newText) else item + } + } + + fun deleteGalleryProcessingMethod(itemId: String) { + galleryProcessingMethods = galleryProcessingMethods.filter { it.id != itemId } + } + + fun addGalleryProcessingMethod(text: String) { + val newItem = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + id = (galleryProcessingMethods.size + 1).toString(), + text = text, + ) + galleryProcessingMethods = galleryProcessingMethods + newItem + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/RegisterAfternotePayload.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/RegisterAfternotePayload.kt similarity index 89% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/RegisterAfternotePayload.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/RegisterAfternotePayload.kt index cc5280b5..dfc3444b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/RegisterAfternotePayload.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/RegisterAfternotePayload.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation import com.kuit.afternote.feature.afternote.domain.model.AfternoteProcessingMethod @@ -18,5 +18,5 @@ data class RegisterAfternotePayload( val informationProcessingMethod: String = "", val processingMethods: List = emptyList(), val galleryProcessingMethods: List = emptyList(), - val atmosphere: String = "" + val atmosphere: String = "", ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongScreen.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongScreen.kt similarity index 64% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongScreen.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongScreen.kt index 78440f19..f007f3b8 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongScreen.kt @@ -1,18 +1,18 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.addsong import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kuit.afternote.core.ui.component.list.SongPlaylistScreen import com.kuit.afternote.core.ui.component.list.SongPlaylistScreenSelectableOptions import com.kuit.afternote.core.uimodel.PlaylistSongDisplay -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Song +import com.kuit.afternote.feature.afternote.presentation.edit.model.Song import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow /** * 노래 추가 화면의 콜백 @@ -20,7 +20,7 @@ import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLight @Immutable data class AddSongCallbacks( val onBackClick: () -> Unit, - val onSongsAdded: (List) -> Unit + val onSongsAdded: (List) -> Unit, ) /** @@ -31,7 +31,7 @@ data class AddSongCallbacks( fun AddSongScreen( modifier: Modifier = Modifier, viewModel: AddSongViewModelContract, - callbacks: AddSongCallbacks + callbacks: AddSongCallbacks, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() SongPlaylistScreen( @@ -42,19 +42,20 @@ fun AddSongScreen( onSongsSelected = { selected -> callbacks.onSongsAdded( selected.map { - Song( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( id = it.id, title = it.title, artist = it.artist, - albumCoverUrl = it.albumImageUrl + albumCoverUrl = it.albumImageUrl, ) - } + }, ) }, - options = SongPlaylistScreenSelectableOptions( - searchQuery = uiState.searchQuery, - onSearchQueryChange = viewModel::onSearchQueryChange - ) + options = + SongPlaylistScreenSelectableOptions( + searchQuery = uiState.searchQuery, + onSearchQueryChange = viewModel::onSearchQueryChange, + ), ) } @@ -70,7 +71,7 @@ fun AddSongScreen( modifier: Modifier = Modifier, songs: List, callbacks: AddSongCallbacks, - initialSelectedSongIds: Set? = null + initialSelectedSongIds: Set? = null, ) { SongPlaylistScreen( modifier = modifier, @@ -80,16 +81,16 @@ fun AddSongScreen( onSongsSelected = { selected -> callbacks.onSongsAdded( selected.map { - Song( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.Song( id = it.id, title = it.title, artist = it.artist, - albumCoverUrl = it.albumImageUrl + albumCoverUrl = it.albumImageUrl, ) - } + }, ) }, - options = SongPlaylistScreenSelectableOptions(initialSelectedSongIds = initialSelectedSongIds) + options = SongPlaylistScreenSelectableOptions(initialSelectedSongIds = initialSelectedSongIds), ) } @@ -98,13 +99,15 @@ fun AddSongScreen( private fun AddSongScreenPreview() { AfternoteLightTheme { AddSongScreen( - songs = (1..9).map { i -> - PlaylistSongDisplay(id = "s$i", title = "노래 제목 $i", artist = "가수 이름") - }, - callbacks = AddSongCallbacks( - onBackClick = {}, - onSongsAdded = {} - ) + songs = + (1..9).map { i -> + PlaylistSongDisplay(id = "s$i", title = "노래 제목 $i", artist = "가수 이름") + }, + callbacks = + AddSongCallbacks( + onBackClick = {}, + onSongsAdded = {}, + ), ) } } @@ -114,14 +117,16 @@ private fun AddSongScreenPreview() { private fun AddSongScreenAddButtonPreview() { AfternoteLightTheme { AddSongScreen( - songs = (1..9).map { i -> - PlaylistSongDisplay(id = "s$i", title = "노래 제목 $i", artist = "가수 이름") - }, - callbacks = AddSongCallbacks( - onBackClick = {}, - onSongsAdded = {} - ), - initialSelectedSongIds = setOf("s1", "s3") + songs = + (1..9).map { i -> + PlaylistSongDisplay(id = "s$i", title = "노래 제목 $i", artist = "가수 이름") + }, + callbacks = + AddSongCallbacks( + onBackClick = {}, + onSongsAdded = {}, + ), + initialSelectedSongIds = setOf("s1", "s3"), ) } } @@ -133,15 +138,16 @@ private class FakeAddSongViewModel : AddSongViewModelContract { override val uiState = MutableStateFlow( AddSongUiState( - songs = (1..5).map { i -> - PlaylistSongDisplay( - id = "f$i", - title = "노래 $i", - artist = "가수" - ) - }, - searchQuery = "아이유" - ) + songs = + (1..5).map { i -> + PlaylistSongDisplay( + id = "f$i", + title = "노래 $i", + artist = "가수", + ) + }, + searchQuery = "아이유", + ), ).asStateFlow() override fun onSearchQueryChange(query: String) { @@ -155,7 +161,7 @@ private fun AddSongScreenWithViewModelPreview() { AfternoteLightTheme { AddSongScreen( viewModel = FakeAddSongViewModel(), - callbacks = AddSongCallbacks(onBackClick = {}, onSongsAdded = {}) + callbacks = AddSongCallbacks(onBackClick = {}, onSongsAdded = {}), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongViewModel.kt new file mode 100644 index 00000000..b6e5657b --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongViewModel.kt @@ -0,0 +1,58 @@ +package com.kuit.afternote.feature.afternote.presentation.addsong + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kuit.afternote.feature.afternote.domain.usecase.SearchMusicUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SEARCH_DEBOUNCE_MS = 300L + +@HiltViewModel +class AddSongViewModel + @Inject + constructor( + private val searchMusicUseCase: SearchMusicUseCase, + ) : ViewModel(), + AddSongViewModelContract { + private val _uiState = MutableStateFlow(AddSongUiState()) + override val uiState: StateFlow = _uiState.asStateFlow() + + private var searchJob: Job? = null + + override fun onSearchQueryChange(query: String) { + _uiState.update { it.copy(searchQuery = query, errorMessage = null) } + searchJob?.cancel() + searchJob = + viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + val trimmed = query.trim() + if (trimmed.isEmpty()) { + _uiState.update { it.copy(songs = emptyList(), isLoading = false) } + return@launch + } + _uiState.update { it.copy(isLoading = true) } + searchMusicUseCase(trimmed) + .onSuccess { list -> + _uiState.update { + it.copy(songs = list, isLoading = false, errorMessage = null) + } + }.onFailure { e -> + _uiState.update { + it.copy( + songs = emptyList(), + isLoading = false, + errorMessage = e.message ?: "검색 실패", + ) + } + } + } + } + } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongViewModelContract.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongViewModelContract.kt similarity index 84% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongViewModelContract.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongViewModelContract.kt index d3a2a83a..c0b80e39 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongViewModelContract.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/addsong/AddSongViewModelContract.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.addsong import com.kuit.afternote.core.uimodel.PlaylistSongDisplay import kotlinx.coroutines.flow.StateFlow @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.StateFlow * Contract for Add Song screen ViewModel (production uses [AddSongViewModel], Preview uses Fake). */ interface AddSongViewModelContract { - val uiState: StateFlow fun onSearchQueryChange(query: String) @@ -20,5 +19,5 @@ data class AddSongUiState( val songs: List = emptyList(), val searchQuery: String = "", val isLoading: Boolean = false, - val errorMessage: String? = null + val errorMessage: String? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/common/util/PlaceholderDrawables.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/common/util/PlaceholderDrawables.kt deleted file mode 100644 index 1a888f58..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/common/util/PlaceholderDrawables.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.kuit.afternote.feature.afternote.presentation.common.util - -import com.kuit.afternote.R - -/** - * 더미/개발용 플레이리스트 앨범 커버 placeholder 이미지 (img_placeholder_1 ~ 12) 순환 반환. - * 실제 앨범 아트 연동 전까지 사용. - */ -object PlaceholderDrawables { - private val LIST = listOf( - R.drawable.img_placeholder_1, - R.drawable.img_placeholder_2, - R.drawable.img_placeholder_3, - R.drawable.img_placeholder_4, - R.drawable.img_placeholder_5, - R.drawable.img_placeholder_6, - R.drawable.img_placeholder_7, - R.drawable.img_placeholder_8, - R.drawable.img_placeholder_9, - R.drawable.img_placeholder_10, - R.drawable.img_placeholder_11, - R.drawable.img_placeholder_12 - ) - - /** - * 0-based index에 맞는 placeholder drawable id. - */ - fun forZeroBasedIndex(index: Int): Int = LIST[index % LIST.size] -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/.gitkeep b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContent.kt deleted file mode 100644 index c3aea871..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContent.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.content - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.kuit.afternote.core.ui.component.LastWishOption -import com.kuit.afternote.core.ui.component.LastWishOtherState -import com.kuit.afternote.core.ui.component.LastWishesRadioGroup -import com.kuit.afternote.core.ui.component.content.MemorialGuidelineContent -import com.kuit.afternote.core.ui.component.content.MemorialGuidelineSlots -import com.kuit.afternote.core.ui.component.list.MemorialPlaylist -import com.kuit.afternote.feature.afternote.presentation.component.edit.memorial.LastMomentQuestion -import com.kuit.afternote.feature.afternote.presentation.component.edit.upload.FuneralVideoUpload -import com.kuit.afternote.feature.afternote.presentation.component.edit.upload.MemorialPhotoUpload -import com.kuit.afternote.ui.theme.AfternoteTheme - -/** - * 추모 가이드라인 종류 선택 시 표시되는 콘텐츠 (편집 모드). - * [MemorialGuidelineContent] 공통 레이아웃을 사용하고, 슬롯에 편집용 컴포넌트를 채움. - */ -@Composable -fun MemorialGuidelineEditContent( - modifier: Modifier = Modifier, - bottomPadding: PaddingValues, - params: MemorialGuidelineEditContentParams -) { - val density = LocalDensity.current - val windowInfo = LocalWindowInfo.current - val bottomPaddingDp = bottomPadding.calculateBottomPadding() - val viewportHeight = with(density) { - windowInfo.containerSize.height.toDp() - bottomPaddingDp - } - val trailingSpacerHeight = viewportHeight * 0.1f - - MemorialGuidelineContent( - modifier = modifier, - slots = MemorialGuidelineSlots( - introContent = { LastMomentQuestion() }, - photoContent = { - MemorialPhotoUpload( - displayImageUri = params.displayMemorialPhotoUri, - onAddPhotoClick = params.onPhotoAddClick - ) - }, - playlistContent = { - MemorialPlaylist( - songCount = params.playlistSongCount, - albumCovers = params.playlistAlbumCovers, - onAddSongClick = params.onSongAddClick - ) - }, - lastWishContent = { - LastWishesRadioGroup( - options = params.lastWishOptions, - selectedValue = params.selectedLastWish, - onOptionSelected = params.onLastWishSelected, - otherState = LastWishOtherState( - text = params.customLastWishText, - onTextChange = params.onCustomLastWishChanged - ) - ) - }, - recipientContent = { - params.recipientSection?.let { RecipientDesignationSection(section = it) } - }, - videoContent = { - FuneralVideoUpload( - videoUrl = params.funeralVideoUrl, - thumbnailUrl = params.funeralThumbnailUrl, - onAddVideoClick = params.onVideoAddClick, - onThumbnailBytesReady = params.onThumbnailBytesReady - ) - } - ), - sectionSpacing = 32.dp, - trailingSpacerHeight = trailingSpacerHeight - ) -} - -@Preview(showBackground = true) -@Composable -private fun MemorialGuidelineEditContentPreview() { - AfternoteTheme { - val lastWishOptions = listOf( - LastWishOption( - text = "차분하고 조용하게 보내주세요.", - value = "calm" - ), - LastWishOption( - text = "슬퍼 하지 말고 밝고 따뜻하게 보내주세요.", - value = "bright" - ), - LastWishOption( - text = "기타(직접 입력)", - value = "other" - ) - ) - - MemorialGuidelineEditContent( - bottomPadding = PaddingValues(bottom = 88.dp), - params = MemorialGuidelineEditContentParams( - displayMemorialPhotoUri = null, - playlistSongCount = 16, - playlistAlbumCovers = emptyList(), - selectedLastWish = "calm", - lastWishOptions = lastWishOptions, - funeralVideoUrl = null, - customLastWishText = "", - onSongAddClick = {}, - onLastWishSelected = {}, - onCustomLastWishChanged = {}, - onPhotoAddClick = {}, - onVideoAddClick = {}, - onThumbnailBytesReady = {} - ) - ) - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodList.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodList.kt deleted file mode 100644 index 8e54c3af..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodList.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -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.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.kuit.afternote.core.ui.component.button.AddCircleButton -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem -import com.kuit.afternote.ui.theme.AfternoteTheme -import com.kuit.afternote.ui.theme.White -import kotlinx.coroutines.launch - -/** - * 처리 방법 리스트 컴포넌트 - */ -@Composable -fun ProcessingMethodList( - modifier: Modifier = Modifier, - params: ProcessingMethodListParams, - state: ProcessingMethodListState = rememberProcessingMethodListState( - initialShowTextField = params.initialShowTextField - ) -) { - val items = params.items - val onItemAdded = params.onItemAdded - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - - // 초기화: 아이템들의 expanded 상태 설정 - LaunchedEffect(items, params.initialExpandedItemId) { - state.initializeExpandedStates(items, params.initialExpandedItemId) - } - - Column( - modifier = modifier - .fillMaxWidth() - .background(color = White, shape = RoundedCornerShape(16.dp)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - items.forEach { item -> - ProcessingMethodCheckbox( - item = item, - expanded = state.expandedStates[item.id] ?: false, - isEditing = state.editingItemId == item.id, - callbacks = ProcessingMethodCheckboxCallbacks( - onMoreClick = { - focusManager.clearFocus() - state.toggleItemExpanded(item.id) - }, - onDismissDropdown = { - state.expandedStates[item.id] = false - }, - onEditClick = { - state.expandedStates[item.id] = false - // Defer editing to next frame so DropdownMenu dismiss settles first - scope.launch { - withFrameNanos { } - state.startEditing(item.id) - } - }, - onDeleteClick = { params.onItemDeleteClick(item.id) }, - onEditConfirmed = { newText -> - params.onItemEdited(item.id, newText) - state.stopEditing() - } - ) - ) - Spacer(modifier = Modifier.height(6.dp)) - } - - // 텍스트 필드 (버튼 클릭 시 표시) - AddItemTextField( - visible = state.showTextField, - onItemAdded = onItemAdded, - onVisibilityChanged = { isVisible -> - params.onTextFieldVisibilityChanged(isVisible) - }, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // 추가 버튼 (텍스트 필드 표시만 토글; 부모 알림 없음) - AddCircleButton( - contentDescription = "추가", - onClick = { state.toggleTextField() } - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun ProcessingMethodListPreview() { - AfternoteTheme { - ProcessingMethodList( - params = ProcessingMethodListParams( - items = listOf( - ProcessingMethodItem("1", "게시물 내리기"), - ProcessingMethodItem("2", "댓글 비활성화") - ), - onItemDeleteClick = {}, - onItemAdded = {}, - onTextFieldVisibilityChanged = {}, - initialShowTextField = true - ) - ) - } -} - -@Preview(showBackground = true, name = "드롭다운 펼쳐진 상태") -@Composable -private fun ProcessingMethodListWithDropdownPreview() { - AfternoteTheme { - ProcessingMethodList( - params = ProcessingMethodListParams( - items = listOf( - ProcessingMethodItem("1", "게시물 내리기"), - ProcessingMethodItem("2", "댓글 비활성화"), - ProcessingMethodItem("3", "추모 계정으로 전환하기") - ), - onItemDeleteClick = {}, - onItemAdded = {}, - onTextFieldVisibilityChanged = {}, - initialShowTextField = false, - initialExpandedItemId = "1" - ) - ) - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailUiState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt similarity index 77% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailUiState.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt index d4602c24..bfbc4805 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.detail import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail @@ -11,5 +11,5 @@ data class AfternoteDetailUiState( val error: String? = null, val isDeleting: Boolean = false, val deleteSuccess: Boolean = false, - val deleteError: String? = null + val deleteError: String? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt similarity index 89% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailViewModel.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt index fb5baa18..a53901b7 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -23,9 +23,8 @@ class AfternoteDetailViewModel @Inject constructor( private val getDetailUseCase: GetAfternoteDetailUseCase, - private val deleteUseCase: DeleteAfternoteUseCase + private val deleteUseCase: DeleteAfternoteUseCase, ) : ViewModel() { - private val _uiState = MutableStateFlow(AfternoteDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -37,12 +36,11 @@ class AfternoteDetailViewModel _uiState.update { it.copy(isLoading = false, detail = detail, error = null) } - } - .onFailure { e -> + }.onFailure { e -> _uiState.update { it.copy( isLoading = false, - error = e.message ?: "상세 정보를 불러오지 못했습니다." + error = e.message ?: "상세 정보를 불러오지 못했습니다.", ) } } @@ -57,12 +55,11 @@ class AfternoteDetailViewModel _uiState.update { it.copy(isDeleting = false, deleteSuccess = true) } - } - .onFailure { e -> + }.onFailure { e -> _uiState.update { it.copy( isDeleting = false, - deleteError = e.message ?: "삭제에 실패했습니다." + deleteError = e.message ?: "삭제에 실패했습니다.", ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditScreen.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditScreen.kt new file mode 100644 index 00000000..bb57c70e --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditScreen.kt @@ -0,0 +1,578 @@ +package com.kuit.afternote.feature.afternote.presentation.edit + +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kuit.afternote.app.compositionlocal.DataProviderLocals +import com.kuit.afternote.core.ui.component.list.AlbumCover +import com.kuit.afternote.core.ui.component.navigation.BottomNavItem +import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar +import com.kuit.afternote.core.ui.component.navigation.TopBar +import com.kuit.afternote.domain.provider.FakeAfternoteEditDataProvider +import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem +import com.kuit.afternote.feature.afternote.domain.model.AfternoteProcessingMethod +import com.kuit.afternote.feature.afternote.presentation.AfternoteItemMapper +import com.kuit.afternote.feature.afternote.presentation.RegisterAfternotePayload +import com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialog +import com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialogCallbacks +import com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialogParams +import com.kuit.afternote.feature.afternote.presentation.edit.content.GalleryAndFileEditContent +import com.kuit.afternote.feature.afternote.presentation.edit.content.GalleryAndFileEditContentParams +import com.kuit.afternote.feature.afternote.presentation.edit.content.MemorialGuidelineEditContent +import com.kuit.afternote.feature.afternote.presentation.edit.content.MemorialGuidelineEditContentParams +import com.kuit.afternote.feature.afternote.presentation.edit.content.SocialNetworkEditContent +import com.kuit.afternote.feature.afternote.presentation.edit.content.SocialNetworkEditContentParams +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.DropdownMenuStyle +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdown +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdownLabelParams +import com.kuit.afternote.feature.afternote.presentation.edit.model.AccountSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverCallbacks +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodSection +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.CustomServiceDialog +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.CustomServiceDialogCallbacks +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.CustomServiceDialogParams +import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme +import com.kuit.afternote.ui.expand.addFocusCleaner +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private const val TAG = "AfternoteEditScreen" +private const val CATEGORY_GALLERY_AND_FILE = "갤러리 및 파일" +private const val CATEGORY_MEMORIAL_GUIDELINE = "추모 가이드라인" + +/** + * 콜백 그룹 (S107: 파라미터 7개 이하 유지). + */ +data class AfternoteEditScreenCallbacks( + val onBackClick: () -> Unit = {}, + val onRegisterClick: (RegisterAfternotePayload) -> Unit = {}, + val onNavigateToAddSong: () -> Unit = {}, + val onNavigateToSelectReceiver: () -> Unit = {}, + val onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, + val onThumbnailBytesReady: (ByteArray?) -> Unit = {}, +) + +/** + * Message to show when save fails (validation or API error). + * When non-null, the screen shows a Snackbar with this text. + */ +data class AfternoteEditSaveError( + val message: String, +) + +/** + * 애프터노트 수정/작성 화면 + * + * 피그마 디자인 기반: + * - 헤더 (뒤로가기, 타이틀, 등록 버튼) + * - 종류 선택 드롭다운 + * - 서비스명 선택 드롭다운 + * - 계정 정보 입력 (아이디, 비밀번호) + * - 계정 처리 방법 선택 (라디오 버튼) + * - 처리 방법 리스트 (체크박스) + * - 남기실 말씀 (멀티라인 텍스트 필드) + */ +@Composable +fun AfternoteEditScreen( + modifier: Modifier = Modifier, + callbacks: AfternoteEditScreenCallbacks = AfternoteEditScreenCallbacks(), + state: AfternoteEditState = rememberAfternoteEditState(), + playlistStateHolder: MemorialPlaylistStateHolder? = null, + initialItem: AfternoteItem? = null, + saveError: AfternoteEditSaveError? = null, +) { + val focusManager = LocalFocusManager.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(saveError) { + saveError?.let { err -> + snackbarHostState.showSnackbar( + message = err.message, + withDismissAction = true, + ) + } + } + + LaunchedEffect(initialItem?.id) { + val item = + initialItem ?: run { + Log.d(TAG, "LaunchedEffect: initialItem is null, skipping loadFromExisting") + return@LaunchedEffect + } + Log.d( + TAG, + "LaunchedEffect: item.id=${item.id}, state.loadedItemId=${state.loadedItemId}, " + + "needsLoad=${state.loadedItemId != item.id}", + ) + if (state.loadedItemId != item.id) { + state.loadFromExisting( + LoadFromExistingParams( + itemId = item.id, + serviceName = item.serviceName, + categoryDisplayString = AfternoteItemMapper.categoryStringForEditScreen(item.type), + account = + LoadFromExistingAccountParams( + id = item.account.id, + password = item.account.password, + ), + processing = + LoadFromExistingProcessingParams( + message = item.processing.message, + accountMethodName = item.processing.accountMethod, + informationMethodName = item.processing.informationMethod, + methods = + item.processing.methods.map { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + it.id, + it.text, + ) + }, + galleryMethods = + item.processing.galleryMethods.map { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem( + it.id, + it.text, + ) + }, + ), + ), + ) + } + } + + // 플레이리스트 상태 홀더가 전달되면 설정 + LaunchedEffect(playlistStateHolder) { + playlistStateHolder?.let { state.setPlaylistStateHolder(it) } + } + + // 플레이리스트 노래 개수 변경 감지 및 동기화 + val songCount by remember { + playlistStateHolder?.songs?.let { + derivedStateOf { it.size } + } ?: derivedStateOf { state.playlistSongCount } + } + + LaunchedEffect(songCount) { + if (playlistStateHolder != null) { + state.updatePlaylistSongCount() + } + } + + val memorialPhotoPickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + state.onMemorialPhotoSelected(uri) + } + val memorialVideoPickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + state.onFuneralVideoSelected(uri) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopBar( + title = "애프터노트 작성하기", + onBackClick = callbacks.onBackClick, + onActionClick = { + val dateFormat = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) + val date = dateFormat.format(Date()) + val processingMethods = + state.processingMethods.map { + AfternoteProcessingMethod(it.id, it.text) + } + val galleryProcessingMethods = + state.galleryProcessingMethods.map { + AfternoteProcessingMethod(it.id, it.text) + } + callbacks.onRegisterClick( + RegisterAfternotePayload( + serviceName = + if (state.selectedCategory == CATEGORY_MEMORIAL_GUIDELINE) { + CATEGORY_MEMORIAL_GUIDELINE + } else { + state.selectedService + }, + date = date, + accountId = state.idState.text.toString(), + password = state.passwordState.text.toString(), + message = state.messageState.text.toString(), + accountProcessingMethod = state.selectedProcessingMethod.name, + informationProcessingMethod = state.selectedInformationProcessingMethod.name, + processingMethods = processingMethods, + galleryProcessingMethods = galleryProcessingMethods, + atmosphere = state.getAtmosphereForSave(), + ), + ) + }, + ) + }, + bottomBar = { + BottomNavigationBar( + selectedItem = state.selectedBottomNavItem, + onItemSelected = { item -> + state.onBottomNavItemSelected(item) + callbacks.onBottomNavTabSelected(item) + }, + ) + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .addFocusCleaner(focusManager), + ) { + EditContent( + state = state, + onNavigateToAddSong = callbacks.onNavigateToAddSong, + onNavigateToSelectReceiver = callbacks.onNavigateToSelectReceiver, + onPhotoAddClick = { + memorialPhotoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + onVideoAddClick = { + memorialVideoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly), + ) + }, + onThumbnailBytesReady = callbacks.onThumbnailBytesReady, + bottomPadding = paddingValues, + ) + + // Line 336 해결: 조건부 렌더링을 nullable로 변경 + state.activeDialog?.let { dialogType -> + when (dialogType) { + DialogType.ADD_AFTERNOTE_EDIT_RECEIVER -> { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver + .AddAfternoteEditReceiverDialog( + params = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver + .AddAfternoteEditReceiverDialogParams( + afternoteEditReceiverNameState = state.afternoteEditReceiverNameState, + phoneNumberState = state.phoneNumberState, + relationshipSelectedValue = state.relationshipSelectedValue, + relationshipOptions = state.relationshipOptions, + callbacks = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver + .AddAfternoteEditReceiverDialogCallbacks( + onDismiss = state::dismissDialog, + onAddClick = state::onAddAfternoteEditReceiver, + onRelationshipSelected = state::onRelationshipSelected, + onImportContactsClick = { + // 연락처 가져오기 기능은 추후 구현 예정 + }, + ), + ), + ) + } + + DialogType.CUSTOM_SERVICE -> { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.CustomServiceDialog( + params = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.processingmethod + .CustomServiceDialogParams( + serviceNameState = state.customServiceNameState, + callbacks = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.processingmethod + .CustomServiceDialogCallbacks( + onDismiss = state::dismissDialog, + onAddClick = state::onAddCustomService, + ), + ), + ) + } + } + } + } + } +} + +@Composable +private fun EditContent( + state: AfternoteEditState, + onNavigateToAddSong: () -> Unit, + onNavigateToSelectReceiver: () -> Unit, + onPhotoAddClick: () -> Unit, + onVideoAddClick: () -> Unit, + onThumbnailBytesReady: (ByteArray?) -> Unit, + bottomPadding: PaddingValues, +) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // 메인 콘텐츠 (스크롤 가능, 남은 공간 차지) + Column( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Spacer(modifier = Modifier.height(24.dp)) + + // 종류 선택 (Line 279 해결: State의 메서드 사용) + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdown( + labelParams = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdownLabelParams( + label = "종류", + ), + selectedValue = state.selectedCategory, + options = state.categories, + onValueSelected = state::onCategorySelected, + menuStyle = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.DropdownMenuStyle( + shadowElevation = 10.dp, + tonalElevation = 10.dp, + ), + state = state.categoryDropdownState, + ) + + // 서비스명 선택 (추모 가이드라인 선택 시 숨김) + if (state.selectedCategory != CATEGORY_MEMORIAL_GUIDELINE) { + Spacer(modifier = Modifier.height(16.dp)) + + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdown( + labelParams = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdownLabelParams( + label = "서비스명", + ), + selectedValue = state.selectedService, + options = state.currentServiceOptions, + onValueSelected = state::onServiceSelected, + menuStyle = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.DropdownMenuStyle( + shadowElevation = 10.dp, + tonalElevation = 10.dp, + ), + state = state.serviceDropdownState, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + + // 종류에 따라 다른 콘텐츠 표시 + // 각 Content 컴포넌트 내부에서 카테고리별 하단 여백 처리 + CategoryContent( + state = state, + onNavigateToAddSong = onNavigateToAddSong, + onNavigateToSelectReceiver = onNavigateToSelectReceiver, + onPhotoAddClick = onPhotoAddClick, + onVideoAddClick = onVideoAddClick, + onThumbnailBytesReady = onThumbnailBytesReady, + bottomPadding = bottomPadding, + ) + } + } +} + +@Composable +private fun CategoryContent( + state: AfternoteEditState, + onNavigateToAddSong: () -> Unit, + onNavigateToSelectReceiver: () -> Unit, + onPhotoAddClick: () -> Unit, + onVideoAddClick: () -> Unit, + onThumbnailBytesReady: (ByteArray?) -> Unit, + bottomPadding: PaddingValues, +) { + when (state.selectedCategory) { + CATEGORY_MEMORIAL_GUIDELINE -> { + val albumCoversFromPlaylist = + state.playlistStateHolder?.songs?.let { songs -> + songs.mapIndexed { _, s -> + AlbumCover( + id = s.id, + imageUrl = s.albumCoverUrl, + title = s.title, + ) + } + } ?: state.playlistAlbumCovers + val livePlaylistSongCount = + state.playlistStateHolder?.songs?.size ?: state.playlistSongCount + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.content.MemorialGuidelineEditContent( + bottomPadding = bottomPadding, + params = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.content.MemorialGuidelineEditContentParams( + displayMemorialPhotoUri = state.pickedMemorialPhotoUri ?: state.memorialPhotoUrl, + playlistSongCount = livePlaylistSongCount, + playlistAlbumCovers = albumCoversFromPlaylist, + selectedLastWish = state.selectedLastWish, + lastWishOptions = state.lastWishOptions, + funeralVideoUrl = state.funeralVideoUrl, + funeralThumbnailUrl = state.funeralThumbnailUrl, + customLastWishText = state.customLastWishText, + recipientSection = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection( + afternoteEditReceivers = state.afternoteEditReceivers, + callbacks = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model + .AfternoteEditReceiverCallbacks( + onAddClick = onNavigateToSelectReceiver, + onItemDeleteClick = state::onAfternoteEditReceiverDelete, + onItemAdded = state::onAfternoteEditReceiverItemAdded, + ), + ), + onSongAddClick = onNavigateToAddSong, + onLastWishSelected = state::onLastWishSelected, + onCustomLastWishChanged = state::onCustomLastWishChanged, + onPhotoAddClick = onPhotoAddClick, + onVideoAddClick = onVideoAddClick, + onThumbnailBytesReady = onThumbnailBytesReady, + ), + ) + } + + CATEGORY_GALLERY_AND_FILE -> { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.content.GalleryAndFileEditContent( + bottomPadding = bottomPadding, + params = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.content.GalleryAndFileEditContentParams( + messageState = state.messageState, + recipientSection = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection( + afternoteEditReceivers = state.afternoteEditReceivers, + callbacks = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model + .AfternoteEditReceiverCallbacks( + onAddClick = onNavigateToSelectReceiver, + onItemDeleteClick = state::onAfternoteEditReceiverDelete, + onItemAdded = state::onAfternoteEditReceiverItemAdded, + ), + ), + processingMethodSection = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodSection( + items = state.galleryProcessingMethods, + callbacks = state.galleryProcessingCallbacks, + ), + ), + ) + } + + else -> { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.content.SocialNetworkEditContent( + bottomPadding = bottomPadding, + params = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.content.SocialNetworkEditContentParams( + messageState = state.messageState, + accountSection = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AccountSection( + idState = state.idState, + passwordState = state.passwordState, + selectedMethod = state.selectedProcessingMethod, + onMethodSelected = state::onProcessingMethodSelected, + ), + recipientSection = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection( + afternoteEditReceivers = state.afternoteEditReceivers, + callbacks = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model + .AfternoteEditReceiverCallbacks( + onAddClick = onNavigateToSelectReceiver, + onItemDeleteClick = state::onAfternoteEditReceiverDelete, + onItemAdded = state::onAfternoteEditReceiverItemAdded, + ), + ), + processingMethodSection = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodSection( + items = state.processingMethods, + callbacks = state.socialProcessingCallbacks, + ), + ), + ) + } + } +} + +@Preview( + showBackground = true, + device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", +) +@Composable +private fun AfternoteEditScreenPreview() { + AfternoteLightTheme { + CompositionLocalProvider( + DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider(), + ) { + AfternoteEditScreen( + callbacks = AfternoteEditScreenCallbacks(onBackClick = {}), + ) + } + } +} + +@Preview( + showBackground = true, + device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", + name = "갤러리 및 파일", +) +@Composable +private fun AfternoteEditScreenGalleryAndFilePreview() { + AfternoteLightTheme { + CompositionLocalProvider( + DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider(), + ) { + val state = + rememberAfternoteEditState().apply { + onCategorySelected(CATEGORY_GALLERY_AND_FILE) + } + AfternoteEditScreen( + callbacks = AfternoteEditScreenCallbacks(onBackClick = {}), + state = state, + ) + } + } +} + +@Preview( + showBackground = true, + device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", + name = "추모 가이드라인", +) +@Composable +private fun AfternoteEditScreenMemorialGuidelinePreview() { + AfternoteLightTheme { + CompositionLocalProvider( + DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider(), + ) { + val state = + rememberAfternoteEditState().apply { + onCategorySelected(CATEGORY_MEMORIAL_GUIDELINE) + } + AfternoteEditScreen( + callbacks = AfternoteEditScreenCallbacks(onBackClick = {}), + state = state, + ) + } + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditState.kt similarity index 68% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditState.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditState.kt index e114aa62..8c4fc400 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditState.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.edit import android.net.Uri import android.util.Log @@ -14,35 +14,29 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import com.kuit.afternote.app.compositionlocal.DataProviderLocals +import com.kuit.afternote.core.domain.model.AfternoteServiceCatalog import com.kuit.afternote.core.ui.component.LastWishOption import com.kuit.afternote.core.ui.component.list.AlbumCover import com.kuit.afternote.core.ui.component.navigation.BottomNavItem -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdownState -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountProcessingMethod -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverCallbacks -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.InformationProcessingMethod -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodCallbacks -import com.kuit.afternote.core.domain.model.AfternoteServiceCatalog -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Song +import com.kuit.afternote.feature.afternote.presentation.ProcessingMethodManager +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem /** * 추모 플레이리스트 상태 홀더 */ @Stable class MemorialPlaylistStateHolder { - val songs: SnapshotStateList = mutableStateListOf() + val songs: SnapshotStateList = mutableStateListOf() var onSongCountChanged: (() -> Unit)? = null - fun initializeSongs(initialSongs: List) { + fun initializeSongs(initialSongs: List) { if (songs.isEmpty()) { songs.addAll(initialSongs) } } - fun addSong(song: Song) { + fun addSong(song: com.kuit.afternote.feature.afternote.presentation.edit.model.Song) { songs.add(song) onSongCountChanged?.invoke() } @@ -78,7 +72,7 @@ private const val LAST_WISH_DEFAULT_BRIGHT = "슬퍼 하지 말고 밝고 따뜻 */ enum class DialogType { ADD_AFTERNOTE_EDIT_RECEIVER, - CUSTOM_SERVICE + CUSTOM_SERVICE, } /** @@ -97,8 +91,8 @@ class AfternoteEditState( val afternoteEditReceiverNameState: TextFieldState, val phoneNumberState: TextFieldState, val customServiceNameState: TextFieldState, - initialAfternoteEditReceivers: List, - albumCovers: List + initialAfternoteEditReceivers: List, + albumCovers: List, ) { // Navigation var selectedBottomNavItem by mutableStateOf(BottomNavItem.AFTERNOTE) @@ -111,9 +105,13 @@ class AfternoteEditState( private set // Processing Methods - var selectedProcessingMethod by mutableStateOf(AccountProcessingMethod.MEMORIAL_ACCOUNT) + var selectedProcessingMethod by mutableStateOf( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AccountProcessingMethod.MEMORIAL_ACCOUNT, + ) private set - var selectedInformationProcessingMethod by mutableStateOf(InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER) + var selectedInformationProcessingMethod by mutableStateOf( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER, + ) private set // AfternoteEditReceivers @@ -132,9 +130,9 @@ class AfternoteEditState( // Processing Method Lists (delegated to manager to keep function count under threshold) private val processingMethodManager = ProcessingMethodManager() - val processingMethods: List + val processingMethods: List get() = processingMethodManager.processingMethods - val galleryProcessingMethods: List + val galleryProcessingMethods: List get() = processingMethodManager.galleryProcessingMethods // Memorial Guideline @@ -144,9 +142,11 @@ class AfternoteEditState( private set var funeralVideoUrl by mutableStateOf(null) private set + /** Memorial video thumbnail URL when available (e.g. from API on edit or future upload). */ var funeralThumbnailUrl by mutableStateOf(null) private set + /** Memorial(PLAYLIST) only: 영정 사진 URL from API (when editing). Sent as playlist.memorialPhotoUrl on save. */ var memorialPhotoUrl by mutableStateOf(null) private set @@ -178,17 +178,20 @@ class AfternoteEditState( // Dropdown States var categoryDropdownState by mutableStateOf( - SelectionDropdownState() + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown + .SelectionDropdownState(), ) private set var serviceDropdownState by mutableStateOf( - SelectionDropdownState() + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown + .SelectionDropdownState(), ) private set @Suppress("UNUSED") var relationshipDropdownState by mutableStateOf( - SelectionDropdownState() + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown + .SelectionDropdownState(), ) private set @@ -199,29 +202,31 @@ class AfternoteEditState( val galleryServices: List get() = AfternoteServiceCatalog.galleryServices val relationshipOptions = listOf("친구", "가족", "연인") - val lastWishOptions = listOf( - LastWishOption( - text = "차분하고 조용하게 보내주세요.", - value = "calm" - ), - LastWishOption( - text = "슬퍼 하지 말고 밝고 따뜻하게 보내주세요.", - value = "bright" - ), - LastWishOption( - text = "기타(직접 입력)", - value = "other" + val lastWishOptions = + listOf( + LastWishOption( + text = "차분하고 조용하게 보내주세요.", + value = "calm", + ), + LastWishOption( + text = "슬퍼 하지 말고 밝고 따뜻하게 보내주세요.", + value = "bright", + ), + LastWishOption( + text = "기타(직접 입력)", + value = "other", + ), ) - ) val playlistAlbumCovers = albumCovers // Computed Properties (Line 295 해결: 삼항 연산자 제거) val currentServiceOptions: List - get() = if (selectedCategory == CATEGORY_GALLERY_AND_FILE) { - galleryServices - } else { - services + CUSTOM_ADD_OPTION - } + get() = + if (selectedCategory == CATEGORY_GALLERY_AND_FILE) { + galleryServices + } else { + services + CUSTOM_ADD_OPTION + } /** * 선택된 서비스가 "직접 추가하기"인지 확인 @@ -229,36 +234,36 @@ class AfternoteEditState( fun isCustomAddOption(service: String): Boolean = service == CUSTOM_ADD_OPTION // Callbacks (Composable 내부 람다 제거로 인지 복잡도 최소화) - val galleryAfternoteEditReceiverCallbacks: AfternoteEditReceiverCallbacks by lazy { - AfternoteEditReceiverCallbacks( + val galleryAfternoteEditReceiverCallbacks: com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverCallbacks by lazy { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverCallbacks( onAddClick = ::showAddAfternoteEditReceiverDialog, onItemDeleteClick = ::onAfternoteEditReceiverDelete, onItemAdded = ::onAfternoteEditReceiverItemAdded, onTextFieldVisibilityChanged = { _ -> // 텍스트 필드 표시 상태 변경 처리 - } + }, ) } - val galleryProcessingCallbacks: ProcessingMethodCallbacks by lazy { - ProcessingMethodCallbacks( + val galleryProcessingCallbacks: com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodCallbacks by lazy { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodCallbacks( onItemDeleteClick = processingMethodManager::deleteGalleryProcessingMethod, onItemAdded = processingMethodManager::addGalleryProcessingMethod, onTextFieldVisibilityChanged = { _ -> // 텍스트 필드 표시 상태 변경 처리 }, - onItemEdited = processingMethodManager::editGalleryProcessingMethod + onItemEdited = processingMethodManager::editGalleryProcessingMethod, ) } - val socialProcessingCallbacks: ProcessingMethodCallbacks by lazy { - ProcessingMethodCallbacks( + val socialProcessingCallbacks: com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodCallbacks by lazy { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodCallbacks( onItemDeleteClick = processingMethodManager::deleteProcessingMethod, onItemAdded = processingMethodManager::addProcessingMethod, onTextFieldVisibilityChanged = { _ -> // 텍스트 필드 표시 상태 변경 처리 }, - onItemEdited = processingMethodManager::editProcessingMethod + onItemEdited = processingMethodManager::editProcessingMethod, ) } @@ -266,11 +271,12 @@ class AfternoteEditState( fun onCategorySelected(category: String) { selectedCategory = category // 카테고리 변경 시 서비스를 해당 카테고리의 기본값으로 초기화 - selectedService = if (category == CATEGORY_GALLERY_AND_FILE) { - AfternoteServiceCatalog.defaultGalleryService - } else { - AfternoteServiceCatalog.defaultSocialService - } + selectedService = + if (category == CATEGORY_GALLERY_AND_FILE) { + AfternoteServiceCatalog.defaultGalleryService + } else { + AfternoteServiceCatalog.defaultSocialService + } } fun onServiceSelected(service: String) { @@ -281,11 +287,13 @@ class AfternoteEditState( } } - fun onProcessingMethodSelected(method: AccountProcessingMethod) { + fun onProcessingMethodSelected(method: com.kuit.afternote.feature.afternote.presentation.edit.model.AccountProcessingMethod) { selectedProcessingMethod = method } - fun onInformationProcessingMethodSelected(method: InformationProcessingMethod) { + fun onInformationProcessingMethodSelected( + method: com.kuit.afternote.feature.afternote.presentation.edit.model.InformationProcessingMethod, + ) { selectedInformationProcessingMethod = method } @@ -360,11 +368,12 @@ class AfternoteEditState( val name = afternoteEditReceiverNameState.text.toString().trim() if (name.isEmpty()) return - val newAfternoteEditReceiver = AfternoteEditReceiver( - id = (afternoteEditReceivers.size + 1).toString(), - name = name, - label = relationshipSelectedValue - ) + val newAfternoteEditReceiver = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = (afternoteEditReceivers.size + 1).toString(), + name = name, + label = relationshipSelectedValue, + ) afternoteEditReceivers = afternoteEditReceivers + newAfternoteEditReceiver dismissDialog() } @@ -381,22 +390,28 @@ class AfternoteEditState( * Adds a receiver from the receiver selection screen (e.g. Time Letter 수신자 목록). * Call when returning from navigation with a selected receiver. */ - fun addReceiverFromSelection(receiverId: Long, name: String, relation: String) { + fun addReceiverFromSelection( + receiverId: Long, + name: String, + relation: String, + ) { if (afternoteEditReceivers.any { it.id == receiverId.toString() }) return - val newReceiver = AfternoteEditReceiver( - id = receiverId.toString(), - name = name, - label = relation - ) + val newReceiver = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = receiverId.toString(), + name = name, + label = relation, + ) afternoteEditReceivers = afternoteEditReceivers + newReceiver } fun onAfternoteEditReceiverItemAdded(text: String) { - val newAfternoteEditReceiver = AfternoteEditReceiver( - id = (afternoteEditReceivers.size + 1).toString(), - name = text, - label = "친구" - ) + val newAfternoteEditReceiver = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver( + id = (afternoteEditReceivers.size + 1).toString(), + name = text, + label = "친구", + ) afternoteEditReceivers = afternoteEditReceivers + newAfternoteEditReceiver } @@ -414,38 +429,50 @@ class AfternoteEditState( TAG, "loadFromExisting: itemId=${params.itemId}, serviceName=${params.serviceName}, " + "category=${params.categoryDisplayString}, " + - "accountPM=${params.accountProcessingMethodName}, infoPM=${params.informationProcessingMethodName}, " + - "processingMethods=${params.processingMethodsList.size}, " + - "galleryProcessingMethods=${params.galleryProcessingMethodsList.size}" + "accountPM=${params.processing.accountMethodName}, infoPM=${params.processing.informationMethodName}, " + + "processingMethods=${params.processing.methods.size}, " + + "galleryProcessingMethods=${params.processing.galleryMethods.size}", ) loadedItemId = params.itemId selectedService = params.serviceName selectedCategory = params.categoryDisplayString - idState.edit { replace(0, length, params.accountId) } - passwordState.edit { replace(0, length, params.password) } - messageState.edit { replace(0, length, params.message) } - - if (params.accountProcessingMethodName.isNotEmpty()) { - selectedProcessingMethod = runCatching { - AccountProcessingMethod.valueOf(params.accountProcessingMethodName) - }.getOrDefault(AccountProcessingMethod.MEMORIAL_ACCOUNT) + idState.edit { replace(0, length, params.account.id) } + passwordState.edit { replace(0, length, params.account.password) } + messageState.edit { replace(0, length, params.processing.message) } + + if (params.processing.accountMethodName.isNotEmpty()) { + selectedProcessingMethod = + runCatching { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AccountProcessingMethod.valueOf( + params.processing.accountMethodName, + ) + }.getOrDefault( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.AccountProcessingMethod.MEMORIAL_ACCOUNT, + ) } - if (params.informationProcessingMethodName.isNotEmpty()) { + if (params.processing.informationMethodName.isNotEmpty()) { val infoMethodName = - when (params.informationProcessingMethodName) { + when (params.processing.informationMethodName) { "TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER", - "ADDITIONAL" -> "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER" - else -> params.informationProcessingMethodName + "ADDITIONAL", + -> "TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER" + + else -> params.processing.informationMethodName } - selectedInformationProcessingMethod = runCatching { - InformationProcessingMethod.valueOf(infoMethodName) - }.getOrDefault(InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER) + selectedInformationProcessingMethod = + runCatching { + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.InformationProcessingMethod.valueOf( + infoMethodName, + ) + }.getOrDefault( + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.model.InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER, + ) } - processingMethodManager.replaceProcessingMethods(params.processingMethodsList) - processingMethodManager.replaceGalleryProcessingMethods(params.galleryProcessingMethodsList) + processingMethodManager.replaceProcessingMethods(params.processing.methods) + processingMethodManager.replaceGalleryProcessingMethods(params.processing.galleryMethods) // Memorial only: when atmosphere does not match a default option, select "기타(직접 입력)" and show saved text. params.atmosphere?.let { atmosphereValue -> @@ -455,14 +482,17 @@ class AfternoteEditState( selectedLastWish = null customLastWishText = "" } + trimmed == LAST_WISH_DEFAULT_CALM -> { selectedLastWish = "calm" customLastWishText = "" } + trimmed == LAST_WISH_DEFAULT_BRIGHT -> { selectedLastWish = "bright" customLastWishText = "" } + else -> { selectedLastWish = "other" customLastWishText = trimmed @@ -491,17 +521,25 @@ data class LoadFromExistingParams( val itemId: String, val serviceName: String, val categoryDisplayString: String, - val accountId: String, - val password: String, - val message: String, - val accountProcessingMethodName: String, - val informationProcessingMethodName: String, - val processingMethodsList: List, - val galleryProcessingMethodsList: List, + val account: LoadFromExistingAccountParams = LoadFromExistingAccountParams(), + val processing: LoadFromExistingProcessingParams = LoadFromExistingProcessingParams(), val atmosphere: String? = null, val memorialVideoUrl: String? = null, val memorialThumbnailUrl: String? = null, - val memorialPhotoUrl: String? = null + val memorialPhotoUrl: String? = null, +) + +data class LoadFromExistingAccountParams( + val id: String = "", + val password: String = "", +) + +data class LoadFromExistingProcessingParams( + val message: String = "", + val accountMethodName: String = "", + val informationMethodName: String = "", + val methods: List = emptyList(), + val galleryMethods: List = emptyList(), ) @Composable @@ -523,7 +561,7 @@ fun rememberAfternoteEditState(): AfternoteEditState { phoneNumberState = phoneNumberState, customServiceNameState = customServiceNameState, initialAfternoteEditReceivers = afternoteProvider.getAfternoteEditReceivers(), - albumCovers = afternoteProvider.getAlbumCovers() + albumCovers = afternoteProvider.getAlbumCovers(), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditViewModel.kt similarity index 68% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModel.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditViewModel.kt index 7e601b75..240c680f 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/AfternoteEditViewModel.kt @@ -1,15 +1,15 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.edit import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentialsDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteMemorialVideoDto -import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylistDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteSongDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail +import com.kuit.afternote.feature.afternote.domain.model.AfternoteUpdateRequestInput +import com.kuit.afternote.feature.afternote.domain.model.CredentialsInput +import com.kuit.afternote.feature.afternote.domain.model.ReceiverRefInput +import com.kuit.afternote.feature.afternote.domain.model.playlist.AfternotePlaylistInput +import com.kuit.afternote.feature.afternote.domain.model.playlist.MemorialVideoInput +import com.kuit.afternote.feature.afternote.domain.model.playlist.SongInput import com.kuit.afternote.feature.afternote.domain.usecase.CreateGalleryAfternoteUseCase import com.kuit.afternote.feature.afternote.domain.usecase.CreatePlaylistAfternoteUseCase import com.kuit.afternote.feature.afternote.domain.usecase.CreateSocialAfternoteUseCase @@ -18,8 +18,12 @@ import com.kuit.afternote.feature.afternote.domain.usecase.UpdateAfternoteUseCas import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialPhotoUseCase import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialThumbnailUseCase import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialVideoUseCase -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Song +import com.kuit.afternote.feature.afternote.presentation.AfternoteSaveState +import com.kuit.afternote.feature.afternote.presentation.AfternoteValidationError +import com.kuit.afternote.feature.afternote.presentation.AfternoteValidationException +import com.kuit.afternote.feature.afternote.presentation.RegisterAfternotePayload +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem +import com.kuit.afternote.feature.afternote.presentation.edit.model.Song import com.kuit.afternote.feature.user.domain.model.ReceiverListItem import com.kuit.afternote.feature.user.domain.usecase.GetReceiversUseCase import com.kuit.afternote.feature.user.domain.usecase.GetUserIdUseCase @@ -64,9 +68,8 @@ class AfternoteEditViewModel private val getUserIdUseCase: GetUserIdUseCase, private val uploadMemorialThumbnailUseCase: UploadMemorialThumbnailUseCase, private val uploadMemorialVideoUseCase: UploadMemorialVideoUseCase, - private val uploadMemorialPhotoUseCase: UploadMemorialPhotoUseCase + private val uploadMemorialPhotoUseCase: UploadMemorialPhotoUseCase, ) : ViewModel() { - private val _saveState = MutableStateFlow(AfternoteSaveState()) val saveState: StateFlow = _saveState.asStateFlow() @@ -93,8 +96,7 @@ class AfternoteEditViewModel /** * Returns the receiver for the given id from the last [loadReceivers] result, or null. */ - fun getReceiverById(id: Long): ReceiverListItem? = - cachedReceivers.find { it.receiverId == id } + fun getReceiverById(id: Long): ReceiverListItem? = cachedReceivers.find { it.receiverId == id } /** * Category from the server when loading for edit. Used for update requests because the API @@ -112,8 +114,7 @@ class AfternoteEditViewModel .onSuccess { url -> Log.d(TAG, "uploadMemorialThumbnail: success, url=$url") _uploadedThumbnailUrl.value = url - } - .onFailure { e -> + }.onFailure { e -> Log.e(TAG, "uploadMemorialThumbnail: failed", e) } } @@ -140,19 +141,20 @@ class AfternoteEditViewModel payload: RegisterAfternotePayload, selectedReceiverIds: List, playlistStateHolder: MemorialPlaylistStateHolder?, - memorialMedia: SaveAfternoteMemorialMedia + memorialMedia: SaveAfternoteMemorialMedia, ) { if (_saveState.value.isSaving) { Log.w(TAG, "saveAfternote: already saving, ignoring duplicate call") return } - val validationError = validateRequiredFieldsSync( - category = category, - payload = payload, - selectedReceiverIds = selectedReceiverIds, - playlistStateHolder = playlistStateHolder - ) + val validationError = + validateRequiredFieldsSync( + category = category, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + playlistStateHolder = playlistStateHolder, + ) if (validationError != null) { Log.w(TAG, "saveAfternote: validation failed: $validationError") _saveState.update { it.copy(validationError = validationError) } @@ -171,7 +173,7 @@ class AfternoteEditViewModel "informationProcessingMethod=${payload.informationProcessingMethod}, " + "processingMethods=${payload.processingMethods}, " + "galleryProcessingMethods=${payload.galleryProcessingMethods}, " + - "hasPlaylist=${playlistStateHolder != null}" + "hasPlaylist=${playlistStateHolder != null}", ) viewModelScope.launch { @@ -184,10 +186,11 @@ class AfternoteEditViewModel ) { return@launch } - val resolvedMemorialPhotoUrl = resolveMemorialPhotoUrlForSave( - memorialPhotoUrl = memorialMedia.memorialPhotoUrl, - pickedMemorialPhotoUri = memorialMedia.pickedMemorialPhotoUri - ) + val resolvedMemorialPhotoUrl = + resolveMemorialPhotoUrlForSave( + memorialPhotoUrl = memorialMedia.memorialPhotoUrl, + pickedMemorialPhotoUri = memorialMedia.pickedMemorialPhotoUri, + ) if (resolvedMemorialPhotoUrl == null && memorialMedia.pickedMemorialPhotoUri != null && memorialMedia.pickedMemorialPhotoUri.startsWith(CONTENT_SCHEME) ) { @@ -196,45 +199,47 @@ class AfternoteEditViewModel val videoUrlForUpdate = videoUrlForUpdateRequest(editingId != null, resolvedVideoUrl) val thumbnailForUpdate = if (videoUrlForUpdate == null) null else memorialMedia.funeralThumbnailUrl - val updateMedia = MemorialMediaUrls( - funeralVideoUrl = videoUrlForUpdate, - funeralThumbnailUrl = thumbnailForUpdate, - memorialPhotoUrl = resolvedMemorialPhotoUrl - ) - val createMedia = MemorialMediaUrls( - funeralVideoUrl = resolvedVideoUrl, - funeralThumbnailUrl = memorialMedia.funeralThumbnailUrl, - memorialPhotoUrl = resolvedMemorialPhotoUrl - ) - (if (editingId != null) { - performUpdate( - afternoteId = editingId, - category = categoryForApi, - payload = payload, - selectedReceiverIds = selectedReceiverIds, - playlistStateHolder = playlistStateHolder, - memorialMedia = updateMedia + val updateMedia = + MemorialMediaUrls( + funeralVideoUrl = videoUrlForUpdate, + funeralThumbnailUrl = thumbnailForUpdate, + memorialPhotoUrl = resolvedMemorialPhotoUrl, ) - } else { - performCreate( - category = categoryForApi, - payload = payload, - selectedReceiverIds = selectedReceiverIds, - playlistStateHolder = playlistStateHolder, - funeralVideoUrl = createMedia.funeralVideoUrl, - funeralThumbnailUrl = createMedia.funeralThumbnailUrl, - memorialPhotoUrl = createMedia.memorialPhotoUrl + val createMedia = + MemorialMediaUrls( + funeralVideoUrl = resolvedVideoUrl, + funeralThumbnailUrl = memorialMedia.funeralThumbnailUrl, + memorialPhotoUrl = resolvedMemorialPhotoUrl, ) - }) - .onSuccess { id -> - Log.d(TAG, "saveAfternote: SUCCESS, savedId=$id") - _saveState.update { - it.copy(isSaving = false, saveSuccess = true, savedId = id) - } + ( + if (editingId != null) { + performUpdate( + afternoteId = editingId, + category = categoryForApi, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + playlistStateHolder = playlistStateHolder, + memorialMedia = updateMedia, + ) + } else { + performCreate( + category = categoryForApi, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + playlistStateHolder = playlistStateHolder, + funeralVideoUrl = createMedia.funeralVideoUrl, + funeralThumbnailUrl = createMedia.funeralThumbnailUrl, + memorialPhotoUrl = createMedia.memorialPhotoUrl, + ) } - .onFailure { e -> - handleSaveFailure(e, categoryForApi) + ).onSuccess { id -> + Log.d(TAG, "saveAfternote: SUCCESS, savedId=$id") + _saveState.update { + it.copy(isSaving = false, saveSuccess = true, savedId = id) } + }.onFailure { e -> + handleSaveFailure(e, categoryForApi) + } } } @@ -254,11 +259,11 @@ class AfternoteEditViewModel _saveState.update { it.copy( isSaving = false, - error = e.message ?: "영상 업로드에 실패했습니다." + error = e.message ?: "영상 업로드에 실패했습니다.", ) } null - } + }, ) } @@ -269,7 +274,7 @@ class AfternoteEditViewModel */ private suspend fun resolveMemorialPhotoUrlForSave( memorialPhotoUrl: String?, - pickedMemorialPhotoUri: String? + pickedMemorialPhotoUri: String?, ): String? { if (!pickedMemorialPhotoUri.isNullOrBlank() && pickedMemorialPhotoUri.startsWith(CONTENT_SCHEME)) { return uploadMemorialPhotoUseCase(pickedMemorialPhotoUri).fold( @@ -279,11 +284,11 @@ class AfternoteEditViewModel _saveState.update { it.copy( isSaving = false, - error = e.message ?: "영정 사진 업로드에 실패했습니다." + error = e.message ?: "영정 사진 업로드에 실패했습니다.", ) } null - } + }, ) } return memorialPhotoUrl?.takeIf { it.isNotBlank() } @@ -292,7 +297,10 @@ class AfternoteEditViewModel /** * For update (PATCH), do not send presigned URLs so the server does not overwrite the stored key. */ - private fun videoUrlForUpdateRequest(isUpdate: Boolean, resolvedVideoUrl: String?): String? { + private fun videoUrlForUpdateRequest( + isUpdate: Boolean, + resolvedVideoUrl: String?, + ): String? { if (!isUpdate || resolvedVideoUrl == null) return resolvedVideoUrl if (resolvedVideoUrl.contains(PRESIGNED_URL_MARKER)) { Log.d(TAG, "saveAfternote: skipping videoUrl in PATCH (presigned URL)") @@ -301,23 +309,28 @@ class AfternoteEditViewModel return resolvedVideoUrl } - private fun handleSaveFailure(e: Throwable, categoryForApi: String) { + private fun handleSaveFailure( + e: Throwable, + categoryForApi: String, + ) { Log.e(TAG, "saveAfternote: FAILURE, category=$categoryForApi", e) - val validationError = when { - e is AfternoteValidationException -> e.validationError - e is HttpException && e.code() == 400 -> parseReceiversRequiredFromBody(e) - else -> null - } - val errorMessage = when { - validationError != null -> null - e is AfternoteValidationException -> null - else -> e.message ?: "저장에 실패했습니다." - } + val validationError = + when { + e is AfternoteValidationException -> e.validationError + e is HttpException && e.code() == 400 -> parseReceiversRequiredFromBody(e) + else -> null + } + val errorMessage = + when { + validationError != null -> null + e is AfternoteValidationException -> null + else -> e.message ?: "저장에 실패했습니다." + } _saveState.update { it.copy( isSaving = false, validationError = validationError, - error = errorMessage + error = errorMessage, ) } } @@ -343,7 +356,7 @@ class AfternoteEditViewModel fun loadForEdit( afternoteId: Long, state: AfternoteEditState, - playlistStateHolder: MemorialPlaylistStateHolder? = null + playlistStateHolder: MemorialPlaylistStateHolder? = null, ) { viewModelScope.launch { getDetailUseCase(afternoteId = afternoteId) @@ -358,56 +371,71 @@ class AfternoteEditViewModel private fun populatePlaylistFromDetail( detail: AfternoteDetail, - playlistStateHolder: MemorialPlaylistStateHolder? + playlistStateHolder: MemorialPlaylistStateHolder?, ) { if (detail.category.uppercase() != "PLAYLIST" || detail.playlist == null || playlistStateHolder == null - ) return + ) { + return + } playlistStateHolder.clearAllSongs() - detail.playlist.songs.mapIndexed { index, s -> - Song( - id = (s.id ?: index.toLong()).toString(), - title = s.title, - artist = s.artist, - albumCoverUrl = s.coverUrl - ) - }.forEach { playlistStateHolder.addSong(it) } + detail.playlist.songs + .mapIndexed { index, s -> + Song( + id = (s.id ?: index.toLong()).toString(), + title = s.title, + artist = s.artist, + albumCoverUrl = s.coverUrl, + ) + }.forEach { playlistStateHolder.addSong(it) } } private fun buildLoadFromExistingParams(detail: AfternoteDetail): LoadFromExistingParams { val actionItems = - detail.actions.mapIndexed { index, text -> + detail.processing?.actions?.mapIndexed { index, text -> ProcessingMethodItem( id = (index + 1).toString(), - text = text + text = text, ) - } - val processMethod = detail.processMethod ?: "" + } ?: emptyList() + val processMethod = detail.processing?.method ?: "" val categoryUpper = detail.category.uppercase() val isGalleryCategory = categoryUpper == "GALLERY" val isSocialCategory = categoryUpper == "SOCIAL" val accountProcessingMethodName = - if (isSocialCategory) serverProcessMethodToAccountEnum(processMethod) - else "" + if (isSocialCategory) { + serverProcessMethodToAccountEnum(processMethod) + } else { + "" + } val informationProcessingMethodName = - if (isGalleryCategory) serverProcessMethodToInfoEnum(processMethod) - else "" + if (isGalleryCategory) { + serverProcessMethodToInfoEnum(processMethod) + } else { + "" + } return LoadFromExistingParams( itemId = detail.id.toString(), serviceName = detail.title, categoryDisplayString = serverCategoryToEditScreenCategory(detail.category), - accountId = detail.credentialsId ?: "", - password = detail.credentialsPassword ?: "", - message = detail.leaveMessage ?: "", - accountProcessingMethodName = accountProcessingMethodName, - informationProcessingMethodName = informationProcessingMethodName, - processingMethodsList = if (!isGalleryCategory) actionItems else emptyList(), - galleryProcessingMethodsList = if (isGalleryCategory) actionItems else emptyList(), + account = + LoadFromExistingAccountParams( + id = detail.credentials?.id ?: "", + password = detail.credentials?.password ?: "", + ), + processing = + LoadFromExistingProcessingParams( + message = detail.processing?.leaveMessage ?: "", + accountMethodName = accountProcessingMethodName, + informationMethodName = informationProcessingMethodName, + methods = if (!isGalleryCategory) actionItems else emptyList(), + galleryMethods = if (isGalleryCategory) actionItems else emptyList(), + ), atmosphere = detail.playlist?.atmosphere, - memorialVideoUrl = detail.playlist?.memorialVideoUrl, - memorialThumbnailUrl = detail.playlist?.memorialThumbnailUrl, - memorialPhotoUrl = detail.playlist?.memorialPhotoUrl ?: detail.playlist?.profilePhoto + memorialVideoUrl = detail.playlist?.playlistDetailMemorialMedia?.videoUrl, + memorialThumbnailUrl = detail.playlist?.playlistDetailMemorialMedia?.thumbnailUrl, + memorialPhotoUrl = detail.playlist?.playlistDetailMemorialMedia?.photoUrl, ) } @@ -415,9 +443,7 @@ class AfternoteEditViewModel * Validates required fields for SOCIAL category (소셜네트워크). * Returns the first validation error found, or null if valid. */ - private fun validateSocialLikeRequiredFields( - payload: RegisterAfternotePayload - ): AfternoteValidationError? { + private fun validateSocialLikeRequiredFields(payload: RegisterAfternotePayload): AfternoteValidationError? { if (payload.accountId.isBlank() || payload.password.isBlank()) { return AfternoteValidationError.SOCIAL_CREDENTIALS_REQUIRED } @@ -439,7 +465,7 @@ class AfternoteEditViewModel category: String, payload: RegisterAfternotePayload, selectedReceiverIds: List, - playlistStateHolder: MemorialPlaylistStateHolder? + playlistStateHolder: MemorialPlaylistStateHolder?, ): AfternoteValidationError? { if (selectedReceiverIds.isEmpty()) { return AfternoteValidationError.RECEIVERS_REQUIRED @@ -461,9 +487,7 @@ class AfternoteEditViewModel return null } - private fun validateMemorialRequiredFields( - playlistStateHolder: MemorialPlaylistStateHolder? - ): AfternoteValidationError? { + private fun validateMemorialRequiredFields(playlistStateHolder: MemorialPlaylistStateHolder?): AfternoteValidationError? { if (playlistStateHolder == null || playlistStateHolder.songs.isEmpty()) { return AfternoteValidationError.PLAYLIST_SONGS_REQUIRED } @@ -477,56 +501,61 @@ class AfternoteEditViewModel playlistStateHolder: MemorialPlaylistStateHolder?, funeralVideoUrl: String? = null, funeralThumbnailUrl: String? = null, - memorialPhotoUrl: String? = null + memorialPhotoUrl: String? = null, ): Result { - val actions = payload.processingMethods.map { it.text } + - payload.galleryProcessingMethods.map { it.text } + val actions = + payload.processingMethods.map { it.text } + + payload.galleryProcessingMethods.map { it.text } val isSocial = category == CATEGORY_SOCIAL - val processMethod = toServerProcessMethod( - accountProcessingMethod = - if (isSocial) payload.accountProcessingMethod else "", - informationProcessingMethod = - if (!isSocial) payload.informationProcessingMethod else "" - ) + val processMethod = + toServerProcessMethod( + accountProcessingMethod = + if (isSocial) payload.accountProcessingMethod else "", + informationProcessingMethod = + if (!isSocial) payload.informationProcessingMethod else "", + ) val leaveMessage = payload.message.ifEmpty { null } Log.d( TAG, "performCreate: category=$category, title=${payload.serviceName}, " + "processMethod=$processMethod, actions=$actions, " + - "leaveMessage=$leaveMessage" + "leaveMessage=$leaveMessage", ) - return when (category) { + return when (category) { CATEGORY_GALLERY -> { val galleryActions = actions.ifEmpty { listOf("정보 전달") } Log.d( TAG, - "performCreate GALLERY: receiverIds=$selectedReceiverIds, actions=$galleryActions" + "performCreate GALLERY: receivers=$selectedReceiverIds, actions=$galleryActions", ) createGalleryUseCase( title = payload.serviceName, processMethod = processMethod, actions = galleryActions, leaveMessage = leaveMessage, - receiverIds = selectedReceiverIds + receiverIds = selectedReceiverIds, ) } + CATEGORY_MEMORIAL -> { - val playlistDto = buildPlaylistDto( - playlistStateHolder = playlistStateHolder, - atmosphere = payload.atmosphere, - memorialPhotoUrl = memorialPhotoUrl, - funeralVideoUrl = funeralVideoUrl, - funeralThumbnailUrl = funeralThumbnailUrl - ) + val playlistDto = + buildPlaylistInput( + playlistStateHolder = playlistStateHolder, + atmosphere = payload.atmosphere, + memorialPhotoUrl = memorialPhotoUrl, + funeralVideoUrl = funeralVideoUrl, + funeralThumbnailUrl = funeralThumbnailUrl, + ) createPlaylistUseCase( title = payload.serviceName, playlist = playlistDto, - receiverIds = selectedReceiverIds + receiverIds = selectedReceiverIds, ) } + else -> { createSocialUseCase( title = payload.serviceName, @@ -535,7 +564,7 @@ class AfternoteEditViewModel leaveMessage = leaveMessage, credentialsId = payload.accountId.takeIf { it.isNotEmpty() }, credentialsPassword = payload.password.takeIf { it.isNotEmpty() }, - receiverIds = selectedReceiverIds + receiverIds = selectedReceiverIds, ) } } @@ -547,7 +576,7 @@ class AfternoteEditViewModel payload: RegisterAfternotePayload, selectedReceiverIds: List, playlistStateHolder: MemorialPlaylistStateHolder?, - memorialMedia: MemorialMediaUrls + memorialMedia: MemorialMediaUrls, ): Result { val body = if (category == CATEGORY_MEMORIAL) { @@ -557,13 +586,13 @@ class AfternoteEditViewModel playlistStateHolder = playlistStateHolder, funeralVideoUrl = memorialMedia.funeralVideoUrl, funeralThumbnailUrl = memorialMedia.funeralThumbnailUrl, - memorialPhotoUrl = memorialMedia.memorialPhotoUrl + memorialPhotoUrl = memorialMedia.memorialPhotoUrl, ) } else { buildNonMemorialUpdateBody( category = category, payload = payload, - selectedReceiverIds = selectedReceiverIds + selectedReceiverIds = selectedReceiverIds, ) } return updateUseCase(afternoteId = afternoteId, body = body) @@ -580,35 +609,37 @@ class AfternoteEditViewModel playlistStateHolder: MemorialPlaylistStateHolder?, funeralVideoUrl: String? = null, funeralThumbnailUrl: String? = null, - memorialPhotoUrl: String? = null - ): AfternoteUpdateRequestDto = - AfternoteUpdateRequestDto( - category = "PLAYLIST", - title = title, - playlist = buildPlaylistDto( + memorialPhotoUrl: String? = null, + ) = AfternoteUpdateRequestInput( + category = "PLAYLIST", + title = title, + playlist = + buildPlaylistInput( playlistStateHolder = playlistStateHolder, atmosphere = atmosphere, memorialPhotoUrl = memorialPhotoUrl, funeralVideoUrl = funeralVideoUrl, - funeralThumbnailUrl = funeralThumbnailUrl - ) - ) + funeralThumbnailUrl = funeralThumbnailUrl, + ), + ) private fun buildNonMemorialUpdateBody( category: String, payload: RegisterAfternotePayload, - selectedReceiverIds: List - ): AfternoteUpdateRequestDto { - val actions = payload.processingMethods.map { it.text } + - payload.galleryProcessingMethods.map { it.text } + selectedReceiverIds: List, + ): AfternoteUpdateRequestInput { + val actions = + payload.processingMethods.map { it.text } + + payload.galleryProcessingMethods.map { it.text } val isSocial = category == CATEGORY_SOCIAL - val processMethod = toServerProcessMethod( - accountProcessingMethod = - if (isSocial) payload.accountProcessingMethod else "", - informationProcessingMethod = - if (!isSocial) payload.informationProcessingMethod else "" - ) + val processMethod = + toServerProcessMethod( + accountProcessingMethod = + if (isSocial) payload.accountProcessingMethod else "", + informationProcessingMethod = + if (!isSocial) payload.informationProcessingMethod else "", + ) val serverCategory = when (category) { CATEGORY_SOCIAL -> "SOCIAL" @@ -616,35 +647,46 @@ class AfternoteEditViewModel else -> null } // Title and category are mandatory for the edit API; fallback for unknown display category. - return AfternoteUpdateRequestDto( + return AfternoteUpdateRequestInput( category = serverCategory ?: "SOCIAL", title = payload.serviceName, processMethod = processMethod.ifEmpty { null }, actions = actions.ifEmpty { null }, leaveMessage = payload.message.ifEmpty { null }, - credentials = when (category) { - CATEGORY_SOCIAL -> { - val id = payload.accountId.takeIf { it.isNotEmpty() } - val pw = payload.password.takeIf { it.isNotEmpty() } - if (id != null || pw != null) AfternoteCredentialsDto(id = id, password = pw) - else null - } - else -> null - }, - receivers = when (category) { - CATEGORY_GALLERY -> - selectedReceiverIds.map { AfternoteReceiverRefDto(receiverId = it) } - else -> null - }, - playlist = null + credentials = + when (category) { + CATEGORY_SOCIAL -> { + val id = payload.accountId.takeIf { it.isNotEmpty() } + val pw = payload.password.takeIf { it.isNotEmpty() } + if (id != null || pw != null) { + CredentialsInput(id = id, password = pw) + } else { + null + } + } + + else -> { + null + } + }, + receivers = + when (category) { + CATEGORY_GALLERY -> { + selectedReceiverIds.map { ReceiverRefInput(receiverId = it) } + } + + else -> { + null + } + }, + playlist = null, ) } - /** - * 클라이언트 enum 이름을 서버 processMethod 코드로 변환. - * 갤러리는 수신자 지정만 지원하며 TRANSFER만 전송합니다. - */ - /** 서버 processMethod → 계정 처리 방법 enum 이름 (소셜/비즈니스 편집용). */ +// 클라이언트 enum 이름을 서버 processMethod 코드로 변환. +// 갤러리는 수신자 지정만 지원하며 TRANSFER만 전송합니다. + + // 서버 processMethod → 계정 처리 방법 enum 이름 (소셜/비즈니스 편집용). private fun serverProcessMethodToAccountEnum(processMethod: String): String = when (processMethod.uppercase()) { "MEMORIAL" -> "MEMORIAL_ACCOUNT" @@ -671,7 +713,7 @@ class AfternoteEditViewModel private fun toServerProcessMethod( accountProcessingMethod: String, - informationProcessingMethod: String + informationProcessingMethod: String, ): String { val fromAccount = when (accountProcessingMethod) { @@ -693,32 +735,36 @@ class AfternoteEditViewModel * selected a video. thumbnailUrl is from POST /files/presigned-url upload or API on edit. * memorialPhotoUrl: 영정 사진 URL (from API on edit or after upload). */ - private fun buildPlaylistDto( + private fun buildPlaylistInput( playlistStateHolder: MemorialPlaylistStateHolder?, atmosphere: String = "", memorialPhotoUrl: String? = null, funeralVideoUrl: String? = null, - funeralThumbnailUrl: String? = null - ): AfternotePlaylistDto { - val songs = playlistStateHolder?.songs?.map { song -> - AfternoteSongDto( - id = song.id.toLongOrNull(), - title = song.title, - artist = song.artist, - coverUrl = song.albumCoverUrl - ) - } ?: emptyList() + funeralThumbnailUrl: String? = null, + ): AfternotePlaylistInput { + val songs = + playlistStateHolder?.songs?.map { song -> + SongInput( + id = song.id.toLongOrNull(), + title = song.title, + artist = song.artist, + coverUrl = song.albumCoverUrl, + ) + } ?: emptyList() val memorialVideo = - if (funeralVideoUrl.isNullOrBlank()) null - else AfternoteMemorialVideoDto( - videoUrl = funeralVideoUrl, - thumbnailUrl = funeralThumbnailUrl.takeIf { !it.isNullOrBlank() } - ) - return AfternotePlaylistDto( + if (funeralVideoUrl.isNullOrBlank()) { + null + } else { + MemorialVideoInput( + videoUrl = funeralVideoUrl, + thumbnailUrl = funeralThumbnailUrl.takeIf { !it.isNullOrBlank() }, + ) + } + return AfternotePlaylistInput( atmosphere = atmosphere.ifEmpty { null }, memorialPhotoUrl = memorialPhotoUrl?.takeIf { it.isNotBlank() }, songs = songs, - memorialVideo = memorialVideo + memorialVideo = memorialVideo, ) } } @@ -731,7 +777,7 @@ data class SaveAfternoteMemorialMedia( val funeralVideoUrl: String? = null, val funeralThumbnailUrl: String? = null, val memorialPhotoUrl: String? = null, - val pickedMemorialPhotoUri: String? = null + val pickedMemorialPhotoUri: String? = null, ) /** @@ -741,11 +787,11 @@ data class SaveAfternoteMemorialMedia( private data class MemorialMediaUrls( val funeralVideoUrl: String? = null, val funeralThumbnailUrl: String? = null, - val memorialPhotoUrl: String? = null + val memorialPhotoUrl: String? = null, ) /** API 400 응답 body 파싱용 (code 475 등). */ @Serializable private data class ApiErrorBody( - val code: Int? = null + val code: Int? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialog.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialog.kt similarity index 56% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialog.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialog.kt index be9b02ed..c0d38454 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialog.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialog.kt @@ -1,5 +1,4 @@ - -package com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver +package com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -30,10 +29,10 @@ import androidx.compose.ui.window.DialogProperties import com.kuit.afternote.core.ui.component.LabeledTextFieldStyle import com.kuit.afternote.core.ui.component.OutlineTextField import com.kuit.afternote.core.ui.component.button.ClickButton -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.DropdownMenuStyle -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdown -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdownLabelParams -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.rememberSelectionDropdownState +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.DropdownMenuStyle +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdown +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdownLabelParams +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.rememberSelectionDropdownState import com.kuit.afternote.ui.expand.dropShadow import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.B3 @@ -50,17 +49,22 @@ private fun RelationshipDropdown( selectedValue: String, options: List, onValueSelected: (String) -> Unit, - menuStyle: DropdownMenuStyle + menuStyle: com.kuit.afternote.feature.afternote.presentation.edit.dropdown.DropdownMenuStyle, ) { - val dropdownState = rememberSelectionDropdownState() - - SelectionDropdown( - labelParams = SelectionDropdownLabelParams(label = "수신자와의 관계"), + val dropdownState = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown + .rememberSelectionDropdownState() + + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdown( + labelParams = + _root_ide_package_.com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdownLabelParams( + label = "수신자와의 관계", + ), selectedValue = selectedValue, options = options, onValueSelected = onValueSelected, menuStyle = menuStyle, - state = dropdownState + state = dropdownState, ) } @@ -77,68 +81,74 @@ private fun RelationshipDropdown( @Composable fun AddAfternoteEditReceiverDialog( modifier: Modifier = Modifier, - params: AddAfternoteEditReceiverDialogParams + params: com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialogParams, ) { Dialog( onDismissRequest = params.callbacks.onDismiss, - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true - ) + properties = + DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + ), ) { Column( - modifier = modifier - .fillMaxWidth() - .dropShadow( - shape = RoundedCornerShape(16.dp), - color = Color.Black.copy(alpha = 0.15f), - blur = 10.dp, - offsetX = 0.dp, - offsetY = 2.dp, - spread = 0.dp - ).background( - White, - RoundedCornerShape(16.dp) - ).padding(vertical = 32.dp, horizontal = 24.dp) + modifier = + modifier + .fillMaxWidth() + .dropShadow( + shape = RoundedCornerShape(16.dp), + color = Color.Black.copy(alpha = 0.15f), + blur = 10.dp, + offsetX = 0.dp, + offsetY = 2.dp, + spread = 0.dp, + ).background( + White, + RoundedCornerShape(16.dp), + ).padding(vertical = 32.dp, horizontal = 24.dp), ) { // 헤더: 타이틀과 추가하기 버튼 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "수신자 추가", - style = TextStyle( - fontSize = 18.sp, - lineHeight = 24.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Bold, - color = Gray9 - ) + style = + TextStyle( + fontSize = 18.sp, + lineHeight = 24.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Bold, + color = Gray9, + ), ) Button( onClick = params.callbacks.onAddClick, modifier = Modifier.height(32.dp), shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.buttonColors( - containerColor = B3 - ), - contentPadding = PaddingValues( - horizontal = 16.dp, - vertical = 8.dp - ) + colors = + ButtonDefaults.buttonColors( + containerColor = B3, + ), + contentPadding = + PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), ) { Text( text = "추가하기", - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) } } @@ -150,7 +160,7 @@ fun AddAfternoteEditReceiverDialog( label = "수신자 이름", textFieldState = params.afternoteEditReceiverNameState, keyboardType = KeyboardType.Text, - style = LabeledTextFieldStyle(containerColor = Gray1, labelSpacing = 7.95.dp) + style = LabeledTextFieldStyle(containerColor = Gray1, labelSpacing = 7.95.dp), ) Spacer(modifier = Modifier.height(16.dp)) @@ -160,12 +170,13 @@ fun AddAfternoteEditReceiverDialog( selectedValue = params.relationshipSelectedValue, options = params.relationshipOptions, onValueSelected = params.callbacks.onRelationshipSelected, - menuStyle = DropdownMenuStyle( - menuOffset = 5.2.dp, - menuBackgroundColor = Gray1, - shadowElevation = 0.dp, - tonalElevation = 0.dp - ) + menuStyle = + DropdownMenuStyle( + menuOffset = 5.2.dp, + menuBackgroundColor = Gray1, + shadowElevation = 0.dp, + tonalElevation = 0.dp, + ), ) Spacer(modifier = Modifier.height(24.dp)) @@ -174,7 +185,7 @@ fun AddAfternoteEditReceiverDialog( label = "전화번호로 추가하기", textFieldState = params.phoneNumberState, keyboardType = KeyboardType.Phone, - style = LabeledTextFieldStyle(containerColor = Gray1, labelSpacing = 7.95.dp) + style = LabeledTextFieldStyle(containerColor = Gray1, labelSpacing = 7.95.dp), ) Spacer(modifier = Modifier.height(16.dp)) @@ -182,13 +193,14 @@ fun AddAfternoteEditReceiverDialog( Text( text = "연락처에서 추가하기", // CaptionLarge-R - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9, - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(8.dp)) @@ -197,7 +209,7 @@ fun AddAfternoteEditReceiverDialog( ClickButton( color = B3, title = "연락처 가져오기", - onButtonClick = params.callbacks.onImportContactsClick + onButtonClick = params.callbacks.onImportContactsClick, ) } } @@ -208,12 +220,13 @@ fun AddAfternoteEditReceiverDialog( private fun AddAfternoteEditReceiverDialogPreview() { AfternoteTheme { AddAfternoteEditReceiverDialog( - params = AddAfternoteEditReceiverDialogParams( - afternoteEditReceiverNameState = rememberTextFieldState(), - phoneNumberState = rememberTextFieldState(), - relationshipSelectedValue = "친구", - relationshipOptions = listOf("친구", "가족", "연인") - ) + params = + AddAfternoteEditReceiverDialogParams( + afternoteEditReceiverNameState = rememberTextFieldState(), + phoneNumberState = rememberTextFieldState(), + relationshipSelectedValue = "친구", + relationshipOptions = listOf("친구", "가족", "연인"), + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialogParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialogParams.kt similarity index 80% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialogParams.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialogParams.kt index 5bc2c9de..7d7bbfae 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialogParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AddAfternoteEditReceiverDialogParams.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver +package com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable @@ -12,7 +12,7 @@ data class AddAfternoteEditReceiverDialogParams( val phoneNumberState: TextFieldState, val relationshipSelectedValue: String, val relationshipOptions: List, - val callbacks: AddAfternoteEditReceiverDialogCallbacks = AddAfternoteEditReceiverDialogCallbacks() + val callbacks: AddAfternoteEditReceiverDialogCallbacks = AddAfternoteEditReceiverDialogCallbacks(), ) /** @@ -23,5 +23,5 @@ data class AddAfternoteEditReceiverDialogCallbacks( val onDismiss: () -> Unit = {}, val onAddClick: () -> Unit = {}, val onRelationshipSelected: (String) -> Unit = {}, - val onImportContactsClick: () -> Unit = {} + val onImportContactsClick: () -> Unit = {}, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AfternoteEditReceiverList.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AfternoteEditReceiverList.kt similarity index 75% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AfternoteEditReceiverList.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AfternoteEditReceiverList.kt index 0793c110..88945f04 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AfternoteEditReceiverList.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AfternoteEditReceiverList.kt @@ -1,5 +1,4 @@ - -package com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver +package com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -28,12 +27,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kuit.afternote.R +import com.kuit.afternote.app.compositionlocal.DataProviderLocals import com.kuit.afternote.core.ui.component.button.AddCircleButton import com.kuit.afternote.core.ui.component.detail.EditDropdownMenu -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverCallbacks -import com.kuit.afternote.app.compositionlocal.DataProviderLocals -import com.kuit.afternote.data.provider.FakeAfternoteEditDataProvider +import com.kuit.afternote.domain.provider.FakeAfternoteEditDataProvider +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverCallbacks import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.Gray5 import com.kuit.afternote.ui.theme.Gray9 @@ -53,7 +52,7 @@ fun AfternoteEditReceiverList( modifier: Modifier = Modifier, afternoteEditReceivers: List, events: AfternoteEditReceiverCallbacks = AfternoteEditReceiverCallbacks(), - state: AfternoteEditReceiverListState = rememberAfternoteEditReceiverListState() + state: AfternoteEditReceiverListState = rememberAfternoteEditReceiverListState(), ) { val focusManager = LocalFocusManager.current @@ -63,11 +62,12 @@ fun AfternoteEditReceiverList( } Column( - modifier = modifier - .fillMaxWidth() - .background(color = White, shape = RoundedCornerShape(16.dp)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + modifier + .fillMaxWidth() + .background(color = White, shape = RoundedCornerShape(16.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { afternoteEditReceivers.forEachIndexed { _, receiver -> AfternoteEditReceiverItem( @@ -81,7 +81,7 @@ fun AfternoteEditReceiverList( state.expandedStates[receiver.id] = false }, showEditItem = false, - onDeleteClick = { events.onItemDeleteClick(receiver.id) } + onDeleteClick = { events.onItemDeleteClick(receiver.id) }, ) Spacer(modifier = Modifier.height(8.dp)) } @@ -94,7 +94,7 @@ fun AfternoteEditReceiverList( onClick = { state.toggleTextField() events.onAddClick() - } + }, ) } } @@ -116,13 +116,14 @@ private fun AfternoteEditReceiverItem( onMoreClick: () -> Unit = {}, onDismissDropdown: () -> Unit = {}, showEditItem: Boolean = true, - onDeleteClick: () -> Unit = {} + onDeleteClick: () -> Unit = {}, ) { Row( - modifier = modifier - .fillMaxWidth(), + modifier = + modifier + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { // 아바타 (기본 프로필 이미지) Image( @@ -137,23 +138,25 @@ private fun AfternoteEditReceiverItem( ) { Text( text = receiver.name, - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Text( text = receiver.label, - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray5 - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray5, + ), ) } @@ -162,14 +165,15 @@ private fun AfternoteEditReceiverItem( Image( painter = painterResource(R.drawable.ic_more_horizontal_1), contentDescription = "더보기", - modifier = Modifier - .clickable(onClick = onMoreClick) + modifier = + Modifier + .clickable(onClick = onMoreClick), ) EditDropdownMenu( expanded = expanded, onDismissRequest = onDismissDropdown, showEditItem = showEditItem, - onDeleteClick = onDeleteClick + onDeleteClick = onDeleteClick, ) } } @@ -177,16 +181,15 @@ private fun AfternoteEditReceiverItem( @Preview(showBackground = true) @Composable - private fun AfternoteEditReceiverListPreview() { AfternoteTheme { CompositionLocalProvider( - DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider() + DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider(), ) { val provider = DataProviderLocals.LocalAfternoteEditDataProvider.current AfternoteEditReceiverList( afternoteEditReceivers = provider.getAfternoteEditReceivers(), - events = AfternoteEditReceiverCallbacks() + events = AfternoteEditReceiverCallbacks(), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AfternoteEditReceiverListState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AfternoteEditReceiverListState.kt similarity index 87% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AfternoteEditReceiverListState.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AfternoteEditReceiverListState.kt index d4248eb2..26f00501 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/afternoteeditreceiver/AfternoteEditReceiverListState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/afternoteeditreceiver/AfternoteEditReceiverListState.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver +package com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver /** * AfternoteEditReceiverList의 상태를 관리하는 State Holder @@ -26,7 +26,7 @@ class AfternoteEditReceiverListState( */ fun initializeExpandedStates( afternoteEditReceivers: List, - initialExpandedItemId: String? + initialExpandedItemId: String?, ) { afternoteEditReceivers.forEach { receiver -> if (!expandedStates.containsKey(receiver.id)) { diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/GalleryAndFileEditContent.kt similarity index 63% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContent.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/GalleryAndFileEditContent.kt index c909589d..ad68bf2b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/GalleryAndFileEditContent.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.content +package com.kuit.afternote.feature.afternote.presentation.edit.content import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -15,16 +15,16 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.kuit.afternote.app.compositionlocal.DataProviderLocals import com.kuit.afternote.core.ui.component.Label import com.kuit.afternote.core.ui.component.LabelStyle import com.kuit.afternote.core.ui.component.Multiline import com.kuit.afternote.core.ui.component.OutlineTextField -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.ProcessingMethodList -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.ProcessingMethodListParams -import com.kuit.afternote.app.compositionlocal.DataProviderLocals -import com.kuit.afternote.data.provider.FakeAfternoteEditDataProvider +import com.kuit.afternote.domain.provider.FakeAfternoteEditDataProvider +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodSection +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.ProcessingMethodList +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.ProcessingMethodListParams import com.kuit.afternote.ui.theme.AfternoteTheme /** @@ -34,7 +34,7 @@ import com.kuit.afternote.ui.theme.AfternoteTheme fun GalleryAndFileEditContent( modifier: Modifier = Modifier, bottomPadding: PaddingValues, - params: GalleryAndFileEditContentParams + params: GalleryAndFileEditContentParams, ) { val density = LocalDensity.current val windowInfo = LocalWindowInfo.current @@ -42,15 +42,16 @@ fun GalleryAndFileEditContent( val bottomPaddingDp = bottomPadding.calculateBottomPadding() // Viewport 높이 = 창 높이 - bottomPadding (네비게이션 바 상단까지의 높이) // 하단 여백은 네비게이션 바 상단까지의 Viewport 높이의 10%로 계산 - val viewportHeight = with(density) { - windowInfo.containerSize.height.toDp() - bottomPaddingDp - } + val viewportHeight = + with(density) { + windowInfo.containerSize.height.toDp() - bottomPaddingDp + } val spacerHeight = viewportHeight * 0.1f GalleryAndFileEditContentBody( modifier = modifier, params = params, - spacerHeight = spacerHeight + spacerHeight = spacerHeight, ) } @@ -58,7 +59,7 @@ fun GalleryAndFileEditContent( private fun GalleryAndFileEditContentBody( modifier: Modifier = Modifier, params: GalleryAndFileEditContentParams, - spacerHeight: Dp + spacerHeight: Dp, ) { Column(modifier = modifier.fillMaxWidth()) { RecipientDesignationSection(section = params.recipientSection) @@ -69,19 +70,20 @@ private fun GalleryAndFileEditContentBody( Label( text = "처리 방법 리스트", isRequired = true, - style = LabelStyle(requiredDotOffsetY = 2.dp) + style = LabelStyle(requiredDotOffsetY = 2.dp), ) Spacer(modifier = Modifier.height(20.dp)) ProcessingMethodList( - params = ProcessingMethodListParams( - items = params.processingMethodSection.items, - onItemDeleteClick = params.processingMethodSection.callbacks.onItemDeleteClick, - onItemAdded = params.processingMethodSection.callbacks.onItemAdded, - onTextFieldVisibilityChanged = params.processingMethodSection.callbacks.onTextFieldVisibilityChanged, - onItemEdited = params.processingMethodSection.callbacks.onItemEdited - ) + params = + ProcessingMethodListParams( + items = params.processingMethodSection.items, + onItemDeleteClick = params.processingMethodSection.callbacks.onItemDeleteClick, + onItemAdded = params.processingMethodSection.callbacks.onItemAdded, + onTextFieldVisibilityChanged = params.processingMethodSection.callbacks.onTextFieldVisibilityChanged, + onItemEdited = params.processingMethodSection.callbacks.onItemEdited, + ), ) Spacer(modifier = Modifier.height(32.dp)) @@ -90,7 +92,7 @@ private fun GalleryAndFileEditContentBody( OutlineTextField( label = "남기실 말씀", textFieldState = params.messageState, - multiline = Multiline + multiline = Multiline, ) // 갤러리 및 파일 탭 하단 여백 (Viewport 높이의 10%, 800dp 기준 약 80dp) @@ -104,18 +106,20 @@ private fun GalleryAndFileEditContentBody( private fun GalleryAndFileEditContentPreview() { AfternoteTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), ) { // 첫 번째 옵션 선택됨 (파란 테두리), 두 번째는 선택 안 됨 (테두리 없음) 상태를 한 화면에 표시 GalleryAndFileEditContent( bottomPadding = PaddingValues(bottom = 88.dp), - params = GalleryAndFileEditContentParams( - messageState = rememberTextFieldState(), - recipientSection = AfternoteEditReceiverSection(), - processingMethodSection = ProcessingMethodSection() - ) + params = + GalleryAndFileEditContentParams( + messageState = rememberTextFieldState(), + recipientSection = AfternoteEditReceiverSection(), + processingMethodSection = ProcessingMethodSection(), + ), ) } } @@ -126,22 +130,25 @@ private fun GalleryAndFileEditContentPreview() { private fun GalleryAndFileEditContentWithAfternoteEditReceiversPreview() { AfternoteTheme { CompositionLocalProvider( - DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider() + DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider(), ) { val provider = DataProviderLocals.LocalAfternoteEditDataProvider.current Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), ) { GalleryAndFileEditContent( bottomPadding = PaddingValues(bottom = 88.dp), - params = GalleryAndFileEditContentParams( - messageState = rememberTextFieldState(), - recipientSection = AfternoteEditReceiverSection( - afternoteEditReceivers = provider.getAfternoteEditReceivers() - ) - ) + params = + GalleryAndFileEditContentParams( + messageState = rememberTextFieldState(), + recipientSection = + AfternoteEditReceiverSection( + afternoteEditReceivers = provider.getAfternoteEditReceivers(), + ), + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContentParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/GalleryAndFileEditContentParams.kt similarity index 56% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContentParams.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/GalleryAndFileEditContentParams.kt index 69db5aa7..005d9446 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContentParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/GalleryAndFileEditContentParams.kt @@ -1,9 +1,9 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.content +package com.kuit.afternote.feature.afternote.presentation.edit.content import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodSection /** * GalleryAndFileEditContent 함수의 매개변수를 묶는 data class @@ -12,5 +12,5 @@ import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Pr data class GalleryAndFileEditContentParams( val messageState: TextFieldState, val recipientSection: AfternoteEditReceiverSection, - val processingMethodSection: ProcessingMethodSection = ProcessingMethodSection() + val processingMethodSection: ProcessingMethodSection = ProcessingMethodSection(), ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/MemorialGuidelineEditContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/MemorialGuidelineEditContent.kt new file mode 100644 index 00000000..b9d045fd --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/MemorialGuidelineEditContent.kt @@ -0,0 +1,127 @@ +package com.kuit.afternote.feature.afternote.presentation.edit.content + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kuit.afternote.core.ui.component.LastWishOption +import com.kuit.afternote.core.ui.component.LastWishOtherState +import com.kuit.afternote.core.ui.component.LastWishesRadioGroup +import com.kuit.afternote.core.ui.component.content.MemorialGuidelineContent +import com.kuit.afternote.core.ui.component.content.MemorialGuidelineSlots +import com.kuit.afternote.core.ui.component.list.MemorialPlaylist +import com.kuit.afternote.feature.afternote.presentation.edit.memorial.LastMomentQuestion +import com.kuit.afternote.feature.afternote.presentation.edit.upload.FuneralVideoUpload +import com.kuit.afternote.feature.afternote.presentation.edit.upload.MemorialPhotoUpload +import com.kuit.afternote.ui.theme.AfternoteTheme + +/** + * 추모 가이드라인 종류 선택 시 표시되는 콘텐츠 (편집 모드). + * [MemorialGuidelineContent] 공통 레이아웃을 사용하고, 슬롯에 편집용 컴포넌트를 채움. + */ +@Composable +fun MemorialGuidelineEditContent( + modifier: Modifier = Modifier, + bottomPadding: PaddingValues, + params: MemorialGuidelineEditContentParams, +) { + val density = LocalDensity.current + val windowInfo = LocalWindowInfo.current + val bottomPaddingDp = bottomPadding.calculateBottomPadding() + val viewportHeight = + with(density) { + windowInfo.containerSize.height.toDp() - bottomPaddingDp + } + val trailingSpacerHeight = viewportHeight * 0.1f + + MemorialGuidelineContent( + modifier = modifier, + slots = + MemorialGuidelineSlots( + introContent = { LastMomentQuestion() }, + photoContent = { + MemorialPhotoUpload( + displayImageUri = params.displayMemorialPhotoUri, + onAddPhotoClick = params.onPhotoAddClick, + ) + }, + playlistContent = { + MemorialPlaylist( + songCount = params.playlistSongCount, + albumCovers = params.playlistAlbumCovers, + onAddSongClick = params.onSongAddClick, + ) + }, + lastWishContent = { + LastWishesRadioGroup( + options = params.lastWishOptions, + selectedValue = params.selectedLastWish, + onOptionSelected = params.onLastWishSelected, + otherState = + LastWishOtherState( + text = params.customLastWishText, + onTextChange = params.onCustomLastWishChanged, + ), + ) + }, + recipientContent = { + params.recipientSection?.let { RecipientDesignationSection(section = it) } + }, + videoContent = { + FuneralVideoUpload( + videoUrl = params.funeralVideoUrl, + thumbnailUrl = params.funeralThumbnailUrl, + onAddVideoClick = params.onVideoAddClick, + onThumbnailBytesReady = params.onThumbnailBytesReady, + ) + }, + ), + sectionSpacing = 32.dp, + trailingSpacerHeight = trailingSpacerHeight, + ) +} + +@Preview(showBackground = true) +@Composable +private fun MemorialGuidelineEditContentPreview() { + AfternoteTheme { + val lastWishOptions = + listOf( + LastWishOption( + text = "차분하고 조용하게 보내주세요.", + value = "calm", + ), + LastWishOption( + text = "슬퍼 하지 말고 밝고 따뜻하게 보내주세요.", + value = "bright", + ), + LastWishOption( + text = "기타(직접 입력)", + value = "other", + ), + ) + + MemorialGuidelineEditContent( + bottomPadding = PaddingValues(bottom = 88.dp), + params = + MemorialGuidelineEditContentParams( + displayMemorialPhotoUri = null, + playlistSongCount = 16, + playlistAlbumCovers = emptyList(), + selectedLastWish = "calm", + lastWishOptions = lastWishOptions, + funeralVideoUrl = null, + customLastWishText = "", + onSongAddClick = {}, + onLastWishSelected = {}, + onCustomLastWishChanged = {}, + onPhotoAddClick = {}, + onVideoAddClick = {}, + onThumbnailBytesReady = {}, + ), + ) + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContentParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/MemorialGuidelineEditContentParams.kt similarity index 78% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContentParams.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/MemorialGuidelineEditContentParams.kt index b49c7d6b..701fe0de 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContentParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/MemorialGuidelineEditContentParams.kt @@ -1,9 +1,9 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.content +package com.kuit.afternote.feature.afternote.presentation.edit.content import androidx.compose.runtime.Immutable import com.kuit.afternote.core.ui.component.LastWishOption import com.kuit.afternote.core.ui.component.list.AlbumCover -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection /** * 추모 가이드라인 콘텐츠 파라미터 @@ -24,5 +24,5 @@ data class MemorialGuidelineEditContentParams( val onCustomLastWishChanged: (String) -> Unit, val onPhotoAddClick: () -> Unit, val onVideoAddClick: () -> Unit, - val onThumbnailBytesReady: (ByteArray?) -> Unit = {} + val onThumbnailBytesReady: (ByteArray?) -> Unit = {}, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/RecipientDesignationSection.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/RecipientDesignationSection.kt similarity index 62% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/RecipientDesignationSection.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/RecipientDesignationSection.kt index 34544268..2c7861a6 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/RecipientDesignationSection.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/RecipientDesignationSection.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.content +package com.kuit.afternote.feature.afternote.presentation.edit.content import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -10,9 +10,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kuit.afternote.core.ui.component.Label import com.kuit.afternote.core.ui.component.LabelStyle -import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AfternoteEditReceiverList -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverCallbacks -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.feature.afternote.presentation.edit.afternoteeditreceiver.AfternoteEditReceiverList +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverCallbacks +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection import com.kuit.afternote.ui.theme.AfternoteTheme /** @@ -22,18 +22,18 @@ import com.kuit.afternote.ui.theme.AfternoteTheme @Composable fun RecipientDesignationSection( modifier: Modifier = Modifier, - section: AfternoteEditReceiverSection + section: AfternoteEditReceiverSection, ) { Column(modifier = modifier.fillMaxWidth()) { Label( text = "수신자 지정", isRequired = true, - style = LabelStyle(requiredDotOffsetY = 3.dp) + style = LabelStyle(requiredDotOffsetY = 3.dp), ) Spacer(modifier = Modifier.height(9.dp)) AfternoteEditReceiverList( afternoteEditReceivers = section.afternoteEditReceivers, - events = section.callbacks + events = section.callbacks, ) } } @@ -43,10 +43,11 @@ fun RecipientDesignationSection( private fun RecipientDesignationSectionPreview() { AfternoteTheme { RecipientDesignationSection( - section = AfternoteEditReceiverSection( - afternoteEditReceivers = emptyList(), - callbacks = AfternoteEditReceiverCallbacks() - ) + section = + AfternoteEditReceiverSection( + afternoteEditReceivers = emptyList(), + callbacks = AfternoteEditReceiverCallbacks(), + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/SocialNetworkEditContent.kt similarity index 69% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContent.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/SocialNetworkEditContent.kt index 20396552..0517d47c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/SocialNetworkEditContent.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.content +package com.kuit.afternote.feature.afternote.presentation.edit.content import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -17,14 +17,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.kuit.afternote.core.ui.component.Label import com.kuit.afternote.core.ui.component.LabelStyle -import com.kuit.afternote.core.ui.component.SelectableRadioCard import com.kuit.afternote.core.ui.component.Multiline import com.kuit.afternote.core.ui.component.OutlineTextField -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountProcessingMethod -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.OptionRadioCardContent -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.ProcessingMethodList -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.ProcessingMethodListParams +import com.kuit.afternote.core.ui.component.SelectableRadioCard +import com.kuit.afternote.feature.afternote.presentation.edit.model.AccountProcessingMethod +import com.kuit.afternote.feature.afternote.presentation.edit.model.AccountSection +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.OptionRadioCardContent +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.ProcessingMethodList +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.ProcessingMethodListParams import com.kuit.afternote.ui.theme.AfternoteTheme /** @@ -34,7 +34,7 @@ import com.kuit.afternote.ui.theme.AfternoteTheme fun SocialNetworkEditContent( modifier: Modifier = Modifier, bottomPadding: PaddingValues, - params: SocialNetworkEditContentParams + params: SocialNetworkEditContentParams, ) { val density = LocalDensity.current val windowInfo = LocalWindowInfo.current @@ -42,15 +42,16 @@ fun SocialNetworkEditContent( val bottomPaddingDp = bottomPadding.calculateBottomPadding() // Viewport 높이 = 창 높이 - bottomPadding (네비게이션 바 상단까지의 높이) // 하단 여백은 네비게이션 바 상단까지의 Viewport 높이의 10%로 계산 - val viewportHeight = with(density) { - windowInfo.containerSize.height.toDp() - bottomPaddingDp - } + val viewportHeight = + with(density) { + windowInfo.containerSize.height.toDp() - bottomPaddingDp + } val spacerHeight = viewportHeight * 0.1f SocialNetworkEditContentContent( modifier = modifier, params = params, - spacerHeight = spacerHeight + spacerHeight = spacerHeight, ) } @@ -58,14 +59,14 @@ fun SocialNetworkEditContent( private fun SocialNetworkEditContentContent( modifier: Modifier = Modifier, params: SocialNetworkEditContentParams, - spacerHeight: Dp + spacerHeight: Dp, ) { Column(modifier = modifier.fillMaxWidth()) { // 계정 정보 섹션 Label( text = "계정 정보", isRequired = true, - style = LabelStyle(requiredDotOffsetY = 4.dp) + style = LabelStyle(requiredDotOffsetY = 4.dp), ) Spacer(modifier = Modifier.height(16.dp)) @@ -74,7 +75,7 @@ private fun SocialNetworkEditContentContent( modifier = Modifier, label = "아이디", textFieldState = params.accountSection.idState, - keyboardType = KeyboardType.Text + keyboardType = KeyboardType.Text, ) Spacer(modifier = Modifier.height(10.dp)) @@ -83,7 +84,7 @@ private fun SocialNetworkEditContentContent( modifier = Modifier, label = "비밀번호", textFieldState = params.accountSection.passwordState, - keyboardType = KeyboardType.Password + keyboardType = KeyboardType.Password, ) Spacer(modifier = Modifier.height(32.dp)) @@ -92,7 +93,7 @@ private fun SocialNetworkEditContentContent( Label( text = "계정 처리 방법", isRequired = true, - style = LabelStyle(requiredDotOffsetY = 2.dp) + style = LabelStyle(requiredDotOffsetY = 2.dp), ) Spacer(modifier = Modifier.height(24.dp)) @@ -104,9 +105,9 @@ private fun SocialNetworkEditContentContent( content = { OptionRadioCardContent( option = AccountProcessingMethod.MEMORIAL_ACCOUNT, - selected = params.accountSection.selectedMethod == AccountProcessingMethod.MEMORIAL_ACCOUNT + selected = params.accountSection.selectedMethod == AccountProcessingMethod.MEMORIAL_ACCOUNT, ) - } + }, ) Spacer(modifier = Modifier.height(8.dp)) @@ -118,9 +119,9 @@ private fun SocialNetworkEditContentContent( content = { OptionRadioCardContent( option = AccountProcessingMethod.PERMANENT_DELETE, - selected = params.accountSection.selectedMethod == AccountProcessingMethod.PERMANENT_DELETE + selected = params.accountSection.selectedMethod == AccountProcessingMethod.PERMANENT_DELETE, ) - } + }, ) Spacer(modifier = Modifier.height(8.dp)) @@ -132,9 +133,9 @@ private fun SocialNetworkEditContentContent( content = { OptionRadioCardContent( option = AccountProcessingMethod.TRANSFER_TO_RECEIVER, - selected = params.accountSection.selectedMethod == AccountProcessingMethod.TRANSFER_TO_RECEIVER + selected = params.accountSection.selectedMethod == AccountProcessingMethod.TRANSFER_TO_RECEIVER, ) - } + }, ) Spacer(modifier = Modifier.height(32.dp)) @@ -148,19 +149,20 @@ private fun SocialNetworkEditContentContent( Label( text = "처리 방법 리스트", isRequired = true, - style = LabelStyle(requiredDotOffsetY = 2.dp) + style = LabelStyle(requiredDotOffsetY = 2.dp), ) Spacer(modifier = Modifier.height(16.dp)) ProcessingMethodList( - params = ProcessingMethodListParams( - items = params.processingMethodSection.items, - onItemDeleteClick = params.processingMethodSection.callbacks.onItemDeleteClick, - onItemAdded = params.processingMethodSection.callbacks.onItemAdded, - onTextFieldVisibilityChanged = params.processingMethodSection.callbacks.onTextFieldVisibilityChanged, - onItemEdited = params.processingMethodSection.callbacks.onItemEdited - ) + params = + ProcessingMethodListParams( + items = params.processingMethodSection.items, + onItemDeleteClick = params.processingMethodSection.callbacks.onItemDeleteClick, + onItemAdded = params.processingMethodSection.callbacks.onItemAdded, + onTextFieldVisibilityChanged = params.processingMethodSection.callbacks.onTextFieldVisibilityChanged, + onItemEdited = params.processingMethodSection.callbacks.onItemEdited, + ), ) Spacer(modifier = Modifier.height(32.dp)) @@ -169,7 +171,7 @@ private fun SocialNetworkEditContentContent( OutlineTextField( label = "남기실 말씀", textFieldState = params.messageState, - multiline = Multiline + multiline = Multiline, ) // 소셜네트워크 탭 하단 여백 (Viewport 높이의 10%, 800dp 기준 약 80dp) @@ -183,22 +185,25 @@ private fun SocialNetworkEditContentContent( private fun SocialNetworkEditContentPreview() { AfternoteTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), ) { // 첫 번째 옵션 선택됨 (파란 테두리), 나머지는 선택 안 됨 (테두리 없음) 상태를 한 화면에 표시 SocialNetworkEditContent( bottomPadding = PaddingValues(bottom = 88.dp), - params = SocialNetworkEditContentParams( - messageState = rememberTextFieldState(), - accountSection = AccountSection( - idState = rememberTextFieldState(), - passwordState = rememberTextFieldState(), - selectedMethod = AccountProcessingMethod.MEMORIAL_ACCOUNT, - onMethodSelected = {} - ) - ) + params = + SocialNetworkEditContentParams( + messageState = rememberTextFieldState(), + accountSection = + AccountSection( + idState = rememberTextFieldState(), + passwordState = rememberTextFieldState(), + selectedMethod = AccountProcessingMethod.MEMORIAL_ACCOUNT, + onMethodSelected = {}, + ), + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContentParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/SocialNetworkEditContentParams.kt similarity index 52% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContentParams.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/SocialNetworkEditContentParams.kt index 9648017d..24d54695 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContentParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/content/SocialNetworkEditContentParams.kt @@ -1,10 +1,10 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.content +package com.kuit.afternote.feature.afternote.presentation.edit.content import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.AccountSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodSection /** * SocialNetworkEditContent 함수의 매개변수를 묶는 data class @@ -14,5 +14,5 @@ data class SocialNetworkEditContentParams( val messageState: TextFieldState, val accountSection: AccountSection, val recipientSection: AfternoteEditReceiverSection? = null, - val processingMethodSection: ProcessingMethodSection = ProcessingMethodSection() + val processingMethodSection: ProcessingMethodSection = ProcessingMethodSection(), ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/dropdown/SelectionDropdown.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/dropdown/SelectionDropdown.kt similarity index 62% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/dropdown/SelectionDropdown.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/dropdown/SelectionDropdown.kt index e1df2631..2917fee8 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/dropdown/SelectionDropdown.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/dropdown/SelectionDropdown.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown +package com.kuit.afternote.feature.afternote.presentation.edit.dropdown import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -51,12 +51,13 @@ private const val CATEGORY_MEMORIAL_PREVIEW = "추모 가이드라인" data class SelectionDropdownLabelParams( val label: String, val isRequired: Boolean = false, - val labelStyle: LabelStyle = LabelStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.Normal, - requiredDotOffsetY = 2.dp - ) + val labelStyle: LabelStyle = + LabelStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Normal, + requiredDotOffsetY = 2.dp, + ), ) /** @@ -71,7 +72,7 @@ data class DropdownMenuStyle( val menuOffset: Dp = 4.dp, val menuBackgroundColor: Color = White, val shadowElevation: Dp = 0.dp, - val tonalElevation: Dp = 0.dp + val tonalElevation: Dp = 0.dp, ) /** @@ -100,59 +101,62 @@ fun SelectionDropdown( options: List, onValueSelected: (String) -> Unit, menuStyle: DropdownMenuStyle = DropdownMenuStyle(), - state: SelectionDropdownState = rememberSelectionDropdownState() + state: SelectionDropdownState = rememberSelectionDropdownState(), ) { val density = LocalDensity.current Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - space = 8.dp - ) + verticalArrangement = + Arrangement.spacedBy( + space = 8.dp, + ), ) { // 라벨 Label( text = labelParams.label, isRequired = labelParams.isRequired, - style = labelParams.labelStyle + style = labelParams.labelStyle, ) // 드롭다운 필드 Box( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Box( - modifier = Modifier - .fillMaxWidth() + modifier = + Modifier + .fillMaxWidth() // .height(36.dp) - .onGloballyPositioned { coordinates -> - val newWidth = with(density) { coordinates.size.width.toDp() } - if (newWidth != state.boxWidth) { - state.boxWidth = newWidth - } - }.clickable { state.expanded = !state.expanded } - .bottomBorder(color = Gray3, width = 0.5.dp) - .padding(all = 8.dp) + .onGloballyPositioned { coordinates -> + val newWidth = with(density) { coordinates.size.width.toDp() } + if (newWidth != state.boxWidth) { + state.boxWidth = newWidth + } + }.clickable { state.expanded = !state.expanded } + .bottomBorder(color = Gray3, width = 0.5.dp) + .padding(all = 8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = selectedValue, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray8 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray8, + ), ) Image( painter = painterResource(R.drawable.ic_dropdown_vector), - contentDescription = "드롭다운" + contentDescription = "드롭다운", ) } } @@ -165,30 +169,32 @@ fun SelectionDropdown( containerColor = menuStyle.menuBackgroundColor, shadowElevation = menuStyle.shadowElevation, tonalElevation = menuStyle.tonalElevation, - modifier = Modifier - .then(if (state.boxWidth > 0.dp) Modifier.width(state.boxWidth) else Modifier.fillMaxWidth()) + modifier = + Modifier + .then(if (state.boxWidth > 0.dp) Modifier.width(state.boxWidth) else Modifier.fillMaxWidth()), ) { options.forEach { option -> DropdownMenuItem( text = { Text( text = option, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9, - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), ) }, onClick = { onValueSelected(option) state.expanded = false }, - contentPadding = PaddingValues(vertical = 16.dp) + contentPadding = PaddingValues(vertical = 16.dp), ) } } @@ -203,12 +209,13 @@ private fun SelectionDropdownPreview() { SelectionDropdown( labelParams = SelectionDropdownLabelParams(label = "종류"), selectedValue = CATEGORY_SOCIAL_NETWORK, - options = listOf( - CATEGORY_SOCIAL_NETWORK, - CATEGORY_GALLERY_AND_FILE_PREVIEW, - CATEGORY_MEMORIAL_PREVIEW - ), - onValueSelected = {} + options = + listOf( + CATEGORY_SOCIAL_NETWORK, + CATEGORY_GALLERY_AND_FILE_PREVIEW, + CATEGORY_MEMORIAL_PREVIEW, + ), + onValueSelected = {}, ) } } @@ -218,33 +225,36 @@ private fun SelectionDropdownPreview() { private fun SelectionDropdownRequiredLabelPreview() { AfternoteTheme { SelectionDropdown( - labelParams = SelectionDropdownLabelParams( - label = "관계", - isRequired = true, - labelStyle = LabelStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontWeight = FontWeight.Medium - ) - ), + labelParams = + SelectionDropdownLabelParams( + label = "관계", + isRequired = true, + labelStyle = + LabelStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, + ), + ), selectedValue = "딸", options = listOf("딸", "아들", "친구", "가족"), - onValueSelected = {} + onValueSelected = {}, ) } } @Preview( showBackground = true, - name = "펼쳐진 드롭다운 메뉴 (기본)" + name = "펼쳐진 드롭다운 메뉴 (기본)", ) @Composable private fun ExpandedDropdownMenuPreview() { AfternoteTheme { Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { DropdownMenu( expanded = true, @@ -253,29 +263,30 @@ private fun ExpandedDropdownMenuPreview() { containerColor = White, shadowElevation = 0.dp, tonalElevation = 0.dp, - modifier = Modifier.width(200.dp) + modifier = Modifier.width(200.dp), ) { listOf( CATEGORY_SOCIAL_NETWORK, CATEGORY_GALLERY_AND_FILE_PREVIEW, - CATEGORY_MEMORIAL_PREVIEW + CATEGORY_MEMORIAL_PREVIEW, ).forEach { option -> DropdownMenuItem( text = { Text( text = option, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9, - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), ) }, - onClick = {} + onClick = {}, ) } } @@ -285,15 +296,16 @@ private fun ExpandedDropdownMenuPreview() { @Preview( showBackground = true, - name = "펼쳐진 드롭다운 메뉴 (높은 Elevation)" + name = "펼쳐진 드롭다운 메뉴 (높은 Elevation)", ) @Composable private fun ExpandedDropdownMenuWithElevationPreview() { AfternoteTheme { Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { DropdownMenu( expanded = true, @@ -302,25 +314,26 @@ private fun ExpandedDropdownMenuWithElevationPreview() { containerColor = White, shadowElevation = 10.dp, tonalElevation = 10.dp, - modifier = Modifier.width(200.dp) + modifier = Modifier.width(200.dp), ) { listOf("인스타그램", "페이스북", "트위터", "링크드인").forEach { option -> DropdownMenuItem( text = { Text( text = option, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9, - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), ) }, - onClick = {} + onClick = {}, ) } } @@ -330,15 +343,16 @@ private fun ExpandedDropdownMenuWithElevationPreview() { @Preview( showBackground = true, - name = "펼쳐진 드롭다운 메뉴 (다이얼로그 스타일)" + name = "펼쳐진 드롭다운 메뉴 (다이얼로그 스타일)", ) @Composable private fun ExpandedDropdownMenuInDialogPreview() { AfternoteTheme { Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { DropdownMenu( expanded = true, @@ -347,25 +361,26 @@ private fun ExpandedDropdownMenuInDialogPreview() { containerColor = Gray1, shadowElevation = 0.dp, tonalElevation = 0.dp, - modifier = Modifier.width(200.dp) + modifier = Modifier.width(200.dp), ) { listOf("친구", "가족", "연인", "동료").forEach { option -> DropdownMenuItem( text = { Text( text = option, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9, - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), ) }, - onClick = {} + onClick = {}, ) } } @@ -375,15 +390,16 @@ private fun ExpandedDropdownMenuInDialogPreview() { @Preview( showBackground = true, - name = "펼쳐진 드롭다운 메뉴 (긴 리스트)" + name = "펼쳐진 드롭다운 메뉴 (긴 리스트)", ) @Composable private fun ExpandedDropdownMenuLongListPreview() { AfternoteTheme { Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { DropdownMenu( expanded = true, @@ -392,7 +408,7 @@ private fun ExpandedDropdownMenuLongListPreview() { containerColor = White, shadowElevation = 0.dp, tonalElevation = 0.dp, - modifier = Modifier.width(200.dp) + modifier = Modifier.width(200.dp), ) { listOf( "카카오톡", @@ -402,24 +418,25 @@ private fun ExpandedDropdownMenuLongListPreview() { "링크드인", "텔레그램", "디스코드", - "슬랙" + "슬랙", ).forEach { option -> DropdownMenuItem( text = { Text( text = option, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9, - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), ) }, - onClick = {} + onClick = {}, ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/dropdown/SelectionDropdownState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/dropdown/SelectionDropdownState.kt similarity index 83% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/dropdown/SelectionDropdownState.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/dropdown/SelectionDropdownState.kt index 96281408..50e99b5e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/dropdown/SelectionDropdownState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/dropdown/SelectionDropdownState.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown +package com.kuit.afternote.feature.afternote.presentation.edit.dropdown import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -16,7 +16,7 @@ import androidx.compose.ui.unit.dp @Stable class SelectionDropdownState( initialExpanded: Boolean = false, - initialBoxWidth: Dp = 0.dp + initialBoxWidth: Dp = 0.dp, ) { var expanded by mutableStateOf(initialExpanded) @@ -29,11 +29,11 @@ class SelectionDropdownState( @Composable fun rememberSelectionDropdownState( initialExpanded: Boolean = false, - initialBoxWidth: Dp = 0.dp + initialBoxWidth: Dp = 0.dp, ): SelectionDropdownState = remember { SelectionDropdownState( initialExpanded = initialExpanded, - initialBoxWidth = initialBoxWidth + initialBoxWidth = initialBoxWidth, ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/memorial/LastMomentQuestion.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/memorial/LastMomentQuestion.kt similarity index 70% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/memorial/LastMomentQuestion.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/memorial/LastMomentQuestion.kt index 4b525374..8abdaf4e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/memorial/LastMomentQuestion.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/memorial/LastMomentQuestion.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.memorial +package com.kuit.afternote.feature.afternote.presentation.edit.memorial import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -25,20 +25,21 @@ import com.kuit.afternote.ui.theme.Sansneo @Composable fun LastMomentQuestion( modifier: Modifier = Modifier, - text: String = "마지막 순간,\n당신을 어떤 모습으로 기억해 주길 바라나요?" + text: String = "마지막 순간,\n당신을 어떤 모습으로 기억해 주길 바라나요?", ) { Column( - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth(), ) { Text( text = text, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) } } @@ -48,7 +49,7 @@ fun LastMomentQuestion( private fun LastMomentQuestionPreview() { AfternoteTheme { LastMomentQuestion( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(20.dp), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AccountProcessingMethod.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AccountProcessingMethod.kt similarity index 73% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AccountProcessingMethod.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AccountProcessingMethod.kt index f6154ba2..7a0f4517 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AccountProcessingMethod.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AccountProcessingMethod.kt @@ -1,22 +1,22 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model /** * 계정 처리 방법 라디오 버튼 옵션 */ enum class AccountProcessingMethod( override val title: String, - override val description: String + override val description: String, ) : ProcessingMethodOption { MEMORIAL_ACCOUNT( "추모 계정으로 전환", - "가족과 친구들이 당신을 추억할 수 있도록 계정을 유지하고, 추모 라벨을 붙여 계정을 보존합니다." + "가족과 친구들이 당신을 추억할 수 있도록 계정을 유지하고, 추모 라벨을 붙여 계정을 보존합니다.", ), PERMANENT_DELETE( "계정 영구 삭제", - "모든 사진과 게시물을 완전히 삭제하고 계정을 영구히 폐쇄합니다." + "모든 사진과 게시물을 완전히 삭제하고 계정을 영구히 폐쇄합니다.", ), TRANSFER_TO_RECEIVER( "수신자에게 정보 전달", - "수신자가 직접 로그인하여 계정을 관리할 수 있도록 계정 정보를 전달합니다." - ) + "수신자가 직접 로그인하여 계정을 관리할 수 있도록 계정 정보를 전달합니다.", + ), } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AccountSection.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AccountSection.kt similarity index 68% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AccountSection.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AccountSection.kt index f8f52465..615130d0 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AccountSection.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AccountSection.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable @@ -11,5 +11,5 @@ data class AccountSection( val idState: TextFieldState, val passwordState: TextFieldState, val selectedMethod: AccountProcessingMethod, - val onMethodSelected: (AccountProcessingMethod) -> Unit + val onMethodSelected: (AccountProcessingMethod) -> Unit, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiver.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiver.kt similarity index 54% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiver.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiver.kt index 24ced7ce..d2be6f44 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiver.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiver.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model /** * 추가 수신자 정보 모델 @@ -6,5 +6,5 @@ package com.kuit.afternote.feature.afternote.presentation.component.edit.model data class AfternoteEditReceiver( val id: String, val name: String, - val label: String + val label: String, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiverCallbacks.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiverCallbacks.kt similarity index 67% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiverCallbacks.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiverCallbacks.kt index 82f05470..75ea62ab 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiverCallbacks.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiverCallbacks.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model import androidx.compose.runtime.Immutable @@ -10,5 +10,5 @@ data class AfternoteEditReceiverCallbacks( val onAddClick: () -> Unit = {}, val onItemDeleteClick: (String) -> Unit = {}, val onItemAdded: (String) -> Unit = {}, - val onTextFieldVisibilityChanged: (Boolean) -> Unit = {} + val onTextFieldVisibilityChanged: (Boolean) -> Unit = {}, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiverSection.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiverSection.kt similarity index 72% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiverSection.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiverSection.kt index c5715cfb..d390633b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/AfternoteEditReceiverSection.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/AfternoteEditReceiverSection.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model import androidx.compose.runtime.Immutable @@ -8,5 +8,5 @@ import androidx.compose.runtime.Immutable @Immutable data class AfternoteEditReceiverSection( val afternoteEditReceivers: List = emptyList(), - val callbacks: AfternoteEditReceiverCallbacks = AfternoteEditReceiverCallbacks() + val callbacks: AfternoteEditReceiverCallbacks = AfternoteEditReceiverCallbacks(), ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/InfoMethodSection.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/InfoMethodSection.kt similarity index 55% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/InfoMethodSection.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/InfoMethodSection.kt index fdc27464..8d5c6f0a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/InfoMethodSection.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/InfoMethodSection.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model import androidx.compose.runtime.Immutable @@ -8,5 +8,5 @@ import androidx.compose.runtime.Immutable @Immutable data class InfoMethodSection( val selectedMethod: InformationProcessingMethod, - val onMethodSelected: (InformationProcessingMethod) -> Unit + val onMethodSelected: (InformationProcessingMethod) -> Unit, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/InformationProcessingMethod.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/InformationProcessingMethod.kt similarity index 75% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/InformationProcessingMethod.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/InformationProcessingMethod.kt index e042df71..94047529 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/InformationProcessingMethod.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/InformationProcessingMethod.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model /** * 정보 처리 방법 (갤러리 및 파일용). @@ -6,11 +6,11 @@ package com.kuit.afternote.feature.afternote.presentation.component.edit.model */ enum class InformationProcessingMethod( override val title: String, - override val description: String + override val description: String, ) : ProcessingMethodOption { /** 수신자 목록(설정)에 등록된 수신자에게 정보 전달 */ TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER( "수신자에게 정보 전달", - "수신자가 직접 로그인하여 정보를 관리할 수 있도록 계정 정보를 전달합니다." - ) + "수신자가 직접 로그인하여 정보를 관리할 수 있도록 계정 정보를 전달합니다.", + ), } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodCallbacks.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodCallbacks.kt similarity index 69% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodCallbacks.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodCallbacks.kt index 02f96621..60524d2e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodCallbacks.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodCallbacks.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model import androidx.compose.runtime.Immutable @@ -10,5 +10,5 @@ data class ProcessingMethodCallbacks( val onItemDeleteClick: (String) -> Unit = {}, val onItemAdded: (String) -> Unit = {}, val onTextFieldVisibilityChanged: (Boolean) -> Unit = {}, - val onItemEdited: (String, String) -> Unit = { _, _ -> } + val onItemEdited: (String, String) -> Unit = { _, _ -> }, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodItem.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodItem.kt similarity index 50% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodItem.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodItem.kt index b8bf33a3..b05191c7 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodItem.kt @@ -1,9 +1,9 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model /** * 처리 방법 리스트 아이템 */ data class ProcessingMethodItem( val id: String, - val text: String + val text: String, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodOption.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodOption.kt similarity index 72% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodOption.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodOption.kt index 460aba92..d2958030 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodOption.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodOption.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model /** * 처리 방법 옵션 인터페이스 diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodSection.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodSection.kt similarity index 74% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodSection.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodSection.kt index a86dcd4b..216f8055 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/ProcessingMethodSection.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/ProcessingMethodSection.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model import androidx.compose.runtime.Immutable @@ -8,5 +8,5 @@ import androidx.compose.runtime.Immutable @Immutable data class ProcessingMethodSection( val items: List = emptyList(), - val callbacks: ProcessingMethodCallbacks = ProcessingMethodCallbacks() + val callbacks: ProcessingMethodCallbacks = ProcessingMethodCallbacks(), ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/Song.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/Song.kt similarity index 60% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/Song.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/Song.kt index e71aea5c..ef60e6d3 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/model/Song.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/model/Song.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.model +package com.kuit.afternote.feature.afternote.presentation.edit.model import androidx.compose.runtime.Immutable @@ -10,5 +10,5 @@ data class Song( val id: String, val title: String, val artist: String, - val albumCoverUrl: String? = null + val albumCoverUrl: String? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/AddItemTextField.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/AddItemTextField.kt similarity index 71% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/AddItemTextField.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/AddItemTextField.kt index 93d15f6a..408222dc 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/AddItemTextField.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/AddItemTextField.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod +package com.kuit.afternote.feature.afternote.presentation.edit.processingmethod import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -46,7 +46,7 @@ fun AddItemTextField( visible: Boolean, onItemAdded: (String) -> Unit, onVisibilityChanged: (Boolean) -> Unit, - placeholder: String = "Text Field" + placeholder: String = "Text Field", ) { val textFieldState = rememberTextFieldState() val focusManager = LocalFocusManager.current @@ -78,10 +78,11 @@ fun AddItemTextField( Spacer(modifier = Modifier.height(7.dp)) // 텍스트 필드 컨테이너 (하단 보더 포함) Box( - modifier = modifier - .fillMaxWidth() - .bottomBorder(color = Gray2, width = 1.dp) - .padding(horizontal = 8.dp, vertical = 4.dp) + modifier = + modifier + .fillMaxWidth() + .bottomBorder(color = Gray2, width = 1.dp) + .padding(horizontal = 8.dp, vertical = 4.dp), ) { OutlinedTextField( state = textFieldState, @@ -89,33 +90,37 @@ fun AddItemTextField( placeholder = { Text( text = placeholder, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray4 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray4, + ), ) }, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedContainerColor = Color.Transparent - ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ), interactionSource = interactionSource, contentPadding = PaddingValues(0.dp), - modifier = Modifier - .fillMaxWidth() - .height(24.dp), - textStyle = TextStyle( - fontSize = 16.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9 - ) + modifier = + Modifier + .fillMaxWidth() + .height(24.dp), + textStyle = + TextStyle( + fontSize = 16.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + ), ) } } @@ -128,7 +133,7 @@ private fun AddItemTextFieldPreview() { AddItemTextField( visible = true, onItemAdded = {}, - onVisibilityChanged = {} + onVisibilityChanged = {}, ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/CustomServiceDialog.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/CustomServiceDialog.kt similarity index 67% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/CustomServiceDialog.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/CustomServiceDialog.kt index 59821949..55b073f1 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/CustomServiceDialog.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/CustomServiceDialog.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod +package com.kuit.afternote.feature.afternote.presentation.edit.processingmethod import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -40,7 +40,7 @@ import com.kuit.afternote.ui.theme.White @Immutable data class CustomServiceDialogCallbacks( val onDismiss: () -> Unit = {}, - val onAddClick: () -> Unit = {} + val onAddClick: () -> Unit = {}, ) /** @@ -49,7 +49,7 @@ data class CustomServiceDialogCallbacks( @Immutable data class CustomServiceDialogParams( val serviceNameState: TextFieldState, - val callbacks: CustomServiceDialogCallbacks = CustomServiceDialogCallbacks() + val callbacks: CustomServiceDialogCallbacks = CustomServiceDialogCallbacks(), ) /** @@ -64,44 +64,47 @@ data class CustomServiceDialogParams( @Composable fun CustomServiceDialog( modifier: Modifier = Modifier, - params: CustomServiceDialogParams + params: CustomServiceDialogParams, ) { Dialog( onDismissRequest = params.callbacks.onDismiss, - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true - ) + properties = + DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + ), ) { Column( - modifier = modifier - .fillMaxWidth() - .dropShadow( - shape = RoundedCornerShape(16.dp), - color = Color.Black.copy(alpha = 0.15f), - blur = 10.dp, - offsetX = 0.dp, - offsetY = 2.dp, - spread = 0.dp - ).background( - color = White, - shape = RoundedCornerShape(16.dp) - ).padding( - horizontal = 24.dp, - vertical = 32.dp - ), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + modifier + .fillMaxWidth() + .dropShadow( + shape = RoundedCornerShape(16.dp), + color = Color.Black.copy(alpha = 0.15f), + blur = 10.dp, + offsetX = 0.dp, + offsetY = 2.dp, + spread = 0.dp, + ).background( + color = White, + shape = RoundedCornerShape(16.dp), + ).padding( + horizontal = 24.dp, + vertical = 32.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, ) { // 타이틀 Text( text = "직접 입력하기", - style = TextStyle( - fontSize = 18.sp, - lineHeight = 24.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Bold, - color = Gray9 - ) + style = + TextStyle( + fontSize = 18.sp, + lineHeight = 24.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Bold, + color = Gray9, + ), ) Spacer(modifier = Modifier.height(24.dp)) @@ -111,7 +114,7 @@ fun CustomServiceDialog( label = "추가 서비스명", textFieldState = params.serviceNameState, keyboardType = KeyboardType.Text, - style = LabeledTextFieldStyle(containerColor = Gray1, labelSpacing = 8.dp) + style = LabeledTextFieldStyle(containerColor = Gray1, labelSpacing = 8.dp), ) Spacer(modifier = Modifier.height(24.dp)) @@ -120,7 +123,7 @@ fun CustomServiceDialog( ClickButton( color = B3, title = "추가하기", - onButtonClick = params.callbacks.onAddClick + onButtonClick = params.callbacks.onAddClick, ) } } @@ -131,9 +134,10 @@ fun CustomServiceDialog( private fun CustomServiceDialogPreview() { AfternoteTheme { CustomServiceDialog( - params = CustomServiceDialogParams( - serviceNameState = rememberTextFieldState() - ) + params = + CustomServiceDialogParams( + serviceNameState = rememberTextFieldState(), + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/OptionRadioCardContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/OptionRadioCardContent.kt similarity index 62% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/OptionRadioCardContent.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/OptionRadioCardContent.kt index d791fdc2..53b0b924 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/OptionRadioCardContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/OptionRadioCardContent.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod +package com.kuit.afternote.feature.afternote.presentation.edit.processingmethod import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -8,12 +8,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.tooling.preview.Preview import com.kuit.afternote.core.ui.component.SelectableRadioCard -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountProcessingMethod -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodOption +import com.kuit.afternote.feature.afternote.presentation.edit.model.AccountProcessingMethod +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodOption import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.B1 import com.kuit.afternote.ui.theme.Gray6 @@ -28,31 +28,33 @@ import com.kuit.afternote.ui.theme.Sansneo fun OptionRadioCardContent( option: ProcessingMethodOption, selected: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( text = option.title, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = if (selected) B1 else Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = if (selected) B1 else Gray9, + ), ) Text( text = option.description, - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray6 - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray6, + ), ) } } @@ -68,9 +70,9 @@ private fun OptionRadioCardContentPreview() { content = { OptionRadioCardContent( option = AccountProcessingMethod.MEMORIAL_ACCOUNT, - selected = true + selected = true, ) - } + }, ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodCheckbox.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodCheckbox.kt similarity index 78% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodCheckbox.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodCheckbox.kt index bc543391..b440aa46 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodCheckbox.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodCheckbox.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod +package com.kuit.afternote.feature.afternote.presentation.edit.processingmethod import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -36,7 +36,7 @@ import androidx.compose.ui.unit.sp import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.detail.EditDropdownMenu import com.kuit.afternote.core.ui.component.icon.CheckCircleIcon -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.Gray9 import com.kuit.afternote.ui.theme.Sansneo @@ -49,16 +49,17 @@ data class ProcessingMethodCheckboxCallbacks( val onDismissDropdown: () -> Unit = {}, val onEditClick: () -> Unit = {}, val onDeleteClick: () -> Unit = {}, - val onEditConfirmed: (String) -> Unit = {} + val onEditConfirmed: (String) -> Unit = {}, ) -private val processingMethodTextStyle = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray9 -) +private val processingMethodTextStyle = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray9, + ) /** * 처리 방법 체크박스 컴포넌트 @@ -76,12 +77,12 @@ fun ProcessingMethodCheckbox( item: ProcessingMethodItem, expanded: Boolean = false, isEditing: Boolean = false, - callbacks: ProcessingMethodCheckboxCallbacks = ProcessingMethodCheckboxCallbacks() + callbacks: ProcessingMethodCheckboxCallbacks = ProcessingMethodCheckboxCallbacks(), ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CheckCircleIcon() @@ -89,13 +90,13 @@ fun ProcessingMethodCheckbox( InlineEditTextField( initialText = item.text, onConfirm = callbacks.onEditConfirmed, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } else { Text( text = item.text, style = processingMethodTextStyle, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } @@ -103,14 +104,15 @@ fun ProcessingMethodCheckbox( Image( painter = painterResource(R.drawable.ic_more_horizontal_1), contentDescription = "더보기", - modifier = Modifier - .clickable(onClick = callbacks.onMoreClick) + modifier = + Modifier + .clickable(onClick = callbacks.onMoreClick), ) EditDropdownMenu( expanded = expanded, onDismissRequest = callbacks.onDismissDropdown, onEditClick = callbacks.onEditClick, - onDeleteClick = callbacks.onDeleteClick + onDeleteClick = callbacks.onDeleteClick, ) } } @@ -130,7 +132,7 @@ fun ProcessingMethodCheckbox( private fun InlineEditTextField( initialText: String, onConfirm: (String) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current @@ -138,8 +140,8 @@ private fun InlineEditTextField( mutableStateOf( TextFieldValue( text = initialText, - selection = TextRange(initialText.length) - ) + selection = TextRange(initialText.length), + ), ) } var hasConfirmed by remember { mutableStateOf(false) } @@ -160,21 +162,23 @@ private fun InlineEditTextField( textStyle = processingMethodTextStyle, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { - confirmEdit() - focusManager.clearFocus() - } - ), - modifier = modifier - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - if (focusState.isFocused) { - hasGainedFocus = true - } else if (hasGainedFocus && !hasConfirmed) { + keyboardActions = + KeyboardActions( + onDone = { confirmEdit() - } - } + focusManager.clearFocus() + }, + ), + modifier = + modifier + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + hasGainedFocus = true + } else if (hasGainedFocus && !hasConfirmed) { + confirmEdit() + } + }, ) LaunchedEffect(Unit) { @@ -205,9 +209,10 @@ private fun ProcessingMethodCheckboxEditingPreview() { ProcessingMethodCheckbox( item = ProcessingMethodItem("1", "게시물 내리기"), isEditing = true, - callbacks = ProcessingMethodCheckboxCallbacks( - onEditConfirmed = {} - ) + callbacks = + ProcessingMethodCheckboxCallbacks( + onEditConfirmed = {}, + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodList.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodList.kt new file mode 100644 index 00000000..32bb41ad --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodList.kt @@ -0,0 +1,148 @@ +package com.kuit.afternote.feature.afternote.presentation.edit.processingmethod + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kuit.afternote.core.ui.component.button.AddCircleButton +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem +import com.kuit.afternote.ui.theme.AfternoteTheme +import com.kuit.afternote.ui.theme.White +import kotlinx.coroutines.launch + +/** + * 처리 방법 리스트 컴포넌트 + */ +@Composable +fun ProcessingMethodList( + modifier: Modifier = Modifier, + params: ProcessingMethodListParams, + state: ProcessingMethodListState = + rememberProcessingMethodListState( + initialShowTextField = params.initialShowTextField, + ), +) { + val items = params.items + val onItemAdded = params.onItemAdded + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + + // 초기화: 아이템들의 expanded 상태 설정 + LaunchedEffect(items, params.initialExpandedItemId) { + state.initializeExpandedStates(items, params.initialExpandedItemId) + } + + Column( + modifier = + modifier + .fillMaxWidth() + .background(color = White, shape = RoundedCornerShape(16.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items.forEach { item -> + ProcessingMethodCheckbox( + item = item, + expanded = state.expandedStates[item.id] ?: false, + isEditing = state.editingItemId == item.id, + callbacks = + ProcessingMethodCheckboxCallbacks( + onMoreClick = { + focusManager.clearFocus() + state.toggleItemExpanded(item.id) + }, + onDismissDropdown = { + state.expandedStates[item.id] = false + }, + onEditClick = { + state.expandedStates[item.id] = false + // Defer editing to next frame so DropdownMenu dismiss settles first + scope.launch { + withFrameNanos { } + state.startEditing(item.id) + } + }, + onDeleteClick = { params.onItemDeleteClick(item.id) }, + onEditConfirmed = { newText -> + params.onItemEdited(item.id, newText) + state.stopEditing() + }, + ), + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + // 텍스트 필드 (버튼 클릭 시 표시) + AddItemTextField( + visible = state.showTextField, + onItemAdded = onItemAdded, + onVisibilityChanged = { isVisible -> + params.onTextFieldVisibilityChanged(isVisible) + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 추가 버튼 (텍스트 필드 표시만 토글; 부모 알림 없음) + AddCircleButton( + contentDescription = "추가", + onClick = { state.toggleTextField() }, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ProcessingMethodListPreview() { + AfternoteTheme { + ProcessingMethodList( + params = + ProcessingMethodListParams( + items = + listOf( + ProcessingMethodItem("1", "게시물 내리기"), + ProcessingMethodItem("2", "댓글 비활성화"), + ), + onItemDeleteClick = {}, + onItemAdded = {}, + onTextFieldVisibilityChanged = {}, + initialShowTextField = true, + ), + ) + } +} + +@Preview(showBackground = true, name = "드롭다운 펼쳐진 상태") +@Composable +private fun ProcessingMethodListWithDropdownPreview() { + AfternoteTheme { + ProcessingMethodList( + params = + ProcessingMethodListParams( + items = + listOf( + ProcessingMethodItem("1", "게시물 내리기"), + ProcessingMethodItem("2", "댓글 비활성화"), + ProcessingMethodItem("3", "추모 계정으로 전환하기"), + ), + onItemDeleteClick = {}, + onItemAdded = {}, + onTextFieldVisibilityChanged = {}, + initialShowTextField = false, + initialExpandedItemId = "1", + ), + ) + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodListParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodListParams.kt similarity index 64% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodListParams.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodListParams.kt index d3e94ea5..f279acd2 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodListParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodListParams.kt @@ -1,6 +1,6 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod +package com.kuit.afternote.feature.afternote.presentation.edit.processingmethod -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem /** * ProcessingMethodList 함수의 매개변수를 묶는 data class @@ -12,5 +12,5 @@ data class ProcessingMethodListParams( val onTextFieldVisibilityChanged: (Boolean) -> Unit = {}, val onItemEdited: (String, String) -> Unit = { _, _ -> }, val initialShowTextField: Boolean = false, - val initialExpandedItemId: String? = null + val initialExpandedItemId: String? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodListState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodListState.kt similarity index 85% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodListState.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodListState.kt index f380f371..97c6a087 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/processingmethod/ProcessingMethodListState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/processingmethod/ProcessingMethodListState.kt @@ -1,5 +1,4 @@ - -package com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod +package com.kuit.afternote.feature.afternote.presentation.edit.processingmethod import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -8,7 +7,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodItem /** * ProcessingMethodList의 상태를 관리하는 State Holder @@ -16,7 +15,7 @@ import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Pr @Stable class ProcessingMethodListState( initialShowTextField: Boolean = false, - initialExpandedItemId: String? = null + initialExpandedItemId: String? = null, ) { var showTextField by mutableStateOf(initialShowTextField) private set @@ -35,7 +34,7 @@ class ProcessingMethodListState( */ fun initializeExpandedStates( items: List, - initialExpandedItemId: String? + initialExpandedItemId: String?, ) { val expandedId = initialExpandedItemId ?: defaultExpandedItemId items.forEach { item -> @@ -80,9 +79,7 @@ class ProcessingMethodListState( * ProcessingMethodListState를 생성하는 Composable 함수 */ @Composable -fun rememberProcessingMethodListState( - initialShowTextField: Boolean = false -): ProcessingMethodListState = +fun rememberProcessingMethodListState(initialShowTextField: Boolean = false): ProcessingMethodListState = remember { ProcessingMethodListState( initialShowTextField = initialShowTextField, diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/FuneralVideoUpload.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/upload/FuneralVideoUpload.kt similarity index 70% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/FuneralVideoUpload.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/upload/FuneralVideoUpload.kt index e757d373..afe4b04d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/FuneralVideoUpload.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/upload/FuneralVideoUpload.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.upload +package com.kuit.afternote.feature.afternote.presentation.edit.upload import android.graphics.Bitmap import android.media.MediaMetadataRetriever @@ -51,10 +51,10 @@ import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.Gray9 import com.kuit.afternote.ui.theme.Sansneo import com.kuit.afternote.ui.theme.White -import java.io.ByteArrayOutputStream import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream private const val TAG = "FuneralVideoUpload" @@ -77,7 +77,7 @@ fun FuneralVideoUpload( thumbnailUrl: String? = null, onAddVideoClick: () -> Unit, onThumbnailBytesReady: (ByteArray?) -> Unit = {}, - ioDispatcher: CoroutineDispatcher = Dispatchers.IO + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { val hasVideo = !videoUrl.isNullOrBlank() val addContentDescription = if (hasVideo) "영상 변경" else "영상 추가" @@ -89,7 +89,7 @@ fun FuneralVideoUpload( TAG, "FuneralVideoUpload active: videoUrl=${videoUrl?.take(50) ?: "null"}, " + "thumbnailUrl=${thumbnailUrl?.take(80) ?: "null"}, hasVideo=$hasVideo, " + - "hasBitmap=${thumbnailBitmap != null}" + "hasBitmap=${thumbnailBitmap != null}", ) } @@ -104,24 +104,27 @@ fun FuneralVideoUpload( onThumbnailBytesReady(null) return@LaunchedEffect } - val (bitmap, jpegBytes) = withContext(ioDispatcher) { - val bmp = runCatching { - val retriever = MediaMetadataRetriever() - try { - retriever.setDataSource(context, videoUrl.toUri()) - retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - } finally { - retriever.release() - } - }.getOrNull() - val bytes = bmp?.let { frame -> - ByteArrayOutputStream().use { out -> - frame.compress(Bitmap.CompressFormat.JPEG, 85, out) - out.toByteArray() - } + val (bitmap, jpegBytes) = + withContext(ioDispatcher) { + val bmp = + runCatching { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(context, videoUrl.toUri()) + retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + } finally { + retriever.release() + } + }.getOrNull() + val bytes = + bmp?.let { frame -> + ByteArrayOutputStream().use { out -> + frame.compress(Bitmap.CompressFormat.JPEG, 85, out) + out.toByteArray() + } + } + Pair(bmp, bytes) } - Pair(bmp, bytes) - } thumbnailBitmap = bitmap?.asImageBitmap() runCatching { onThumbnailBytesReady(jpegBytes) }.onFailure { /* avoid throwing from LaunchedEffect */ } } @@ -132,35 +135,38 @@ fun FuneralVideoUpload( ) { Text( text = label, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) if (!hasVideo) { // 업로드 전 상태: 흰색 카드 + 플러스 아이콘 Box( - modifier = Modifier - .fillMaxWidth() - .height(80.dp) - .background(color = White, shape = RoundedCornerShape(size = 16.dp)) - .clickable(onClick = onAddVideoClick) + modifier = + Modifier + .fillMaxWidth() + .height(80.dp) + .background(color = White, shape = RoundedCornerShape(size = 16.dp)) + .clickable(onClick = onAddVideoClick), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(24.dp)) Image( painter = painterResource(R.drawable.ic_add_circle), contentDescription = addContentDescription, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.height(24.dp)) } @@ -168,21 +174,21 @@ fun FuneralVideoUpload( } else { // 업로드 후 상태: 썸네일(URL 또는 로컬 프레임) 또는 그라데이션 배경 + 재생 아이콘 Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .clip(RoundedCornerShape(size = 16.dp)) - .background( - brush = - Brush.verticalGradient( - colors = - listOf( - Color(0x99757575), - Color(0x99222222) - ) - ) - ) - .clickable(onClick = onAddVideoClick) + modifier = + Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(size = 16.dp)) + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color(0x99757575), + Color(0x99222222), + ), + ), + ).clickable(onClick = onAddVideoClick), ) { when { !thumbnailUrl.isNullOrBlank() -> { @@ -191,14 +197,16 @@ fun FuneralVideoUpload( } AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(thumbnailUrl) .httpHeaders( - NetworkHeaders.Builder().apply { - this["User-Agent"] = "Afternote Android App" - }.build() - ) - .build(), + NetworkHeaders + .Builder() + .apply { + this["User-Agent"] = "Afternote Android App" + }.build(), + ).build(), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, @@ -213,11 +221,12 @@ fun FuneralVideoUpload( Log.e( TAG, "Coil load failed: url=$thumbnailUrl", - state.result.throwable + state.result.throwable, ) - } + }, ) } + thumbnailBitmap != null -> { SideEffect { Log.d(TAG, "Branch=LocalBitmap, videoUrl=$videoUrl") @@ -226,15 +235,16 @@ fun FuneralVideoUpload( bitmap = thumbnailBitmap!!, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) } + else -> { SideEffect { Log.w( TAG, "Branch=NoThumbnail, videoUrl=$videoUrl, " + - "thumbnailUrl=$thumbnailUrl, thumbnailBitmap=null" + "thumbnailUrl=$thumbnailUrl, thumbnailBitmap=null", ) } } @@ -245,7 +255,7 @@ fun FuneralVideoUpload( modifier = Modifier .align(Alignment.Center) - .size(32.dp) + .size(32.dp), ) } } @@ -258,17 +268,17 @@ private fun FuneralVideoUploadPreview() { AfternoteTheme { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + verticalArrangement = Arrangement.spacedBy(24.dp), ) { // 영상 없음 FuneralVideoUpload( - onAddVideoClick = {} + onAddVideoClick = {}, ) // 영상 있음 (실기기에서 선택 시 썸네일 표시) FuneralVideoUpload( videoUrl = "test", - onAddVideoClick = {} + onAddVideoClick = {}, ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/MemorialPhotoUpload.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/upload/MemorialPhotoUpload.kt similarity index 78% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/MemorialPhotoUpload.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/upload/MemorialPhotoUpload.kt index 35e7888d..1e4cc1ff 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/MemorialPhotoUpload.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/edit/upload/MemorialPhotoUpload.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.edit.upload +package com.kuit.afternote.feature.afternote.presentation.edit.upload import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,7 +34,7 @@ fun MemorialPhotoUpload( modifier: Modifier = Modifier, label: String = "영정사진 추가", displayImageUri: String? = null, - onAddPhotoClick: () -> Unit = {} + onAddPhotoClick: () -> Unit = {}, ) { Column( modifier = modifier.fillMaxWidth(), @@ -42,23 +42,24 @@ fun MemorialPhotoUpload( ) { Text( text = label, - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9 - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + ), ) Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { ProfileImage( fallbackImageRes = R.drawable.img_default_profile_deceased, profileImageSize = 144.dp, displayImageUri = displayImageUri, - onEditClick = onAddPhotoClick + onEditClick = onAddPhotoClick, ) } } @@ -69,7 +70,7 @@ fun MemorialPhotoUpload( private fun MemorialPhotoUploadPreview() { AfternoteTheme { Column( - verticalArrangement = Arrangement.spacedBy(24.dp) + verticalArrangement = Arrangement.spacedBy(24.dp), ) { // 이미지 없음 MemorialPhotoUpload() diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/fingerprint/FingerprintAuthContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/fingerprint/FingerprintAuthContent.kt similarity index 74% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/fingerprint/FingerprintAuthContent.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/fingerprint/FingerprintAuthContent.kt index 82c6b2dc..ce22cedd 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/fingerprint/FingerprintAuthContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/fingerprint/FingerprintAuthContent.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.fingerprint +package com.kuit.afternote.feature.afternote.presentation.fingerprint import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -34,27 +34,29 @@ import com.kuit.afternote.ui.theme.Sansneo @Composable fun FingerprintAuthContent( modifier: Modifier = Modifier, - onFingerprintAuthClick: () -> Unit = {} + onFingerprintAuthClick: () -> Unit = {}, ) { Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 18.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.weight(1f)) // 안내 텍스트 Text( text = "사용자 인증 후 조회가 가능합니다.", - style = TextStyle( - fontSize = 16.sp, - lineHeight = 22.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Gray9, - textAlign = TextAlign.Center - ) + style = + TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9, + textAlign = TextAlign.Center, + ), ) Spacer(modifier = Modifier.height(40.dp)) @@ -62,12 +64,12 @@ fun FingerprintAuthContent( // 지문 아이콘 Box( modifier = Modifier.size(100.dp, 114.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Image( painter = painterResource(R.drawable.ic_fingerprint), contentDescription = "지문 인증", - modifier = Modifier.size(100.dp, 114.dp) + modifier = Modifier.size(100.dp, 114.dp), ) } @@ -78,7 +80,7 @@ fun FingerprintAuthContent( title = "지문 인증하기", onButtonClick = onFingerprintAuthClick, color = B3, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/FingerprintLoginScreen.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/fingerprint/FingerprintLoginScreen.kt similarity index 80% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/FingerprintLoginScreen.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/fingerprint/FingerprintLoginScreen.kt index 2f50bf19..b2fcc3e3 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/FingerprintLoginScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/fingerprint/FingerprintLoginScreen.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.fingerprint import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -15,7 +15,6 @@ import androidx.compose.ui.tooling.preview.Preview import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.component.fingerprint.FingerprintAuthContent import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme /** @@ -33,7 +32,7 @@ import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLight fun FingerprintLoginScreen( modifier: Modifier = Modifier, onFingerprintAuthClick: () -> Unit = {}, - onBottomNavTabSelected: (BottomNavItem) -> Unit = {} + onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, ) { var selectedBottomNavItem by remember { mutableStateOf(BottomNavItem.AFTERNOTE) } @@ -48,21 +47,23 @@ fun FingerprintLoginScreen( onItemSelected = { item -> selectedBottomNavItem = item onBottomNavTabSelected(item) - } + }, ) - } + }, ) { paddingValues -> Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), ) { // 메인 컨텐츠 FingerprintAuthContent( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - onFingerprintAuthClick = onFingerprintAuthClick + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + onFingerprintAuthClick = onFingerprintAuthClick, ) } } @@ -70,7 +71,7 @@ fun FingerprintLoginScreen( @Preview( showBackground = true, - device = "spec:width=390dp,height=844dp,dpi=420,isRound=false" + device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", ) @Composable private fun FingerprintLoginScreenPreview() { diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListEvent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListEvent.kt similarity index 81% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListEvent.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListEvent.kt index 6a7615fb..8179140a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListEvent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListEvent.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.list import com.kuit.afternote.core.ui.component.list.AfternoteTab import com.kuit.afternote.core.ui.component.navigation.BottomNavItem @@ -12,21 +12,21 @@ sealed interface AfternoteListEvent { * 탭 선택 이벤트 */ data class SelectTab( - val tab: AfternoteTab + val tab: AfternoteTab, ) : AfternoteListEvent /** * 하단 네비게이션 아이템 선택 이벤트 */ data class SelectBottomNav( - val navItem: BottomNavItem + val navItem: BottomNavItem, ) : AfternoteListEvent /** * 아이템 클릭 이벤트 */ data class ClickItem( - val itemId: String + val itemId: String, ) : AfternoteListEvent /** diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/AfternoteListItem.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListItem.kt similarity index 64% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/AfternoteListItem.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListItem.kt index 2757ebb2..5e49fc1b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/AfternoteListItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListItem.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.list +package com.kuit.afternote.feature.afternote.presentation.list import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -52,49 +52,52 @@ import com.kuit.afternote.ui.theme.White fun AfternoteListItem( modifier: Modifier = Modifier, item: AfternoteItem, - onClick: () -> Unit = {} + onClick: () -> Unit = {}, ) { val imageRes = getIconResForServiceName(item.serviceName) val shape = RoundedCornerShape(16.dp) Surface( - modifier = modifier - .fillMaxWidth() - .height(74.dp) - .dropShadow( - shape = shape, - color = ShadowBlack, - blur = 5.dp, - offsetY = 2.dp, - offsetX = 0.dp, - spread = 0.dp - ), + modifier = + modifier + .fillMaxWidth() + .height(74.dp) + .dropShadow( + shape = shape, + color = ShadowBlack, + blur = 5.dp, + offsetY = 2.dp, + offsetX = 0.dp, + spread = 0.dp, + ), shape = shape, color = White, - onClick = onClick + onClick = onClick, ) { Box( - modifier = Modifier.padding(horizontal = 20.dp) + modifier = Modifier.padding(horizontal = 20.dp), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { // 왼쪽: 아이콘/이미지 영역 Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.Transparent), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Transparent), + contentAlignment = Alignment.Center, ) { Image( painter = painterResource(imageRes), contentDescription = null, modifier = Modifier.size(40.dp), - contentScale = ContentScale.FillBounds + contentScale = ContentScale.FillBounds, ) } @@ -102,8 +105,9 @@ fun AfternoteListItem( // 중간: 텍스트 영역 Column( - modifier = Modifier - .weight(1f) + modifier = + Modifier + .weight(1f), ) { Text( text = item.serviceName, @@ -112,7 +116,7 @@ fun AfternoteListItem( fontFamily = Sansneo, fontWeight = FontWeight.Medium, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -122,7 +126,7 @@ fun AfternoteListItem( fontFamily = Sansneo, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } @@ -130,17 +134,19 @@ fun AfternoteListItem( // 오른쪽: 화살표 버튼 Box( - modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(12.dp)) - .background(B2) + modifier = + Modifier + .size(24.dp) + .clip(RoundedCornerShape(12.dp)) + .background(B2), ) { Image( painter = painterResource(R.drawable.ic_arrow_forward_b2), contentDescription = null, - modifier = Modifier - .size(6.dp, 12.dp) - .offset(x = 9.9.dp, y = 6.dp) + modifier = + Modifier + .size(6.dp, 12.dp) + .offset(x = 9.9.dp, y = 6.dp), ) } } @@ -153,21 +159,23 @@ fun AfternoteListItem( private fun AfternoteListItemPreview() { Column { AfternoteListItem( - item = AfternoteItem( - id = "1", - serviceName = "인스타그램", - date = "2023.11.24", - type = AfternoteServiceType.SOCIAL_NETWORK - ) + item = + AfternoteItem( + id = "1", + serviceName = "인스타그램", + date = "2023.11.24", + type = AfternoteServiceType.SOCIAL_NETWORK, + ), ) Spacer(modifier = Modifier.height(8.dp)) AfternoteListItem( - item = AfternoteItem( - id = "2", - serviceName = "갤러리", - date = "2023.11.25", - type = AfternoteServiceType.GALLERY_AND_FILES - ) + item = + AfternoteItem( + id = "2", + serviceName = "갤러리", + date = "2023.11.25", + type = AfternoteServiceType.GALLERY_AND_FILES, + ), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListRoute.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListRoute.kt similarity index 60% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListRoute.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListRoute.kt index 2d8e05db..d866819c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListRoute.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListRoute.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.list import android.util.Log import androidx.compose.runtime.Composable @@ -7,22 +7,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.kuit.afternote.core.domain.model.AfternoteServiceType +import com.kuit.afternote.core.ui.component.list.AfternoteTab +import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.screen.AfternoteListScreen import com.kuit.afternote.core.ui.screen.AfternoteListScreenListParams import com.kuit.afternote.core.ui.screen.AfternoteListScreenShellParams +import com.kuit.afternote.core.ui.util.getIconResForServiceName import com.kuit.afternote.core.uimodel.AfternoteListDisplayItem -import com.kuit.afternote.core.ui.component.navigation.BottomNavItem -import com.kuit.afternote.core.ui.component.list.AfternoteTab import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem -import com.kuit.afternote.core.domain.model.AfternoteServiceType -import com.kuit.afternote.core.ui.util.getIconResForServiceName data class AfternoteListRouteCallbacks( val onNavigateToDetail: (String) -> Unit = {}, val onNavigateToGalleryDetail: (String) -> Unit = {}, val onNavigateToMemorialGuidelineDetail: (String) -> Unit = {}, val onNavigateToAdd: (AfternoteTab) -> Unit = {}, - val onBottomNavTabSelected: (BottomNavItem) -> Unit = {} + val onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, ) /** @@ -39,7 +39,7 @@ fun AfternoteListRoute( initialItems: List = emptyList(), onItemsChanged: (List) -> Unit = {}, listRefreshRequested: Boolean = false, - onListRefreshConsumed: () -> Unit = {} + onListRefreshConsumed: () -> Unit = {}, ) { // ViewModel.init에서 loadAfternotes()를 호출하므로 여기서는 더미 데이터만 처리. // LaunchedEffect(Unit)에서 매번 loadAfternotes()를 호출하면, 하위 화면에서 @@ -71,42 +71,45 @@ fun AfternoteListRoute( } } - val displayItems = uiState.items.map { item -> - AfternoteListDisplayItem( - id = item.id, - serviceName = item.serviceName, - date = item.date, - iconResId = getIconResForServiceName(item.serviceName) - ) - } + val displayItems = + uiState.items.map { item -> + AfternoteListDisplayItem( + id = item.id, + serviceName = item.serviceName, + date = item.date, + iconResId = getIconResForServiceName(item.serviceName), + ) + } AfternoteListScreen( - shell = AfternoteListScreenShellParams( - title = "애프터노트", - bottomBarSelectedItem = uiState.selectedBottomNavItem, - onBottomBarItemSelected = { - viewModel.onEvent(AfternoteListEvent.SelectBottomNav(it)) - callbacks.onBottomNavTabSelected(it) - }, - showFab = true, - onFabClick = { callbacks.onNavigateToAdd(uiState.selectedTab) } - ), - list = AfternoteListScreenListParams( - items = displayItems, - selectedTab = uiState.selectedTab, - onTabSelected = { viewModel.onEvent(AfternoteListEvent.SelectTab(it)) }, - onItemClick = { itemId -> - val item = uiState.items.find { it.id == itemId } ?: return@AfternoteListScreenListParams - // Design has two detail screens only: Social Network, Gallery and Files. Others use Social-style detail. - when (item.type) { - AfternoteServiceType.GALLERY_AND_FILES -> callbacks.onNavigateToGalleryDetail(itemId) - AfternoteServiceType.MEMORIAL -> callbacks.onNavigateToMemorialGuidelineDetail(itemId) - else -> callbacks.onNavigateToDetail(itemId) - } - }, - hasNext = uiState.hasNext, - isLoadingMore = uiState.isLoadingMore, - onLoadMore = { viewModel.loadNextPage() } - ) + shell = + AfternoteListScreenShellParams( + title = "애프터노트", + bottomBarSelectedItem = uiState.selectedBottomNavItem, + onBottomBarItemSelected = { + viewModel.onEvent(AfternoteListEvent.SelectBottomNav(it)) + callbacks.onBottomNavTabSelected(it) + }, + showFab = true, + onFabClick = { callbacks.onNavigateToAdd(uiState.selectedTab) }, + ), + list = + AfternoteListScreenListParams( + items = displayItems, + selectedTab = uiState.selectedTab, + onTabSelected = { viewModel.onEvent(AfternoteListEvent.SelectTab(it)) }, + onItemClick = { itemId -> + val item = uiState.items.find { it.id == itemId } ?: return@AfternoteListScreenListParams + // Design has two detail screens only: Social Network, Gallery and Files. Others use Social-style detail. + when (item.type) { + AfternoteServiceType.GALLERY_AND_FILES -> callbacks.onNavigateToGalleryDetail(itemId) + AfternoteServiceType.MEMORIAL -> callbacks.onNavigateToMemorialGuidelineDetail(itemId) + else -> callbacks.onNavigateToDetail(itemId) + } + }, + hasNext = uiState.hasNext, + isLoadingMore = uiState.isLoadingMore, + onLoadMore = { viewModel.loadNextPage() }, + ), ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListUiState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListUiState.kt similarity index 90% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListUiState.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListUiState.kt index 4844a642..42d4a2af 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListUiState.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.list import com.kuit.afternote.core.ui.component.list.AfternoteTab import com.kuit.afternote.core.ui.component.navigation.BottomNavItem @@ -22,5 +22,5 @@ data class AfternoteListUiState( val isLoading: Boolean = false, val loadError: String? = null, val hasNext: Boolean = false, - val isLoadingMore: Boolean = false + val isLoadingMore: Boolean = false, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListViewModel.kt similarity index 83% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListViewModel.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListViewModel.kt index 0ee29809..b39edd7f 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteListViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteListViewModel.kt @@ -1,11 +1,11 @@ -package com.kuit.afternote.feature.afternote.presentation.screen +package com.kuit.afternote.feature.afternote.presentation.list import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kuit.afternote.core.domain.model.AfternoteServiceType import com.kuit.afternote.core.ui.component.list.AfternoteTab import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem -import com.kuit.afternote.core.domain.model.AfternoteServiceType import com.kuit.afternote.feature.afternote.domain.usecase.GetAfternotesUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -26,9 +26,8 @@ import javax.inject.Inject class AfternoteListViewModel @Inject constructor( - private val getAfternotesUseCase: GetAfternotesUseCase + private val getAfternotesUseCase: GetAfternotesUseCase, ) : ViewModel() { - private val _uiState = MutableStateFlow(AfternoteListUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -61,12 +60,11 @@ class AfternoteListViewModel isLoading = false, loadError = null, hasNext = paged.hasNext, - isLoadingMore = false + isLoadingMore = false, ) } updateFilteredItems(_uiState.value.selectedTab) - } - .onFailure { e -> + }.onFailure { e -> allItems = emptyList() currentPage = 0 hasNextPage = false @@ -76,7 +74,7 @@ class AfternoteListViewModel items = emptyList(), loadError = e.message ?: "애프터노트 목록을 불러오지 못했습니다.", hasNext = false, - isLoadingMore = false + isLoadingMore = false, ) } } @@ -102,8 +100,7 @@ class AfternoteListViewModel it.copy(isLoadingMore = false, hasNext = paged.hasNext) } updateFilteredItems(_uiState.value.selectedTab) - } - .onFailure { + }.onFailure { _uiState.update { it.copy(isLoadingMore = false) } } } @@ -126,9 +123,18 @@ class AfternoteListViewModel */ fun onEvent(event: AfternoteListEvent) { when (event) { - is AfternoteListEvent.SelectTab -> updateTab(event.tab) - is AfternoteListEvent.SelectBottomNav -> updateBottomNav(event.navItem) - is AfternoteListEvent.ClickItem -> handleItemClick(event.itemId) + is AfternoteListEvent.SelectTab -> { + updateTab(event.tab) + } + + is AfternoteListEvent.SelectBottomNav -> { + updateBottomNav(event.navItem) + } + + is AfternoteListEvent.ClickItem -> { + handleItemClick(event.itemId) + } + is AfternoteListEvent.ClickAdd -> { // 네비게이션은 Route에서 처리 } @@ -147,12 +153,13 @@ class AfternoteListViewModel * 선택된 탭에 따라 아이템 필터링 */ private fun updateFilteredItems(tab: AfternoteTab) { - val filtered = when (tab) { - AfternoteTab.ALL -> allItems - AfternoteTab.SOCIAL_NETWORK -> allItems.filter { it.type == AfternoteServiceType.SOCIAL_NETWORK } - AfternoteTab.GALLERY_AND_FILES -> allItems.filter { it.type == AfternoteServiceType.GALLERY_AND_FILES } - AfternoteTab.MEMORIAL -> allItems.filter { it.type == AfternoteServiceType.MEMORIAL } - } + val filtered = + when (tab) { + AfternoteTab.ALL -> allItems + AfternoteTab.SOCIAL_NETWORK -> allItems.filter { it.type == AfternoteServiceType.SOCIAL_NETWORK } + AfternoteTab.GALLERY_AND_FILES -> allItems.filter { it.type == AfternoteServiceType.GALLERY_AND_FILES } + AfternoteTab.MEMORIAL -> allItems.filter { it.type == AfternoteServiceType.MEMORIAL } + } _uiState.update { it.copy(items = filtered) } } @@ -169,7 +176,7 @@ class AfternoteListViewModel * 아이템 클릭 처리 */ private fun handleItemClick( - @Suppress("UNUSED_PARAMETER") itemId: String + @Suppress("UNUSED_PARAMETER") itemId: String, ) { // 네비게이션은 Route에서 처리 } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/AfternoteTabRow.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteTabRow.kt similarity index 75% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/AfternoteTabRow.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteTabRow.kt index 677934b8..372dfe33 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/AfternoteTabRow.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/AfternoteTabRow.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.list +package com.kuit.afternote.feature.afternote.presentation.list import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -50,7 +50,7 @@ import com.kuit.afternote.ui.theme.Sansneo fun AfternoteTabRow( modifier: Modifier = Modifier, selectedTab: AfternoteTab = AfternoteTab.ALL, - onTabSelected: (AfternoteTab) -> Unit + onTabSelected: (AfternoteTab) -> Unit, ) { val scrollState = rememberScrollState() val canScrollRight by remember { @@ -59,17 +59,18 @@ fun AfternoteTabRow( Box(modifier = modifier.fillMaxWidth()) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalFadingEdge(edgeWidth = 45.dp) - .horizontalScroll(scrollState), - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = + Modifier + .fillMaxWidth() + .horizontalFadingEdge(edgeWidth = 45.dp) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AfternoteTab.entries.forEach { tab -> TabItem( tab = tab, isSelected = tab == selectedTab, - onClick = { onTabSelected(tab) } + onClick = { onTabSelected(tab) }, ) } } @@ -77,13 +78,14 @@ fun AfternoteTabRow( // 오른쪽 끝에 화살표 아이콘 (스크롤 가능할 때만 표시) if (canScrollRight) { RightArrowIcon( - iconSpec = ArrowIconSpec( - iconRes = R.drawable.ic_arrow_right_tab, - contentDescription = "더 보기" - ), + iconSpec = + ArrowIconSpec( + iconRes = R.drawable.ic_arrow_right_tab, + contentDescription = "더 보기", + ), backgroundColor = B1, size = 16.dp, - modifier = Modifier.align(Alignment.CenterEnd) + modifier = Modifier.align(Alignment.CenterEnd), ) } } @@ -97,16 +99,17 @@ private fun TabItem( tab: AfternoteTab, isSelected: Boolean, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Box( - modifier = modifier - .height(34.dp) - .clip(RoundedCornerShape(17.dp)) - .background(if (isSelected) B3 else Gray2) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp), - contentAlignment = Alignment.Center + modifier = + modifier + .height(34.dp) + .clip(RoundedCornerShape(17.dp)) + .background(if (isSelected) B3 else Gray2) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, ) { Text( text = tab.label, @@ -114,7 +117,7 @@ private fun TabItem( fontFamily = Sansneo, fontWeight = FontWeight.Medium, lineHeight = 18.sp, - fontSize = 12.sp + fontSize = 12.sp, ) } } @@ -127,7 +130,7 @@ private fun AfternoteTabRowPreview() { var selectedTab by remember { mutableStateOf(AfternoteTab.ALL) } AfternoteTabRow( selectedTab = selectedTab, - onTabSelected = { selectedTab = it } + onTabSelected = { selectedTab = it }, ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/EmptyAfternoteContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/EmptyAfternoteContent.kt similarity index 73% rename from app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/EmptyAfternoteContent.kt rename to app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/EmptyAfternoteContent.kt index 08a60c74..6f02d70a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/list/EmptyAfternoteContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/list/EmptyAfternoteContent.kt @@ -1,4 +1,4 @@ -package com.kuit.afternote.feature.afternote.presentation.component.list +package com.kuit.afternote.feature.afternote.presentation.list import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column @@ -27,31 +27,33 @@ import com.kuit.afternote.ui.theme.Sansneo fun EmptyAfternoteContent(modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.weight(1f)) Image( painter = painterResource(R.drawable.img_empty_state), contentDescription = "빈 애프터노트", - modifier = Modifier - .width(106.dp) - .height(109.dp) - .alpha(0.6f) + modifier = + Modifier + .width(106.dp) + .height(109.dp) + .alpha(0.6f), ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "아직 등록된 애프터노트가 없어요.\n등록하여 계정을 보호하세요.", - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray4, - textAlign = TextAlign.Center - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray4, + textAlign = TextAlign.Center, + ), ) Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/mapper/AfternoteItemMapper.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/mapper/AfternoteItemMapper.kt new file mode 100644 index 00000000..c7d4c38d --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/mapper/AfternoteItemMapper.kt @@ -0,0 +1,36 @@ +package com.kuit.afternote.feature.afternote.presentation.mapper + +import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem +import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiModel +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel { + // 1. 날짜 포맷팅 로직 (안정성을 위해 java.time 사용) + val inputFormatter = + DateTimeFormatter + .ofPattern("yyyy.MM.dd") + val isoFormatter = DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd + + val parsedDate = + runCatching { + LocalDate.parse(this.date, inputFormatter) + }.getOrElse { + LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) + } + + val originalDateStr = parsedDate.format(isoFormatter) // "2026-02-06" + val formattedDateStr = "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" + + return MindRecordUiModel( + // String ID를 Long으로 변환 (UI용 unique key 확보) + id = this.id.hashCode().toLong(), + title = this.serviceName, + formattedDate = formattedDateStr, + draftLabel = "완료", // 애프터노트는 도메인상 완료된 데이터로 간주 + content = this.processing.message, + type = this.type.name, + category = null, // 필요 시 mapping 로직 추가 + originalDate = originalDateStr, + ) +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt index c62e66fe..768fe35b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt @@ -1,6 +1,7 @@ package com.kuit.afternote.feature.afternote.presentation.navgraph import android.util.Log +import android.widget.Toast import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL @@ -46,27 +47,27 @@ import com.kuit.afternote.core.ui.screen.afternotedetail.SocialNetworkDetailCont import com.kuit.afternote.core.ui.screen.afternotedetail.SocialNetworkDetailScreen import com.kuit.afternote.domain.provider.AfternoteEditDataProvider import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver -import com.kuit.afternote.feature.afternote.presentation.screen.AddSongCallbacks -import com.kuit.afternote.feature.afternote.presentation.screen.AddSongScreen -import com.kuit.afternote.feature.afternote.presentation.screen.AddSongViewModel -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteDetailViewModel -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteEditSaveError -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteEditScreen -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteEditScreenCallbacks -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteEditState -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteEditViewModel -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteItemMapper -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteListRoute -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteListRouteCallbacks -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteSaveState -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteValidationError -import com.kuit.afternote.feature.afternote.presentation.screen.FingerprintLoginScreen -import com.kuit.afternote.feature.afternote.presentation.screen.MemorialPlaylistRouteScreen -import com.kuit.afternote.feature.afternote.presentation.screen.MemorialPlaylistStateHolder -import com.kuit.afternote.feature.afternote.presentation.screen.RegisterAfternotePayload -import com.kuit.afternote.feature.afternote.presentation.screen.SaveAfternoteMemorialMedia -import com.kuit.afternote.feature.afternote.presentation.screen.rememberAfternoteEditState +import com.kuit.afternote.feature.afternote.presentation.AfternoteItemMapper +import com.kuit.afternote.feature.afternote.presentation.AfternoteSaveState +import com.kuit.afternote.feature.afternote.presentation.AfternoteValidationError +import com.kuit.afternote.feature.afternote.presentation.fingerprint.FingerprintLoginScreen +import com.kuit.afternote.feature.afternote.presentation.MemorialPlaylistRouteScreen +import com.kuit.afternote.feature.afternote.presentation.RegisterAfternotePayload +import com.kuit.afternote.feature.afternote.presentation.addsong.AddSongCallbacks +import com.kuit.afternote.feature.afternote.presentation.addsong.AddSongScreen +import com.kuit.afternote.feature.afternote.presentation.addsong.AddSongViewModel +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel +import com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditSaveError +import com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditScreen +import com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditScreenCallbacks +import com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditState +import com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditViewModel +import com.kuit.afternote.feature.afternote.presentation.edit.MemorialPlaylistStateHolder +import com.kuit.afternote.feature.afternote.presentation.edit.SaveAfternoteMemorialMedia +import com.kuit.afternote.feature.afternote.presentation.edit.rememberAfternoteEditState +import com.kuit.afternote.feature.afternote.presentation.list.AfternoteListRoute +import com.kuit.afternote.feature.afternote.presentation.list.AfternoteListRouteCallbacks import com.kuit.afternote.ui.theme.AfternoteTheme private const val TAG_AFTERNOTE_EDIT = "AfternoteEdit" @@ -80,7 +81,7 @@ private const val CATEGORY_MEMORIAL_GUIDELINE = "추모 가이드라인" */ data class AfternoteEditStateHandling( val holder: MutableState, - val onClear: () -> Unit + val onClear: () -> Unit, ) /** @@ -89,7 +90,7 @@ data class AfternoteEditStateHandling( data class AfternoteListRefreshParams( val listRefreshRequestedProvider: () -> Boolean, val onListRefreshConsumed: () -> Unit, - val onAfternoteDeleted: () -> Unit + val onAfternoteDeleted: () -> Unit, ) /** @@ -103,7 +104,7 @@ data class AfternoteNavGraphParams( val userNameProvider: () -> String, val editStateHandling: AfternoteEditStateHandling, val listRefresh: AfternoteListRefreshParams? = null, - val onNavigateToSelectReceiver: () -> Unit = {} + val onNavigateToSelectReceiver: () -> Unit = {}, ) /** @@ -128,7 +129,7 @@ inline fun NavGraphBuilder.afternoteComposable(noinline conten private fun resolveListItems( afternoteItems: List, - afternoteProvider: AfternoteEditDataProvider + afternoteProvider: AfternoteEditDataProvider, ): List = afternoteItems.ifEmpty { AfternoteItemMapper.toAfternoteItemsWithStableIds(afternoteProvider.getDefaultAfternoteItems()) @@ -141,7 +142,7 @@ private fun AfternoteListRouteContent( onItemsUpdated: (List) -> Unit, editStateHandling: AfternoteEditStateHandling, playlistStateHolder: MemorialPlaylistStateHolder, - listRefresh: AfternoteListRefreshParams? = null + listRefresh: AfternoteListRefreshParams? = null, ) { AfternoteListRoute( listRefreshRequested = listRefresh?.listRefreshRequestedProvider?.invoke() == true, @@ -167,14 +168,14 @@ private fun AfternoteListRouteContent( if (selectedTab == AfternoteTab.ALL) null else selectedTab.label Log.d( "AfternoteNav", - "FAB onNavigateToAdd → navigate(EditRoute initialCategory=$initialCategory)" + "FAB onNavigateToAdd → navigate(EditRoute initialCategory=$initialCategory)", ) navController.navigate(AfternoteRoute.EditRoute(initialCategory = initialCategory)) }, - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ), initialItems = emptyList(), - onItemsChanged = onItemsUpdated + onItemsChanged = onItemsUpdated, ) } @@ -185,7 +186,7 @@ private val DESIGNED_DETAIL_TYPES = setOf(AfternoteServiceType.SOCIAL_NETWORK) private fun DetailLoadingContent() { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } @@ -197,13 +198,14 @@ private fun DesignPendingDetailContent(onBackClick: () -> Unit) { modifier = Modifier.fillMaxSize(), topBar = { TopBar(title = "", onBackClick = onBackClick) - } + }, ) { paddingValues -> Box( - modifier = Modifier - .fillMaxSize() - .then(Modifier.padding(paddingValues)), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxSize() + .then(Modifier.padding(paddingValues)), + contentAlignment = Alignment.Center, ) { Text(text = stringResource(R.string.design_pending)) } @@ -217,7 +219,7 @@ private fun AfternoteDetailRouteContent( backStackEntry: NavBackStackEntry, navController: NavController, userName: String, - onAfternoteDeleted: () -> Unit = {} + onAfternoteDeleted: () -> Unit = {}, ) { val route = backStackEntry.toRoute() val viewModel: AfternoteDetailViewModel = hiltViewModel() @@ -239,39 +241,48 @@ private fun AfternoteDetailRouteContent( Log.d( TAG_AFTERNOTE_DETAIL, "DetailRoute: itemId=${route.itemId}, type=${detail?.type}, " + - "isLoading=${uiState.isLoading}" + "isLoading=${uiState.isLoading}", ) when { - uiState.isLoading -> DetailLoadingContent() - detail == null || detail.type !in DESIGNED_DETAIL_TYPES -> + uiState.isLoading -> { + DetailLoadingContent() + } + + detail == null || detail.type !in DESIGNED_DETAIL_TYPES -> { DesignPendingDetailContent(onBackClick = { navController.popBackStack() }) - else -> SocialNetworkDetailScreen( - content = SocialNetworkDetailContent( - serviceName = detail.title, - userName = userName, - accountId = detail.credentialsId ?: "", - password = detail.credentialsPassword ?: "", - accountProcessingMethod = detail.processMethod ?: "", - processingMethods = detail.actions, - message = detail.leaveMessage ?: "", - finalWriteDate = detail.updatedAt.ifEmpty { detail.createdAt }, - afternoteEditReceivers = detail.receivers.map { r -> - AfternoteEditReceiver( - id = "", - name = r.name, - label = r.relation + } + + else -> { + SocialNetworkDetailScreen( + content = + SocialNetworkDetailContent( + serviceName = detail.title, + userName = userName, + accountId = detail.credentials?.id ?: "", + password = detail.credentials?.password ?: "", + accountProcessingMethod = detail.processing?.method ?: "", + processingMethods = detail.processing?.actions ?: emptyList(), + message = detail.processing?.leaveMessage ?: "", + finalWriteDate = detail.timestamps.updatedAt.ifEmpty { detail.timestamps.createdAt }, + afternoteEditReceivers = + detail.receivers.map { r -> + AfternoteEditReceiver( + id = "", + name = r.name, + label = r.relation, + ) + }, + ), + onBackClick = { navController.popBackStack() }, + onEditClick = { + navController.navigate( + AfternoteRoute.EditRoute(itemId = detail.id.toString()), ) - } - ), - onBackClick = { navController.popBackStack() }, - onEditClick = { - navController.navigate( - AfternoteRoute.EditRoute(itemId = detail.id.toString()) - ) - }, - onDeleteConfirm = { viewModel.deleteAfternote(detail.id) } - ) + }, + onDeleteConfirm = { viewModel.deleteAfternote(detail.id) }, + ) + } } } @@ -282,7 +293,7 @@ private fun AfternoteGalleryDetailRouteContent( backStackEntry: NavBackStackEntry, navController: NavController, userName: String, - onAfternoteDeleted: () -> Unit = {} + onAfternoteDeleted: () -> Unit = {}, ) { val route = backStackEntry.toRoute() val viewModel: AfternoteDetailViewModel = hiltViewModel() @@ -302,36 +313,47 @@ private fun AfternoteGalleryDetailRouteContent( val detail = uiState.detail when { - uiState.isLoading -> DetailLoadingContent() - detail == null -> DesignPendingDetailContent( - onBackClick = { navController.popBackStack() } - ) - else -> GalleryDetailScreen( - detailState = GalleryDetailState( - serviceName = detail.title, - userName = userName, - finalWriteDate = detail.updatedAt.ifEmpty { detail.createdAt }, - afternoteEditReceivers = detail.receivers.map { r -> - AfternoteEditReceiver( - id = "", - name = r.name, - label = r.relation - ) - }, - informationProcessingMethod = detail.processMethod ?: "", - processingMethods = detail.actions, - message = detail.leaveMessage ?: "" - ), - callbacks = GalleryDetailCallbacks( + uiState.isLoading -> { + DetailLoadingContent() + } + + detail == null -> { + DesignPendingDetailContent( onBackClick = { navController.popBackStack() }, - onEditClick = { - navController.navigate( - AfternoteRoute.EditRoute(itemId = detail.id.toString()) - ) - }, - onDeleteConfirm = { viewModel.deleteAfternote(detail.id) } ) - ) + } + + else -> { + GalleryDetailScreen( + detailState = + GalleryDetailState( + serviceName = detail.title, + userName = userName, + finalWriteDate = detail.timestamps.updatedAt.ifEmpty { detail.timestamps.createdAt }, + afternoteEditReceivers = + detail.receivers.map { r -> + AfternoteEditReceiver( + id = "", + name = r.name, + label = r.relation, + ) + }, + informationProcessingMethod = detail.processing?.method ?: "", + processingMethods = detail.processing?.actions ?: emptyList(), + message = detail.processing?.leaveMessage ?: "", + ), + callbacks = + GalleryDetailCallbacks( + onBackClick = { navController.popBackStack() }, + onEditClick = { + navController.navigate( + AfternoteRoute.EditRoute(itemId = detail.id.toString()), + ) + }, + onDeleteConfirm = { viewModel.deleteAfternote(detail.id) }, + ), + ) + } } } @@ -342,7 +364,7 @@ private fun AfternoteMemorialGuidelineDetailContent( backStackEntry: NavBackStackEntry, navController: NavController, userName: String, - onAfternoteDeleted: () -> Unit = {} + onAfternoteDeleted: () -> Unit = {}, ) { val route = backStackEntry.toRoute() val viewModel: AfternoteDetailViewModel = hiltViewModel() @@ -362,45 +384,57 @@ private fun AfternoteMemorialGuidelineDetailContent( val detail = uiState.detail when { - uiState.isLoading -> DetailLoadingContent() - detail == null -> DesignPendingDetailContent( - onBackClick = { navController.popBackStack() } - ) - else -> MemorialGuidelineDetailScreen( - detailState = MemorialGuidelineDetailState( - userName = userName, - finalWriteDate = detail.updatedAt.ifEmpty { detail.createdAt }, - profileImageUri = detail.playlist?.memorialPhotoUrl ?: detail.playlist?.profilePhoto, - afternoteEditReceivers = detail.receivers.map { r -> - AfternoteEditReceiver( - id = "", - name = r.name, - label = r.relation - ) - }, - albumCovers = detail.playlist?.songs?.map { s -> - AlbumCover( - id = (s.id ?: 0L).toString(), - imageUrl = s.coverUrl, - title = s.title - ) - } ?: emptyList(), - songCount = detail.playlist?.songs?.size ?: 0, - // Memorial only: "남기고 싶은 당부" comes from playlist.atmosphere; other categories use leaveMessage. - lastWish = detail.playlist?.atmosphere ?: "", - memorialVideoUrl = detail.playlist?.memorialVideoUrl, - memorialThumbnailUrl = detail.playlist?.memorialThumbnailUrl, - ), - callbacks = MemorialGuidelineDetailCallbacks( + uiState.isLoading -> { + DetailLoadingContent() + } + + detail == null -> { + DesignPendingDetailContent( onBackClick = { navController.popBackStack() }, - onEditClick = { - navController.navigate( - AfternoteRoute.EditRoute(itemId = detail.id.toString()) - ) - }, - onDeleteConfirm = { viewModel.deleteAfternote(detail.id) } ) - ) + } + + else -> { + MemorialGuidelineDetailScreen( + detailState = + MemorialGuidelineDetailState( + userName = userName, + finalWriteDate = detail.timestamps.updatedAt.ifEmpty { detail.timestamps.createdAt }, + profileImageUri = detail.playlist?.playlistDetailMemorialMedia?.photoUrl, + afternoteEditReceivers = + detail.receivers.map { r -> + AfternoteEditReceiver( + id = "", + name = r.name, + label = r.relation, + ) + }, + albumCovers = + detail.playlist?.songs?.map { s -> + AlbumCover( + id = (s.id ?: 0L).toString(), + imageUrl = s.coverUrl, + title = s.title, + ) + } ?: emptyList(), + songCount = detail.playlist?.songs?.size ?: 0, + // Memorial only: "남기고 싶은 당부" comes from playlist.atmosphere; other categories use leaveMessage. + lastWish = detail.playlist?.atmosphere ?: "", + memorialVideoUrl = detail.playlist?.playlistDetailMemorialMedia?.videoUrl, + memorialThumbnailUrl = detail.playlist?.playlistDetailMemorialMedia?.thumbnailUrl, + ), + callbacks = + MemorialGuidelineDetailCallbacks( + onBackClick = { navController.popBackStack() }, + onEditClick = { + navController.navigate( + AfternoteRoute.EditRoute(itemId = detail.id.toString()), + ) + }, + onDeleteConfirm = { viewModel.deleteAfternote(detail.id) }, + ), + ) + } } } @@ -408,8 +442,13 @@ private fun AfternoteMemorialGuidelineDetailContent( /** Result of save error resolution: either a validation string resource id or a raw message. */ private sealed class EditSaveErrorResult { - data class Validation(val messageResId: Int) : EditSaveErrorResult() - data class Raw(val message: String) : EditSaveErrorResult() + data class Validation( + val messageResId: Int, + ) : EditSaveErrorResult() + + data class Raw( + val message: String, + ) : EditSaveErrorResult() } /** @@ -418,11 +457,13 @@ private sealed class EditSaveErrorResult { */ private fun editSaveErrorFromState( saveState: AfternoteSaveState, - playlistSongCount: Int + playlistSongCount: Int, ): EditSaveErrorResult? { if (saveState.validationError == AfternoteValidationError.PLAYLIST_SONGS_REQUIRED && playlistSongCount > 0 - ) return null + ) { + return null + } saveState.validationError?.let { return EditSaveErrorResult.Validation(it.messageResId) } saveState.error?.let { return EditSaveErrorResult.Raw(it) } return null @@ -437,7 +478,7 @@ private data class EditScreenCallbacksParams( val initialItem: AfternoteItem?, val playlistStateHolder: MemorialPlaylistStateHolder, val onNavigateToSelectReceiver: () -> Unit, - val onBottomNavTabSelected: (BottomNavItem) -> Unit + val onBottomNavTabSelected: (BottomNavItem) -> Unit, ) private data class AfternoteEditRouteContentParams( @@ -448,12 +489,12 @@ private data class AfternoteEditRouteContentParams( val afternoteProvider: AfternoteEditDataProvider, val editStateHandling: AfternoteEditStateHandling, val onNavigateToSelectReceiver: () -> Unit = {}, - val onBottomNavTabSelected: (BottomNavItem) -> Unit = {} + val onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, ) private fun navigateToAfternoteListOnSaveSuccess( editStateHandling: AfternoteEditStateHandling, - navController: NavController + navController: NavController, ) { editStateHandling.onClear() navController.navigate(AfternoteRoute.AfternoteListRoute) { @@ -465,7 +506,7 @@ private fun navigateToAfternoteListOnSaveSuccess( private fun applyUploadedThumbnailAndClear( url: String, state: AfternoteEditState, - viewModel: AfternoteEditViewModel + viewModel: AfternoteEditViewModel, ) { runCatching { state.onFuneralThumbnailDataUrlReady(url) } .onFailure { e -> Log.e(TAG_AFTERNOTE_EDIT, "apply uploadedThumbnailUrl failed", e) } @@ -479,7 +520,7 @@ private fun applyUploadedThumbnailAndClear( private fun tryApplyReceiverSelectionFromSavedState( backStackEntry: NavBackStackEntry, viewModel: AfternoteEditViewModel, - state: AfternoteEditState + state: AfternoteEditState, ) { val id = backStackEntry.savedStateHandle[SELECTED_RECEIVER_ID_KEY] as? Long ?: return backStackEntry.savedStateHandle.remove(SELECTED_RECEIVER_ID_KEY) @@ -500,12 +541,13 @@ private fun buildEditScreenCallbacks(params: EditScreenCallbacksParams): Afterno payload = payload, selectedReceiverIds = params.state.afternoteEditReceivers.mapNotNull { it.id.toLongOrNull() }, playlistStateHolder = params.playlistStateHolder, - memorialMedia = SaveAfternoteMemorialMedia( - funeralVideoUrl = params.state.funeralVideoUrl, - funeralThumbnailUrl = params.state.funeralThumbnailUrl, - memorialPhotoUrl = params.state.memorialPhotoUrl, - pickedMemorialPhotoUri = params.state.pickedMemorialPhotoUri - ) + memorialMedia = + SaveAfternoteMemorialMedia( + funeralVideoUrl = params.state.funeralVideoUrl, + funeralThumbnailUrl = params.state.funeralThumbnailUrl, + memorialPhotoUrl = params.state.memorialPhotoUrl, + pickedMemorialPhotoUri = params.state.pickedMemorialPhotoUri, + ), ) }, onNavigateToAddSong = { params.navController.navigate(AfternoteRoute.MemorialPlaylistRoute) }, @@ -513,23 +555,25 @@ private fun buildEditScreenCallbacks(params: EditScreenCallbacksParams): Afterno onBottomNavTabSelected = params.onBottomNavTabSelected, onThumbnailBytesReady = { bytes -> if (bytes != null) params.editViewModel.uploadMemorialThumbnail(bytes) - } + }, ) @Composable private fun AfternoteEditRouteContent(params: AfternoteEditRouteContentParams) { val route = params.backStackEntry.toRoute() - val listItems = remember(params.afternoteItems, params.afternoteProvider) { - resolveListItems(params.afternoteItems, params.afternoteProvider) - } - val initialItem = remember(route.itemId, listItems) { - route.itemId?.let { id -> listItems.find { it.id == id } } - } + val listItems = + remember(params.afternoteItems, params.afternoteProvider) { + resolveListItems(params.afternoteItems, params.afternoteProvider) + } + val initialItem = + remember(route.itemId, listItems) { + route.itemId?.let { id -> listItems.find { it.id == id } } + } if (route.itemId != null && initialItem == null) { Log.w( TAG_AFTERNOTE_EDIT, "Edit opened but item not found: itemId=${route.itemId}, " + - "listSize=${listItems.size}" + "listSize=${listItems.size}", ) } @@ -552,7 +596,7 @@ private fun AfternoteEditRouteContent(params: AfternoteEditRouteContentParams) { tryApplyReceiverSelectionFromSavedState( params.backStackEntry, editViewModel, - state + state, ) } } @@ -569,7 +613,7 @@ private fun AfternoteEditRouteContent(params: AfternoteEditRouteContentParams) { editViewModel.loadForEdit( afternoteId = id, state = state, - playlistStateHolder = params.playlistStateHolder + playlistStateHolder = params.playlistStateHolder, ) } } @@ -587,35 +631,38 @@ private fun AfternoteEditRouteContent(params: AfternoteEditRouteContentParams) { } } - val errorResult = remember( - saveState.validationError, - saveState.error, - params.playlistStateHolder.songs.size - ) { editSaveErrorFromState(saveState, params.playlistStateHolder.songs.size) } - val saveError = when (errorResult) { - is EditSaveErrorResult.Validation -> AfternoteEditSaveError(stringResource(errorResult.messageResId)) - is EditSaveErrorResult.Raw -> AfternoteEditSaveError(errorResult.message) - null -> null - } + val errorResult = + remember( + saveState.validationError, + saveState.error, + params.playlistStateHolder.songs.size, + ) { editSaveErrorFromState(saveState, params.playlistStateHolder.songs.size) } + val saveError = + when (errorResult) { + is EditSaveErrorResult.Validation -> AfternoteEditSaveError(stringResource(errorResult.messageResId)) + is EditSaveErrorResult.Raw -> AfternoteEditSaveError(errorResult.message) + null -> null + } AfternoteEditScreen( - callbacks = buildEditScreenCallbacks( - EditScreenCallbacksParams( - navController = params.navController, - editViewModel = editViewModel, - editStateHandling = params.editStateHandling, - state = state, - route = route, - initialItem = initialItem, - playlistStateHolder = params.playlistStateHolder, - onNavigateToSelectReceiver = params.onNavigateToSelectReceiver, - onBottomNavTabSelected = params.onBottomNavTabSelected - ) - ), + callbacks = + buildEditScreenCallbacks( + EditScreenCallbacksParams( + navController = params.navController, + editViewModel = editViewModel, + editStateHandling = params.editStateHandling, + state = state, + route = route, + initialItem = initialItem, + playlistStateHolder = params.playlistStateHolder, + onNavigateToSelectReceiver = params.onNavigateToSelectReceiver, + onBottomNavTabSelected = params.onBottomNavTabSelected, + ), + ), playlistStateHolder = params.playlistStateHolder, initialItem = if (route.itemId != null) null else initialItem, state = state, - saveError = saveError + saveError = saveError, ) } @@ -624,7 +671,7 @@ private fun AfternoteEditRouteContent(params: AfternoteEditRouteContentParams) { @Composable private fun AfternoteFingerprintLoginContent( navController: NavController, - onBottomNavTabSelected: (BottomNavItem) -> Unit + onBottomNavTabSelected: (BottomNavItem) -> Unit, ) { val context = LocalContext.current val activity = context as? FragmentActivity @@ -640,9 +687,7 @@ private fun AfternoteFingerprintLoginContent( fragActivity, executor, object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult - ) { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { navController.navigate(AfternoteRoute.AfternoteListRoute) { popUpTo(AfternoteRoute.FingerprintLoginRoute) { inclusive = true @@ -650,7 +695,7 @@ private fun AfternoteFingerprintLoginContent( launchSingleTop = true } } - } + }, ) } } catch (e: Throwable) { @@ -660,7 +705,8 @@ private fun AfternoteFingerprintLoginContent( } val promptInfo = remember(promptTitle, promptSubtitle) { - BiometricPrompt.PromptInfo.Builder() + BiometricPrompt.PromptInfo + .Builder() .setTitle(promptTitle) .setSubtitle(promptSubtitle) .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) @@ -671,14 +717,18 @@ private fun AfternoteFingerprintLoginContent( if (activity == null) return@FingerprintLoginScreen val biometricManager = BiometricManager.from(context) when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) { - BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt?.authenticate(promptInfo) - else -> - android.widget.Toast - .makeText(context, notAvailableMessage, android.widget.Toast.LENGTH_SHORT) + BiometricManager.BIOMETRIC_SUCCESS -> { + biometricPrompt?.authenticate(promptInfo) + } + + else -> { + Toast + .makeText(context, notAvailableMessage, Toast.LENGTH_SHORT) .show() + } } }, - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ) } @@ -688,17 +738,18 @@ private fun AfternoteFingerprintLoginContent( private fun AfternoteAddSongRouteContent( navController: NavController, playlistStateHolder: MemorialPlaylistStateHolder, - viewModel: AddSongViewModel + viewModel: AddSongViewModel, ) { AddSongScreen( viewModel = viewModel, - callbacks = AddSongCallbacks( - onBackClick = { navController.popBackStack() }, - onSongsAdded = { added -> - added.forEach { playlistStateHolder.addSong(it) } - navController.popBackStack() - } - ) + callbacks = + AddSongCallbacks( + onBackClick = { navController.popBackStack() }, + onSongsAdded = { added -> + added.forEach { playlistStateHolder.addSong(it) } + navController.popBackStack() + }, + ), ) } @@ -707,7 +758,7 @@ private fun AfternoteAddSongRouteContent( fun NavGraphBuilder.afternoteNavGraph( navController: NavController, params: AfternoteNavGraphParams, - onBottomNavTabSelected: (BottomNavItem) -> Unit = {} + onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, ) { val afternoteProvider = params.afternoteProvider @@ -718,7 +769,7 @@ fun NavGraphBuilder.afternoteNavGraph( onItemsUpdated = params.onItemsUpdated, editStateHandling = params.editStateHandling, playlistStateHolder = params.playlistStateHolder, - listRefresh = params.listRefresh + listRefresh = params.listRefresh, ) } @@ -729,7 +780,7 @@ fun NavGraphBuilder.afternoteNavGraph( backStackEntry = backStackEntry, navController = navController, userName = params.userNameProvider(), - onAfternoteDeleted = onAfternoteDeleted + onAfternoteDeleted = onAfternoteDeleted, ) } @@ -738,7 +789,7 @@ fun NavGraphBuilder.afternoteNavGraph( backStackEntry = backStackEntry, navController = navController, userName = params.userNameProvider(), - onAfternoteDeleted = onAfternoteDeleted + onAfternoteDeleted = onAfternoteDeleted, ) } @@ -753,8 +804,8 @@ fun NavGraphBuilder.afternoteNavGraph( afternoteProvider = afternoteProvider, editStateHandling = params.editStateHandling, onNavigateToSelectReceiver = params.onNavigateToSelectReceiver, - onBottomNavTabSelected = onBottomNavTabSelected - ) + onBottomNavTabSelected = onBottomNavTabSelected, + ), ) } @@ -763,7 +814,7 @@ fun NavGraphBuilder.afternoteNavGraph( backStackEntry = backStackEntry, navController = navController, userName = params.userNameProvider(), - onAfternoteDeleted = onAfternoteDeleted + onAfternoteDeleted = onAfternoteDeleted, ) } @@ -771,14 +822,14 @@ fun NavGraphBuilder.afternoteNavGraph( MemorialPlaylistRouteScreen( playlistStateHolder = params.playlistStateHolder, onBackClick = { navController.popBackStack() }, - onNavigateToAddSongScreen = { navController.navigate(AfternoteRoute.AddSongRoute) } + onNavigateToAddSongScreen = { navController.navigate(AfternoteRoute.AddSongRoute) }, ) } afternoteComposable { AfternoteFingerprintLoginContent( navController = navController, - onBottomNavTabSelected = onBottomNavTabSelected + onBottomNavTabSelected = onBottomNavTabSelected, ) } @@ -787,7 +838,7 @@ fun NavGraphBuilder.afternoteNavGraph( AfternoteAddSongRouteContent( navController = navController, playlistStateHolder = params.playlistStateHolder, - viewModel = addSongViewModel + viewModel = addSongViewModel, ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/.gitkeep b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongViewModel.kt deleted file mode 100644 index 0d64b2a2..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AddSongViewModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.kuit.afternote.feature.afternote.presentation.screen - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.kuit.afternote.feature.afternote.domain.usecase.SearchMusicUseCase -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -private const val SEARCH_DEBOUNCE_MS = 300L - -@HiltViewModel -class AddSongViewModel - @Inject - constructor( - private val searchMusicUseCase: SearchMusicUseCase - ) : ViewModel(), AddSongViewModelContract { - - private val _uiState = MutableStateFlow(AddSongUiState()) - override val uiState: StateFlow = _uiState.asStateFlow() - - private var searchJob: Job? = null - - override fun onSearchQueryChange(query: String) { - _uiState.update { it.copy(searchQuery = query, errorMessage = null) } - searchJob?.cancel() - searchJob = viewModelScope.launch { - delay(SEARCH_DEBOUNCE_MS) - val trimmed = query.trim() - if (trimmed.isEmpty()) { - _uiState.update { it.copy(songs = emptyList(), isLoading = false) } - return@launch - } - _uiState.update { it.copy(isLoading = true) } - searchMusicUseCase(trimmed) - .onSuccess { list -> - _uiState.update { - it.copy(songs = list, isLoading = false, errorMessage = null) - } - } - .onFailure { e -> - _uiState.update { - it.copy( - songs = emptyList(), - isLoading = false, - errorMessage = e.message ?: "검색 실패" - ) - } - } - } - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt deleted file mode 100644 index 36a59b4c..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt +++ /dev/null @@ -1,513 +0,0 @@ -package com.kuit.afternote.feature.afternote.presentation.screen - -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import android.util.Log -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.kuit.afternote.core.ui.component.list.AlbumCover -import com.kuit.afternote.core.ui.component.navigation.BottomNavItem -import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar -import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.component.edit.content.GalleryAndFileEditContent -import com.kuit.afternote.feature.afternote.presentation.component.edit.content.GalleryAndFileEditContentParams -import com.kuit.afternote.feature.afternote.presentation.component.edit.content.MemorialGuidelineEditContent -import com.kuit.afternote.feature.afternote.presentation.component.edit.content.MemorialGuidelineEditContentParams -import com.kuit.afternote.feature.afternote.presentation.component.edit.content.SocialNetworkEditContent -import com.kuit.afternote.feature.afternote.presentation.component.edit.content.SocialNetworkEditContentParams -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.DropdownMenuStyle -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdown -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdownLabelParams -import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialog -import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialogCallbacks -import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialogParams -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverCallbacks -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.CustomServiceDialog -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.CustomServiceDialogCallbacks -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.CustomServiceDialogParams -import com.kuit.afternote.app.compositionlocal.DataProviderLocals -import com.kuit.afternote.data.provider.FakeAfternoteEditDataProvider -import com.kuit.afternote.feature.afternote.domain.model.AfternoteProcessingMethod -import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteLightTheme -import com.kuit.afternote.ui.expand.addFocusCleaner -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -private const val TAG = "AfternoteEditScreen" -private const val CATEGORY_GALLERY_AND_FILE = "갤러리 및 파일" -private const val CATEGORY_MEMORIAL_GUIDELINE = "추모 가이드라인" - -/** - * 콜백 그룹 (S107: 파라미터 7개 이하 유지). - */ -data class AfternoteEditScreenCallbacks( - val onBackClick: () -> Unit = {}, - val onRegisterClick: (RegisterAfternotePayload) -> Unit = {}, - val onNavigateToAddSong: () -> Unit = {}, - val onNavigateToSelectReceiver: () -> Unit = {}, - val onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, - val onThumbnailBytesReady: (ByteArray?) -> Unit = {} -) - -/** - * Message to show when save fails (validation or API error). - * When non-null, the screen shows a Snackbar with this text. - */ -data class AfternoteEditSaveError(val message: String) - -/** - * 애프터노트 수정/작성 화면 - * - * 피그마 디자인 기반: - * - 헤더 (뒤로가기, 타이틀, 등록 버튼) - * - 종류 선택 드롭다운 - * - 서비스명 선택 드롭다운 - * - 계정 정보 입력 (아이디, 비밀번호) - * - 계정 처리 방법 선택 (라디오 버튼) - * - 처리 방법 리스트 (체크박스) - * - 남기실 말씀 (멀티라인 텍스트 필드) - */ -@Composable -fun AfternoteEditScreen( - modifier: Modifier = Modifier, - callbacks: AfternoteEditScreenCallbacks = AfternoteEditScreenCallbacks(), - state: AfternoteEditState = rememberAfternoteEditState(), - playlistStateHolder: MemorialPlaylistStateHolder? = null, - initialItem: com.kuit.afternote.feature.afternote.domain.model.AfternoteItem? = null, - saveError: AfternoteEditSaveError? = null -) { - val focusManager = LocalFocusManager.current - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(saveError) { - saveError?.let { err -> - snackbarHostState.showSnackbar( - message = err.message, - withDismissAction = true - ) - } - } - - LaunchedEffect(initialItem?.id) { - val item = initialItem ?: run { - Log.d(TAG, "LaunchedEffect: initialItem is null, skipping loadFromExisting") - return@LaunchedEffect - } - Log.d( - TAG, - "LaunchedEffect: item.id=${item.id}, state.loadedItemId=${state.loadedItemId}, " + - "needsLoad=${state.loadedItemId != item.id}" - ) - if (state.loadedItemId != item.id) { - state.loadFromExisting( - LoadFromExistingParams( - itemId = item.id, - serviceName = item.serviceName, - categoryDisplayString = AfternoteItemMapper.categoryStringForEditScreen(item.type), - accountId = item.accountId, - password = item.password, - message = item.message, - accountProcessingMethodName = item.accountProcessingMethod, - informationProcessingMethodName = item.informationProcessingMethod, - processingMethodsList = item.processingMethods.map { ProcessingMethodItem(it.id, it.text) }, - galleryProcessingMethodsList = item.galleryProcessingMethods.map { ProcessingMethodItem(it.id, it.text) } - ) - ) - } - } - - // 플레이리스트 상태 홀더가 전달되면 설정 - LaunchedEffect(playlistStateHolder) { - playlistStateHolder?.let { state.setPlaylistStateHolder(it) } - } - - // 플레이리스트 노래 개수 변경 감지 및 동기화 - val songCount by remember { - playlistStateHolder?.songs?.let { - androidx.compose.runtime.derivedStateOf { it.size } - } ?: androidx.compose.runtime.derivedStateOf { state.playlistSongCount } - } - - LaunchedEffect(songCount) { - if (playlistStateHolder != null) { - state.updatePlaylistSongCount() - } - } - - val memorialPhotoPickerLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> - state.onMemorialPhotoSelected(uri) - } - val memorialVideoPickerLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> - state.onFuneralVideoSelected(uri) - } - - Scaffold( - modifier = modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopBar( - title = "애프터노트 작성하기", - onBackClick = callbacks.onBackClick, - onActionClick = { - val dateFormat = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) - val date = dateFormat.format(Date()) - val processingMethods = state.processingMethods.map { - AfternoteProcessingMethod(it.id, it.text) - } - val galleryProcessingMethods = state.galleryProcessingMethods.map { - AfternoteProcessingMethod(it.id, it.text) - } - callbacks.onRegisterClick( - RegisterAfternotePayload( - serviceName = - if (state.selectedCategory == CATEGORY_MEMORIAL_GUIDELINE) - CATEGORY_MEMORIAL_GUIDELINE - else - state.selectedService, - date = date, - accountId = state.idState.text.toString(), - password = state.passwordState.text.toString(), - message = state.messageState.text.toString(), - accountProcessingMethod = state.selectedProcessingMethod.name, - informationProcessingMethod = state.selectedInformationProcessingMethod.name, - processingMethods = processingMethods, - galleryProcessingMethods = galleryProcessingMethods, - atmosphere = state.getAtmosphereForSave() - ) - ) - } - ) - }, - bottomBar = { - BottomNavigationBar( - selectedItem = state.selectedBottomNavItem, - onItemSelected = { item -> - state.onBottomNavItemSelected(item) - callbacks.onBottomNavTabSelected(item) - } - ) - } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .addFocusCleaner(focusManager) - ) { - EditContent( - state = state, - onNavigateToAddSong = callbacks.onNavigateToAddSong, - onNavigateToSelectReceiver = callbacks.onNavigateToSelectReceiver, - onPhotoAddClick = { - memorialPhotoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - onVideoAddClick = { - memorialVideoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly) - ) - }, - onThumbnailBytesReady = callbacks.onThumbnailBytesReady, - bottomPadding = paddingValues - ) - - // Line 336 해결: 조건부 렌더링을 nullable로 변경 - state.activeDialog?.let { dialogType -> - when (dialogType) { - DialogType.ADD_AFTERNOTE_EDIT_RECEIVER -> { - AddAfternoteEditReceiverDialog( - params = AddAfternoteEditReceiverDialogParams( - afternoteEditReceiverNameState = state.afternoteEditReceiverNameState, - phoneNumberState = state.phoneNumberState, - relationshipSelectedValue = state.relationshipSelectedValue, - relationshipOptions = state.relationshipOptions, - callbacks = AddAfternoteEditReceiverDialogCallbacks( - onDismiss = state::dismissDialog, - onAddClick = state::onAddAfternoteEditReceiver, - onRelationshipSelected = state::onRelationshipSelected, - onImportContactsClick = { - // 연락처 가져오기 기능은 추후 구현 예정 - } - ) - ) - ) - } - - DialogType.CUSTOM_SERVICE -> { - CustomServiceDialog( - params = CustomServiceDialogParams( - serviceNameState = state.customServiceNameState, - callbacks = CustomServiceDialogCallbacks( - onDismiss = state::dismissDialog, - onAddClick = state::onAddCustomService - ) - ) - ) - } - } - } - } - } -} - -@Composable -private fun EditContent( - state: AfternoteEditState, - onNavigateToAddSong: () -> Unit, - onNavigateToSelectReceiver: () -> Unit, - onPhotoAddClick: () -> Unit, - onVideoAddClick: () -> Unit, - onThumbnailBytesReady: (ByteArray?) -> Unit, - bottomPadding: PaddingValues -) { - Column( - modifier = Modifier.fillMaxSize() - ) { - // 메인 콘텐츠 (스크롤 가능, 남은 공간 차지) - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - ) { - Spacer(modifier = Modifier.height(24.dp)) - - // 종류 선택 (Line 279 해결: State의 메서드 사용) - SelectionDropdown( - labelParams = SelectionDropdownLabelParams(label = "종류"), - selectedValue = state.selectedCategory, - options = state.categories, - onValueSelected = state::onCategorySelected, - menuStyle = DropdownMenuStyle( - shadowElevation = 10.dp, - tonalElevation = 10.dp - ), - state = state.categoryDropdownState - ) - - // 서비스명 선택 (추모 가이드라인 선택 시 숨김) - if (state.selectedCategory != CATEGORY_MEMORIAL_GUIDELINE) { - Spacer(modifier = Modifier.height(16.dp)) - - SelectionDropdown( - labelParams = SelectionDropdownLabelParams(label = "서비스명"), - selectedValue = state.selectedService, - options = state.currentServiceOptions, - onValueSelected = state::onServiceSelected, - menuStyle = DropdownMenuStyle( - shadowElevation = 10.dp, - tonalElevation = 10.dp - ), - state = state.serviceDropdownState - ) - } - Spacer(modifier = Modifier.height(24.dp)) - - // 종류에 따라 다른 콘텐츠 표시 - // 각 Content 컴포넌트 내부에서 카테고리별 하단 여백 처리 - CategoryContent( - state = state, - onNavigateToAddSong = onNavigateToAddSong, - onNavigateToSelectReceiver = onNavigateToSelectReceiver, - onPhotoAddClick = onPhotoAddClick, - onVideoAddClick = onVideoAddClick, - onThumbnailBytesReady = onThumbnailBytesReady, - bottomPadding = bottomPadding - ) - } - } -} - -@Composable -private fun CategoryContent( - state: AfternoteEditState, - onNavigateToAddSong: () -> Unit, - onNavigateToSelectReceiver: () -> Unit, - onPhotoAddClick: () -> Unit, - onVideoAddClick: () -> Unit, - onThumbnailBytesReady: (ByteArray?) -> Unit, - bottomPadding: PaddingValues -) { - when (state.selectedCategory) { - CATEGORY_MEMORIAL_GUIDELINE -> { - val albumCoversFromPlaylist = state.playlistStateHolder?.songs?.let { songs -> - songs.mapIndexed { _, s -> - AlbumCover( - id = s.id, - imageUrl = s.albumCoverUrl, - title = s.title - ) - } - } ?: state.playlistAlbumCovers - val livePlaylistSongCount = - state.playlistStateHolder?.songs?.size ?: state.playlistSongCount - MemorialGuidelineEditContent( - bottomPadding = bottomPadding, - params = MemorialGuidelineEditContentParams( - displayMemorialPhotoUri = state.pickedMemorialPhotoUri ?: state.memorialPhotoUrl, - playlistSongCount = livePlaylistSongCount, - playlistAlbumCovers = albumCoversFromPlaylist, - selectedLastWish = state.selectedLastWish, - lastWishOptions = state.lastWishOptions, - funeralVideoUrl = state.funeralVideoUrl, - funeralThumbnailUrl = state.funeralThumbnailUrl, - customLastWishText = state.customLastWishText, - recipientSection = AfternoteEditReceiverSection( - afternoteEditReceivers = state.afternoteEditReceivers, - callbacks = AfternoteEditReceiverCallbacks( - onAddClick = onNavigateToSelectReceiver, - onItemDeleteClick = state::onAfternoteEditReceiverDelete, - onItemAdded = state::onAfternoteEditReceiverItemAdded - ) - ), - onSongAddClick = onNavigateToAddSong, - onLastWishSelected = state::onLastWishSelected, - onCustomLastWishChanged = state::onCustomLastWishChanged, - onPhotoAddClick = onPhotoAddClick, - onVideoAddClick = onVideoAddClick, - onThumbnailBytesReady = onThumbnailBytesReady - ) - ) - } - - CATEGORY_GALLERY_AND_FILE -> { - GalleryAndFileEditContent( - bottomPadding = bottomPadding, - params = GalleryAndFileEditContentParams( - messageState = state.messageState, - recipientSection = AfternoteEditReceiverSection( - afternoteEditReceivers = state.afternoteEditReceivers, - callbacks = AfternoteEditReceiverCallbacks( - onAddClick = onNavigateToSelectReceiver, - onItemDeleteClick = state::onAfternoteEditReceiverDelete, - onItemAdded = state::onAfternoteEditReceiverItemAdded - ) - ), - processingMethodSection = ProcessingMethodSection( - items = state.galleryProcessingMethods, - callbacks = state.galleryProcessingCallbacks - ) - ) - ) - } - - else -> { - SocialNetworkEditContent( - bottomPadding = bottomPadding, - params = SocialNetworkEditContentParams( - messageState = state.messageState, - accountSection = AccountSection( - idState = state.idState, - passwordState = state.passwordState, - selectedMethod = state.selectedProcessingMethod, - onMethodSelected = state::onProcessingMethodSelected - ), - recipientSection = AfternoteEditReceiverSection( - afternoteEditReceivers = state.afternoteEditReceivers, - callbacks = AfternoteEditReceiverCallbacks( - onAddClick = onNavigateToSelectReceiver, - onItemDeleteClick = state::onAfternoteEditReceiverDelete, - onItemAdded = state::onAfternoteEditReceiverItemAdded - ) - ), - processingMethodSection = ProcessingMethodSection( - items = state.processingMethods, - callbacks = state.socialProcessingCallbacks - ) - ) - ) - } - } -} - -@Preview( - showBackground = true, - device = "spec:width=390dp,height=844dp,dpi=420,isRound=false" -) -@Composable -private fun AfternoteEditScreenPreview() { - AfternoteLightTheme { - CompositionLocalProvider( - DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider() - ) { - AfternoteEditScreen( - callbacks = AfternoteEditScreenCallbacks(onBackClick = {}) - ) - } - } -} - -@Preview( - showBackground = true, - device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "갤러리 및 파일" -) -@Composable -private fun AfternoteEditScreenGalleryAndFilePreview() { - AfternoteLightTheme { - CompositionLocalProvider( - DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider() - ) { - val state = rememberAfternoteEditState().apply { - onCategorySelected(CATEGORY_GALLERY_AND_FILE) - } - AfternoteEditScreen( - callbacks = AfternoteEditScreenCallbacks(onBackClick = {}), - state = state - ) - } - } -} - -@Preview( - showBackground = true, - device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "추모 가이드라인" -) -@Composable -private fun AfternoteEditScreenMemorialGuidelinePreview() { - AfternoteLightTheme { - CompositionLocalProvider( - DataProviderLocals.LocalAfternoteEditDataProvider provides FakeAfternoteEditDataProvider() - ) { - val state = rememberAfternoteEditState().apply { - onCategorySelected(CATEGORY_MEMORIAL_GUIDELINE) - } - AfternoteEditScreen( - callbacks = AfternoteEditScreenCallbacks(onBackClick = {}), - state = state - ) - } - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/ProcessingMethodManager.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/ProcessingMethodManager.kt deleted file mode 100644 index eea3df96..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/ProcessingMethodManager.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.kuit.afternote.feature.afternote.presentation.screen - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem - -/** - * Holds processing method list state and actions for the afternote edit screen. - * Extracted from [AfternoteEditState] to keep function count under the detekt threshold. - */ -@Stable -class ProcessingMethodManager { - - var processingMethods by mutableStateOf>(emptyList()) - private set - var galleryProcessingMethods by mutableStateOf>(emptyList()) - private set - - fun replaceProcessingMethods(list: List) { - processingMethods = list - } - - fun replaceGalleryProcessingMethods(list: List) { - galleryProcessingMethods = list - } - - fun editProcessingMethod(itemId: String, newText: String) { - processingMethods = processingMethods.map { item -> - if (item.id == itemId) item.copy(text = newText) else item - } - } - - fun deleteProcessingMethod(itemId: String) { - processingMethods = processingMethods.filter { it.id != itemId } - } - - fun addProcessingMethod(text: String) { - val newItem = ProcessingMethodItem( - id = (processingMethods.size + 1).toString(), - text = text - ) - processingMethods = processingMethods + newItem - } - - fun editGalleryProcessingMethod(itemId: String, newText: String) { - galleryProcessingMethods = galleryProcessingMethods.map { item -> - if (item.id == itemId) item.copy(text = newText) else item - } - } - - fun deleteGalleryProcessingMethod(itemId: String) { - galleryProcessingMethods = galleryProcessingMethods.filter { it.id != itemId } - } - - fun addGalleryProcessingMethod(text: String) { - val newItem = ProcessingMethodItem( - id = (galleryProcessingMethods.size + 1).toString(), - text = text - ) - galleryProcessingMethods = galleryProcessingMethods + newItem - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/auth/data/api/AuthApiService.kt b/app/src/main/java/com/kuit/afternote/feature/auth/data/api/AuthApiService.kt index 81f93f60..7cf783ce 100644 --- a/app/src/main/java/com/kuit/afternote/feature/auth/data/api/AuthApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/auth/data/api/AuthApiService.kt @@ -1,7 +1,6 @@ package com.kuit.afternote.feature.auth.data.api -import com.kuit.afternote.data.remote.ApiResponse -import com.kuit.afternote.feature.auth.data.dto.SocialLoginRequest +import com.kuit.afternote.data.BaseResponse import com.kuit.afternote.feature.auth.data.dto.LoginData import com.kuit.afternote.feature.auth.data.dto.LoginRequest import com.kuit.afternote.feature.auth.data.dto.LogoutRequest @@ -11,6 +10,7 @@ import com.kuit.afternote.feature.auth.data.dto.ReissueRequest import com.kuit.afternote.feature.auth.data.dto.SendEmailCodeRequest import com.kuit.afternote.feature.auth.data.dto.SignUpData import com.kuit.afternote.feature.auth.data.dto.SignUpRequest +import com.kuit.afternote.feature.auth.data.dto.SocialLoginRequest import com.kuit.afternote.feature.auth.data.dto.VerifyEmailData import com.kuit.afternote.feature.auth.data.dto.VerifyEmailRequest import retrofit2.http.Body @@ -26,41 +26,41 @@ import retrofit2.http.POST interface AuthApiService { @POST("auth/email/send") suspend fun sendEmailCode( - @Body body: SendEmailCodeRequest - ): ApiResponse + @Body body: SendEmailCodeRequest, + ): BaseResponse @POST("auth/email/verify") suspend fun verifyEmail( - @Body body: VerifyEmailRequest - ): ApiResponse + @Body body: VerifyEmailRequest, + ): BaseResponse @POST("auth/sign-up") suspend fun signUp( - @Body body: SignUpRequest - ): ApiResponse + @Body body: SignUpRequest, + ): BaseResponse @POST("auth/login") suspend fun login( - @Body body: LoginRequest - ): ApiResponse + @Body body: LoginRequest, + ): BaseResponse @POST("auth/social/login") suspend fun socialLogin( - @Body body: SocialLoginRequest - ): ApiResponse + @Body body: SocialLoginRequest, + ): BaseResponse @POST("auth/reissue") suspend fun reissue( - @Body body: ReissueRequest - ): ApiResponse + @Body body: ReissueRequest, + ): BaseResponse @POST("auth/logout") suspend fun logout( - @Body body: LogoutRequest - ): ApiResponse + @Body body: LogoutRequest, + ): BaseResponse @POST("auth/password/change") suspend fun passwordChange( - @Body body: PasswordChangeRequest - ): ApiResponse + @Body body: PasswordChangeRequest, + ): BaseResponse } diff --git a/app/src/main/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImpl.kt index 6f2a7a88..415d8c13 100644 --- a/app/src/main/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImpl.kt @@ -1,16 +1,16 @@ package com.kuit.afternote.feature.auth.data.repository import android.util.Log -import com.kuit.afternote.data.remote.requireData -import com.kuit.afternote.data.remote.requireSuccess +import com.kuit.afternote.data.requireData +import com.kuit.afternote.data.requireStatus import com.kuit.afternote.feature.auth.data.api.AuthApiService -import com.kuit.afternote.feature.auth.data.dto.SocialLoginRequest import com.kuit.afternote.feature.auth.data.dto.LoginRequest import com.kuit.afternote.feature.auth.data.dto.LogoutRequest import com.kuit.afternote.feature.auth.data.dto.PasswordChangeRequest import com.kuit.afternote.feature.auth.data.dto.ReissueRequest import com.kuit.afternote.feature.auth.data.dto.SendEmailCodeRequest import com.kuit.afternote.feature.auth.data.dto.SignUpRequest +import com.kuit.afternote.feature.auth.data.dto.SocialLoginRequest import com.kuit.afternote.feature.auth.data.dto.VerifyEmailData import com.kuit.afternote.feature.auth.data.dto.VerifyEmailRequest import com.kuit.afternote.feature.auth.data.mapper.AuthMapper @@ -27,7 +27,7 @@ import javax.inject.Inject class AuthRepositoryImpl @Inject constructor( - private val api: AuthApiService + private val api: AuthApiService, ) : AuthRepository { override suspend fun sendEmailCode(email: String): Result = runCatching { @@ -38,13 +38,13 @@ class AuthRepositoryImpl override suspend fun verifyEmail( email: String, - certificateCode: String + certificateCode: String, ): Result = runCatching { Log.d(TAG, "verifyEmail: email=$email, code=$certificateCode") val response = api.verifyEmail(VerifyEmailRequest(email, certificateCode)) Log.d(TAG, "verifyEmail: response=$response") - response.requireSuccess() + response.requireStatus() AuthMapper.toEmailVerifyResult(response.data ?: VerifyEmailData(isVerified = null)) } @@ -53,7 +53,7 @@ class AuthRepositoryImpl email: String, password: String, name: String, - profileUrl: String? + profileUrl: String?, ): Result = runCatching { Log.d(TAG, "signUp: email=$email, name=$name") @@ -64,7 +64,7 @@ class AuthRepositoryImpl override suspend fun login( email: String, - password: String + password: String, ): Result = runCatching { Log.d(TAG, "login: email=$email") @@ -97,7 +97,7 @@ class AuthRepositoryImpl override suspend fun passwordChange( currentPassword: String, - newPassword: String + newPassword: String, ): Result = runCatching { Log.d(TAG, "passwordChange: calling API") diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt index 993f8a4e..c4bab541 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt @@ -1,6 +1,5 @@ package com.kuit.afternote.feature.dailyrecord.data.api -import com.kuit.afternote.data.remote.ApiResponse import com.kuit.afternote.feature.dailyrecord.data.dto.* import retrofit2.Response import retrofit2.http.* @@ -9,7 +8,6 @@ import retrofit2.http.* * 마음의 기록 API 서비스 (Swagger 기준) */ interface DailyRecordApiService { - @GET("daily-question") suspend fun getDailyQuestion(): DailyQuestionResponse @@ -18,40 +16,39 @@ interface DailyRecordApiService { @Query("type") type: String, @Query("view") view: String = "LIST", @Query("year") year: Int? = null, - @Query("month") month: Int? = null + @Query("month") month: Int? = null, ): MindRecordListResponse // 기록 작성 @POST("mind-records") suspend fun createMindRecord( - @Body request: CreateMindRecordRequest + @Body request: CreateMindRecordRequest, ): PostMindRecordResponse - // 단건 조회 @GET("mind-records/{recordId}") suspend fun getMindRecord( - @Path("recordId") recordId: Long + @Path("recordId") recordId: Long, ): Response // 기록 수정 @PATCH("mind-records/{recordId}") suspend fun editMindRecord( @Path("recordId") recordId: Long, - @Body request: PostMindRecordRequest + @Body request: PostMindRecordRequest, ): Response // 기록 삭제 @DELETE("mind-records/{recordId}") suspend fun deleteMindRecord( - @Path("recordId") recordId: Long + @Path("recordId") recordId: Long, ): Response @PATCH("mind-records/{recordId}/receivers/{receiverId}") suspend fun setMindRecordReceiverEnabled( @Path("recordId") recordId: Long, @Path("receiverId") receiverId: Long, - @Body request: ReceiverEnabledRequest + @Body request: ReceiverEnabledRequest, ): Response @GET("/api/v1/emotions") diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt index c33dcc83..49241e0f 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt @@ -1,22 +1,14 @@ package com.kuit.afternote.feature.dailyrecord.presentation.screen import android.os.Build -import android.util.Log import androidx.annotation.RequiresApi import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -24,11 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -36,15 +24,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteListViewModel import com.kuit.afternote.feature.dailyrecord.presentation.component.EmotionBubbleReport import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordAllSeeReport import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordCurrentWeek import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordListItem import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordTextComponent import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordWeekTotal -import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordWeekendReview -import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordweekendMindKeyword import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordViewModel import com.kuit.afternote.ui.theme.Gray1 import java.time.DayOfWeek @@ -59,7 +44,7 @@ fun RecordWeekendReportScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, - mindRecordViewModel: MindRecordViewModel = hiltViewModel() + mindRecordViewModel: MindRecordViewModel = hiltViewModel(), ) { val weekState = mindRecordViewModel.totalSummary.collectAsStateWithLifecycle() val dailyState = mindRecordViewModel.dailyQuestionSummary.collectAsStateWithLifecycle() @@ -86,9 +71,10 @@ fun RecordWeekendReportScreen( // 이번 주의 일요일 val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) Scaffold( - modifier = modifier - .fillMaxSize() - .background(color = Gray1), + modifier = + modifier + .fillMaxSize() + .background(color = Gray1), containerColor = Gray1, bottomBar = { BottomNavigationBar( @@ -96,12 +82,12 @@ fun RecordWeekendReportScreen( onItemSelected = { item -> selectedBottomNavItem = item onBottomNavTabSelected(item) - } + }, ) - } + }, ) { paddingValues -> LazyColumn( - modifier = Modifier.padding(paddingValues) + modifier = Modifier.padding(paddingValues), ) { item { TopBar( @@ -114,18 +100,18 @@ fun RecordWeekendReportScreen( today = today, week = weekOfMonth, startDate = startOfWeek, - endDate = endOfWeek + endDate = endOfWeek, ) } item { RecordWeekTotal( - weeklySummaryUiState = weekState.value + weeklySummaryUiState = weekState.value, ) } item { RecordAllSeeReport( dailySummary = dailyState.value, - afterNoteSummary = afternoteState.value + afterNoteSummary = afternoteState.value, ) } item { @@ -143,12 +129,11 @@ fun RecordWeekendReportScreen( mindRecordViewModel.deleteRecord( recordId = record.id, recordType = record.type ?: "DAILY_QUESTION", - onReload = { mindRecordViewModel.loadRecords("DAILY_QUESTION") } + onReload = { mindRecordViewModel.loadRecords("DAILY_QUESTION") }, ) }, onEditClick = { recordId -> - - } + }, ) // 이제 UIModel을 그대로 넘김 } } @@ -158,9 +143,9 @@ fun RecordWeekendReportScreen( @RequiresApi(Build.VERSION_CODES.O) @Preview @Composable -private fun RecordWeekendReportScreenPreview(){ +private fun RecordWeekendReportScreenPreview() { RecordWeekendReportScreen( onBackClick = {}, - onBottomNavTabSelected = {} + onBottomNavTabSelected = {}, ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt index 97ff1a18..c8270165 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch import retrofit2.HttpException import java.time.DayOfWeek import java.time.LocalDate +import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.time.temporal.TemporalAdjusters import java.util.Locale @@ -44,7 +45,7 @@ data class CreateRecordParams( val date: String, val isDraft: Boolean, val questionId: Long? = null, - val category: String? = null + val category: String? = null, ) /** Params for [MindRecordViewModel.editRecord] to satisfy S107 (max 7 params). */ @@ -55,7 +56,7 @@ data class EditRecordParams( val date: String, val type: String, val category: String?, - val isDraft: Boolean + val isDraft: Boolean, ) /** @@ -63,358 +64,402 @@ data class EditRecordParams( */ interface MindRecordHomeContract { val calendarDays: StateFlow> + fun loadRecordsForDiaryList() } @HiltViewModel -class MindRecordViewModel @Inject constructor( - private val getMindRecordsUseCase: GetMindRecordsUseCase, - private val createMindRecordUseCase: CreateMindRecordUseCase, - private val getDailyQuestionUseCase: GetDailyQuestionUseCase, - private val deleteMindRecordUseCase: DeleteMindRecordUseCase, - private val getMindRecordUseCase: GetMindRecordUseCase, - private val editMindRecordUseCase: EditMindRecordUseCase, - private val getAfternotesUseCase: GetAfternotesUseCase, - private val getEmotionsUseCase: GetEmotionsUseCase -) : ViewModel(), MindRecordHomeContract { - - // --- State 정의 --- - private val _records = MutableStateFlow>(emptyList()) - val records: StateFlow> = _records - - private val _emotions = MutableStateFlow(EmotionResponse(emotions = emptyList())) - val emotions: StateFlow = _emotions - fun getEmotions() { - viewModelScope.launch { - Log.d("TRACE_VM", "1. getEmotions 시작") - try { - val response = getEmotionsUseCase() - _emotions.value = response - Log.d("TRACE_VM", "2. 성공: ${response.emotions}") - } catch (e: Exception) { - // 이 로그가 찍혀야 범인을 잡을 수 있습니다. - Log.e("TRACE_VM", "3. 실패: ${e.message}", e) +class MindRecordViewModel + @Inject + constructor( + private val getMindRecordsUseCase: GetMindRecordsUseCase, + private val createMindRecordUseCase: CreateMindRecordUseCase, + private val getDailyQuestionUseCase: GetDailyQuestionUseCase, + private val deleteMindRecordUseCase: DeleteMindRecordUseCase, + private val getMindRecordUseCase: GetMindRecordUseCase, + private val editMindRecordUseCase: EditMindRecordUseCase, + private val getAfternotesUseCase: GetAfternotesUseCase, + private val getEmotionsUseCase: GetEmotionsUseCase, + ) : ViewModel(), + MindRecordHomeContract { + // --- State 정의 --- + private val _records = MutableStateFlow>(emptyList()) + val records: StateFlow> = _records + + private val _emotions = MutableStateFlow(EmotionResponse(emotions = emptyList())) + val emotions: StateFlow = _emotions + + fun getEmotions() { + viewModelScope.launch { + Log.d("TRACE_VM", "1. getEmotions 시작") + try { + val response = getEmotionsUseCase() + _emotions.value = response + Log.d("TRACE_VM", "2. 성공: ${response.emotions}") + } catch (e: Exception) { + // 이 로그가 찍혀야 범인을 잡을 수 있습니다. + Log.e("TRACE_VM", "3. 실패: ${e.message}", e) + } } } - } - // [추가] 리포트용 독립적 데이터 소스 (UI 데이터 스트림 분리) - private val _dailyQuestionRecords = MutableStateFlow>(emptyList()) - private val _totalRecords = MutableStateFlow>(emptyList()) - private val _afternoteRecords = MutableStateFlow>(emptyList()) + // [추가] 리포트용 독립적 데이터 소스 (UI 데이터 스트림 분리) + private val _dailyQuestionRecords = MutableStateFlow>(emptyList()) + private val _totalRecords = MutableStateFlow>(emptyList()) + private val _afternoteRecords = MutableStateFlow>(emptyList()) - private val _markedDates = MutableStateFlow>(emptySet()) - val markedDates: StateFlow> = _markedDates + private val _markedDates = MutableStateFlow>(emptySet()) + val markedDates: StateFlow> = _markedDates - private val weekDates: List = run { - val now = LocalDate.now() - val monday = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - (0..6).map { monday.plusDays(it.toLong()) } - } + private val weekDates: List = + run { + val now = LocalDate.now() + val monday = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + (0..6).map { monday.plusDays(it.toLong()) } + } - // --- 리포트용 UI State --- - data class WeeklySummaryUiState( - val calendarDays: List = emptyList(), - val totalWeeklyCount: Int = 0, - val dayCounts: Map = emptyMap() - ) - fun loadWeeklyReportData() { - viewModelScope.launch { - // 모든 리포트 데이터를 병렬 혹은 순차적으로 로드 - launch { loadRecords("DAILY_QUESTION") } - launch { loadRecordsForDiaryList() } - launch { loadAfternoteRecords() } - launch { getEmotions() } - Log.d("emotion", emotions.value.emotions.toString()) + // --- 리포트용 UI State --- + data class WeeklySummaryUiState( + val calendarDays: List = emptyList(), + val totalWeeklyCount: Int = 0, + val dayCounts: Map = emptyMap(), + ) + + fun loadWeeklyReportData() { + viewModelScope.launch { + // 모든 리포트 데이터를 병렬 혹은 순차적으로 로드 + launch { loadRecords("DAILY_QUESTION") } + launch { loadRecordsForDiaryList() } + launch { loadAfternoteRecords() } + launch { getEmotions() } + Log.d("emotion", emotions.value.emotions.toString()) + } } - } - // [추가] 데일리 질문 주간 통계 스트림 - val dailyQuestionSummary: StateFlow = _dailyQuestionRecords - .map { calculateWeeklySummary(it) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) - - val afternoteSummary: StateFlow = _afternoteRecords - .map { calculateWeeklySummary(it) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) - // [추가] 전체 기록 주간 통계 스트림 - val totalSummary: StateFlow = _totalRecords - .map { calculateWeeklySummary(it) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) - - // 기존 캘린더 데이 (메인 화면용 유지) - override val calendarDays: StateFlow> = _records - .map { recordList -> - val recordedDates = recordList.map { it.originalDate }.toSet() - val today = LocalDate.now() - weekDates.map { date -> - val dateString = date.toString() - val style = when { - date.isEqual(today) -> CalendarDayStyle.TODAY - recordedDates.contains(dateString) -> CalendarDayStyle.FILLED - else -> CalendarDayStyle.OUTLINED - } - CalendarDay( - dayLabel = date.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN), - date = date.dayOfMonth, - style = style + + // [추가] 데일리 질문 주간 통계 스트림 + val dailyQuestionSummary: StateFlow = + _dailyQuestionRecords + .map { calculateWeeklySummary(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) + + val afternoteSummary: StateFlow = + _afternoteRecords + .map { calculateWeeklySummary(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) + + // [추가] 전체 기록 주간 통계 스트림 + val totalSummary: StateFlow = + _totalRecords + .map { calculateWeeklySummary(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) + + // 기존 캘린더 데이 (메인 화면용 유지) + override val calendarDays: StateFlow> = + _records + .map { recordList -> + val recordedDates = recordList.map { it.originalDate }.toSet() + val today = LocalDate.now() + weekDates.map { date -> + val dateString = date.toString() + val style = + when { + date.isEqual(today) -> CalendarDayStyle.TODAY + recordedDates.contains(dateString) -> CalendarDayStyle.FILLED + else -> CalendarDayStyle.OUTLINED + } + CalendarDay( + dayLabel = date.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN), + date = date.dayOfMonth, + style = style, + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + // --- Helper Functions --- + + private fun loadAfternoteRecords() { + viewModelScope.launch { + getAfternotesUseCase(category = null, page = 0, size = 50).fold( + onSuccess = { pagedData -> + // 매핑 함수 사용 + _afternoteRecords.value = pagedData.items.map { it.toMindRecordUiModel() } + }, + onFailure = { Log.e(TAG, "Afternote 로드 실패", it) }, ) } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - // --- Helper Functions --- - - private fun loadAfternoteRecords() { - viewModelScope.launch { - getAfternotesUseCase(category = null, page = 0, size = 50).fold( - onSuccess = { pagedData -> - // 매핑 함수 사용 - _afternoteRecords.value = pagedData.items.map { it.toMindRecordUiModel() } - }, - onFailure = { Log.e(TAG, "Afternote 로드 실패", it) } - ) - } - } - // [추가] 주간 통계 계산 로직 (Pure Function) - private fun calculateWeeklySummary(recordList: List): WeeklySummaryUiState { - val today = LocalDate.now() - val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - val sunday = monday.plusDays(6) - - val weekRange = (0..6).map { monday.plusDays(it.toLong()) } - - // 이번 주 범위 내의 기록만 필터링 - val recordsInThisWeek = recordList.filter { - val d = LocalDate.parse(it.originalDate) - !d.isBefore(monday) && !d.isAfter(sunday) } - val countsByDate = recordsInThisWeek - .groupBy { it.originalDate } - .mapValues { it.value.size } - - val calendarDays = weekRange.map { date -> - val dateString = date.toString() - CalendarDay( - dayLabel = date.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN), - date = date.dayOfMonth, - style = when { - countsByDate.containsKey(dateString) -> CalendarDayStyle.FILLED - date.isEqual(today) -> CalendarDayStyle.TODAY - else -> CalendarDayStyle.OUTLINED - } - ) - } + // [추가] 주간 통계 계산 로직 (Pure Function) + private fun calculateWeeklySummary(recordList: List): WeeklySummaryUiState { + val today = LocalDate.now() + val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val sunday = monday.plusDays(6) - return WeeklySummaryUiState( - calendarDays = calendarDays, - totalWeeklyCount = recordsInThisWeek.size, - dayCounts = countsByDate - ) - } + val weekRange = (0..6).map { monday.plusDays(it.toLong()) } - private fun formatDate(date: String): String { - val parts = date.split("-") - return "${parts[1].toInt()}월 ${parts[2].toInt()}일" - } + // 이번 주 범위 내의 기록만 필터링 + val recordsInThisWeek = + recordList.filter { + val d = LocalDate.parse(it.originalDate) + !d.isBefore(monday) && !d.isAfter(sunday) + } - // --- Public Functions (Business Logic) --- - - fun loadRecords( - type: String, - view: String = "LIST", - year: Int? = null, - month: Int? = null - ) { - viewModelScope.launch { - try { - val result = getMindRecordsUseCase(type, view, year, month) - val domainRecords = result.records - val uiModels = domainRecords.map { summary -> - MindRecordUiModel( - id = summary.recordId, - title = summary.title ?: "", - formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date, - draftLabel = if (summary.isDraft) "임시저장" else "완료", - content = summary.content, - originalDate = summary.date, - type = summary.type + val countsByDate = + recordsInThisWeek + .groupBy { it.originalDate } + .mapValues { it.value.size } + + val calendarDays = + weekRange.map { date -> + val dateString = date.toString() + CalendarDay( + dayLabel = date.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN), + date = date.dayOfMonth, + style = + when { + countsByDate.containsKey(dateString) -> CalendarDayStyle.FILLED + date.isEqual(today) -> CalendarDayStyle.TODAY + else -> CalendarDayStyle.OUTLINED + }, ) } - _records.value = uiModels + return WeeklySummaryUiState( + calendarDays = calendarDays, + totalWeeklyCount = recordsInThisWeek.size, + dayCounts = countsByDate, + ) + } - // [추가] 데일리 질문인 경우 리포트용 전용 상태 업데이트 - if (type == "DAILY_QUESTION") { - _dailyQuestionRecords.value = uiModels - } + private fun formatDate(date: String): String { + val parts = date.split("-") + return "${parts[1].toInt()}월 ${parts[2].toInt()}일" + } - val apiMarked = result.markedDates.toSet() - _markedDates.value = if (apiMarked.isNotEmpty()) apiMarked else uiModels.map { it.originalDate }.toSet() - } catch (e: Exception) { - Log.e(TAG, "loadRecords 실패", e) - _records.value = emptyList() + // --- Public Functions (Business Logic) --- + + fun loadRecords( + type: String, + view: String = "LIST", + year: Int? = null, + month: Int? = null, + ) { + viewModelScope.launch { + try { + val result = getMindRecordsUseCase(type, view, year, month) + val domainRecords = result.records + val uiModels = + domainRecords.map { summary -> + MindRecordUiModel( + id = summary.recordId, + title = summary.title ?: "", + formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date, + draftLabel = if (summary.isDraft) "임시저장" else "완료", + content = summary.content, + originalDate = summary.date, + type = summary.type, + ) + } + + _records.value = uiModels + + // [추가] 데일리 질문인 경우 리포트용 전용 상태 업데이트 + if (type == "DAILY_QUESTION") { + _dailyQuestionRecords.value = uiModels + } + + val apiMarked = result.markedDates.toSet() + _markedDates.value = if (apiMarked.isNotEmpty()) apiMarked else uiModels.map { it.originalDate }.toSet() + } catch (e: Exception) { + Log.e(TAG, "loadRecords 실패", e) + _records.value = emptyList() + } } } - } - override fun loadRecordsForDiaryList() { - viewModelScope.launch { - try { - val diaryResult = getMindRecordsUseCase("DIARY", "LIST", null, null) - val deepResult = getMindRecordsUseCase("DEEP_THOUGHT", "LIST", null, null) - val combined = diaryResult.records + deepResult.records - val sorted = combined.sortedByDescending { it.date } - - val uiModels = sorted.map { summary -> - MindRecordUiModel( - id = summary.recordId, - title = summary.title ?: "", - formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date, - draftLabel = if (summary.isDraft) "임시저장" else "완료", - content = summary.content, - originalDate = summary.date, - type = summary.type - ) + override fun loadRecordsForDiaryList() { + viewModelScope.launch { + try { + val diaryResult = getMindRecordsUseCase("DIARY", "LIST", null, null) + val deepResult = getMindRecordsUseCase("DEEP_THOUGHT", "LIST", null, null) + val combined = diaryResult.records + deepResult.records + val sorted = combined.sortedByDescending { it.date } + + val uiModels = + sorted.map { summary -> + MindRecordUiModel( + id = summary.recordId, + title = summary.title ?: "", + formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date, + draftLabel = if (summary.isDraft) "임시저장" else "완료", + content = summary.content, + originalDate = summary.date, + type = summary.type, + ) + } + + _records.value = uiModels + // [추가] 전체 기록 리포트용 상태 업데이트 + _totalRecords.value = uiModels + + val apiMarkedDates = (diaryResult.markedDates + deepResult.markedDates).toSet() + _markedDates.value = if (apiMarkedDates.isNotEmpty()) apiMarkedDates else uiModels.map { it.originalDate }.toSet() + } catch (e: Exception) { + Log.e(TAG, "loadRecordsForDiaryList 실패", e) + _records.value = emptyList() } - - _records.value = uiModels - // [추가] 전체 기록 리포트용 상태 업데이트 - _totalRecords.value = uiModels - - val apiMarkedDates = (diaryResult.markedDates + deepResult.markedDates).toSet() - _markedDates.value = if (apiMarkedDates.isNotEmpty()) apiMarkedDates else uiModels.map { it.originalDate }.toSet() - } catch (e: Exception) { - Log.e(TAG, "loadRecordsForDiaryList 실패", e) - _records.value = emptyList() } } - } - // [추가] 두 가지 리포트 데이터를 한 번에 갱신하는 함수 - fun loadAllReportData() { - loadRecords("DAILY_QUESTION") - loadRecordsForDiaryList() - } + // [추가] 두 가지 리포트 데이터를 한 번에 갱신하는 함수 + fun loadAllReportData() { + loadRecords("DAILY_QUESTION") + loadRecordsForDiaryList() + } - // --- 나머지 기존 함수 유지 --- + // --- 나머지 기존 함수 유지 --- - private val _uiState = MutableStateFlow(MindRecordUiState()) - val uiState: StateFlow = _uiState + private val _uiState = MutableStateFlow(MindRecordUiState()) + val uiState: StateFlow = _uiState - fun loadDailyQuestion() { - viewModelScope.launch { - val data = getDailyQuestionUseCase() - _uiState.update { it.copy(dailyQuestionText = data?.content) } + fun loadDailyQuestion() { + viewModelScope.launch { + val data = getDailyQuestionUseCase() + _uiState.update { it.copy(dailyQuestionText = data?.content) } + } } - } - fun onCreateRecord(params: CreateRecordParams, onSuccess: () -> Unit = {}) { - viewModelScope.launch { - val resolvedQuestionId = when { - params.type == "DAILY_QUESTION" && params.questionId == null -> { - val data = getDailyQuestionUseCase() - if (data == null) { - _uiState.update { it.copy(createErrorMessage = "오늘의 질문을 불러올 수 없습니다.") } - return@launch + fun onCreateRecord( + params: CreateRecordParams, + onSuccess: () -> Unit = {}, + ) { + viewModelScope.launch { + val resolvedQuestionId = + when { + params.type == "DAILY_QUESTION" && params.questionId == null -> { + val data = getDailyQuestionUseCase() + if (data == null) { + _uiState.update { it.copy(createErrorMessage = "오늘의 질문을 불러올 수 없습니다.") } + return@launch + } + data.questionId + } + + else -> { + params.questionId + } } - data.questionId - } - else -> params.questionId - } - createMindRecordUseCase( - type = params.type, - title = params.title.ifBlank { null }, - content = params.content, - date = params.date, - isDraft = params.isDraft, - questionId = resolvedQuestionId, - category = params.category - ).fold( - onSuccess = { - _uiState.update { it.copy(createErrorMessage = null) } - loadRecords(params.type) - // 기록 생성 후 리포트 데이터도 함께 갱신하여 정합성 유지 - loadAllReportData() - onSuccess() - }, - onFailure = { e -> - Log.e(TAG, "onCreateRecord 실패", e) - val message = if (e is HttpException && e.code() == 401) "인증 만료" else e.message ?: "등록 실패" - _uiState.update { it.copy(createErrorMessage = message) } - } - ) + createMindRecordUseCase( + type = params.type, + title = params.title.ifBlank { null }, + content = params.content, + date = params.date, + isDraft = params.isDraft, + questionId = resolvedQuestionId, + category = params.category, + ).fold( + onSuccess = { + _uiState.update { it.copy(createErrorMessage = null) } + loadRecords(params.type) + // 기록 생성 후 리포트 데이터도 함께 갱신하여 정합성 유지 + loadAllReportData() + onSuccess() + }, + onFailure = { e -> + Log.e(TAG, "onCreateRecord 실패", e) + val message = if (e is HttpException && e.code() == 401) "인증 만료" else e.message ?: "등록 실패" + _uiState.update { it.copy(createErrorMessage = message) } + }, + ) + } } - } - fun clearCreateError() { - _uiState.update { it.copy(createErrorMessage = null) } - } + fun clearCreateError() { + _uiState.update { it.copy(createErrorMessage = null) } + } - fun deleteRecord(recordId: Long, recordType: String, onReload: (() -> Unit)? = null, onSuccess: () -> Unit = {}) { - viewModelScope.launch { - deleteMindRecordUseCase(recordId).fold( - onSuccess = { - if (onReload != null) onReload() else loadRecords(recordType) - loadAllReportData() // 삭제 후 통계 갱신 - onSuccess() - }, - onFailure = { e -> Log.e(TAG, "deleteRecord failed", e) } - ) + fun deleteRecord( + recordId: Long, + recordType: String, + onReload: (() -> Unit)? = null, + onSuccess: () -> Unit = {}, + ) { + viewModelScope.launch { + deleteMindRecordUseCase(recordId).fold( + onSuccess = { + if (onReload != null) onReload() else loadRecords(recordType) + loadAllReportData() // 삭제 후 통계 갱신 + onSuccess() + }, + onFailure = { e -> Log.e(TAG, "deleteRecord failed", e) }, + ) + } } - } - private val _selectedRecord = MutableStateFlow(null) - val selectedRecord: StateFlow = _selectedRecord - - fun loadRecord(recordId: Long) { - viewModelScope.launch { - try { - val response = getMindRecordUseCase(recordId).getOrThrow() - val detail = response.data - if (detail != null) { - _selectedRecord.value = MindRecordUiModel( - id = detail.recordId, - title = detail.title, - formattedDate = runCatching { formatDate(detail.date) }.getOrNull() ?: detail.date, - draftLabel = if (detail.isDraft) "임시저장" else "완료", - content = detail.content, - type = detail.type, - category = detail.category, - originalDate = detail.date - ) + private val _selectedRecord = MutableStateFlow(null) + val selectedRecord: StateFlow = _selectedRecord + + fun loadRecord(recordId: Long) { + viewModelScope.launch { + try { + val response = getMindRecordUseCase(recordId).getOrThrow() + val detail = response.data + if (detail != null) { + _selectedRecord.value = + MindRecordUiModel( + id = detail.recordId, + title = detail.title, + formattedDate = runCatching { formatDate(detail.date) }.getOrNull() ?: detail.date, + draftLabel = if (detail.isDraft) "임시저장" else "완료", + content = detail.content, + type = detail.type, + category = detail.category, + originalDate = detail.date, + ) + } + } catch (e: Exception) { + Log.e(TAG, "loadRecord failed", e) } - } catch (e: Exception) { - Log.e(TAG, "loadRecord failed", e) } } - } - fun editRecord(params: EditRecordParams, onSuccess: () -> Unit = {}) { - viewModelScope.launch { - try { - val request = PostMindRecordRequest( - params.title, params.content, params.date, params.type, params.isDraft, null, params.category - ) - editMindRecordUseCase(params.recordId, request).getOrThrow() - loadRecords(params.type) - loadAllReportData() // 수정 후 통계 갱신 - onSuccess() - } catch (e: Exception) { - Log.e(TAG, "editRecord failed", e) + fun editRecord( + params: EditRecordParams, + onSuccess: () -> Unit = {}, + ) { + viewModelScope.launch { + try { + val request = + PostMindRecordRequest( + params.title, + params.content, + params.date, + params.type, + params.isDraft, + null, + params.category, + ) + editMindRecordUseCase(params.recordId, request).getOrThrow() + loadRecords(params.type) + loadAllReportData() // 수정 후 통계 갱신 + onSuccess() + } catch (e: Exception) { + Log.e(TAG, "editRecord failed", e) + } } } } -} fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel { // 1. 날짜 포맷팅 로직 (안정성을 위해 java.time 사용) - val inputFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy.MM.dd") - val isoFormatter = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd + val inputFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") + val isoFormatter = DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd - val parsedDate = runCatching { - java.time.LocalDate.parse(this.date, inputFormatter) - }.getOrNull() ?: java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) + val parsedDate = + runCatching { + LocalDate.parse(this.date, inputFormatter) + }.getOrNull() ?: LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) val originalDateStr = parsedDate.format(isoFormatter) // "2026-02-06" val formattedDateStr = "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" @@ -425,10 +470,9 @@ fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel { title = this.serviceName, formattedDate = formattedDateStr, draftLabel = "완료", // 애프터노트는 도메인상 완료된 데이터로 간주 - content = this.message, type = this.type.name, category = null, // 필요 시 mapping 로직 추가 - originalDate = originalDateStr + originalDate = originalDateStr, + content = this.processing.message, ) } - diff --git a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModel.kt index b21c47cb..ef3508bc 100644 --- a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModel.kt @@ -3,8 +3,8 @@ package com.kuit.afternote.feature.onboarding.presentation.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.data.local.TokenManager -import com.kuit.afternote.data.remote.ApiException +import com.kuit.afternote.data.service.ApiException +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.auth.domain.usecase.KakaoLoginUseCase import com.kuit.afternote.feature.auth.domain.usecase.LoginUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -30,7 +30,7 @@ class LoginViewModel constructor( private val loginUseCase: LoginUseCase, private val kakaoLoginUseCase: KakaoLoginUseCase, - private val tokenManager: TokenManager + private val tokenManager: TokenManager, ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -42,7 +42,7 @@ class LoginViewModel */ fun login( email: String, - password: String + password: String, ) { if (email.isBlank() || password.isBlank()) { _uiState.update { it.copy(errorMessage = "이메일과 비밀번호를 입력하세요.") } @@ -59,7 +59,7 @@ class LoginViewModel tokenManager.saveTokens( accessToken = accessToken, refreshToken = refreshToken, - email = email + email = email, ) } _uiState.update { @@ -95,7 +95,7 @@ class LoginViewModel if (!accessToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty()) { tokenManager.saveTokens( accessToken = accessToken, - refreshToken = refreshToken + refreshToken = refreshToken, ) } _uiState.update { @@ -107,7 +107,7 @@ class LoginViewModel _uiState.update { it.copy( isLoading = false, - errorMessage = message.ifBlank { "카카오 로그인에 실패했습니다." } + errorMessage = message.ifBlank { "카카오 로그인에 실패했습니다." }, ) } } @@ -119,12 +119,13 @@ class LoginViewModel * 빈 문자열이 되지 않도록 항상 기본 문구를 반환한다. */ private fun mapLoginErrorToMessage(e: Throwable): String { - val raw = when (e) { - is ApiException -> e.message.ifBlank { null } - is HttpException -> parseLoginHttpError(e) - is IOException -> "네트워크 연결을 확인해주세요." - else -> e.message?.takeIf { it.isNotBlank() } - } + val raw = + when (e) { + is ApiException -> e.message.ifBlank { null } + is HttpException -> parseLoginHttpError(e) + is IOException -> "네트워크 연결을 확인해주세요." + else -> e.message?.takeIf { it.isNotBlank() } + } return raw?.takeIf { it.isNotBlank() } ?: "로그인에 실패했습니다." } @@ -145,9 +146,13 @@ class LoginViewModel private fun mapLoginHttpCodeToMessage(code: Int): String = when (code) { 400, - 401 -> "이메일 또는 비밀번호가 올바르지 않습니다." + 401, + -> "이메일 또는 비밀번호가 올바르지 않습니다." + 404 -> "등록되지 않은 이메일입니다." + 500, 502, 503 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + else -> "로그인에 실패했습니다. (오류 코드: $code)" } @@ -174,10 +179,11 @@ class LoginViewModel companion object { private const val TAG = "LoginViewModel" - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } } } @@ -186,5 +192,5 @@ class LoginViewModel private data class LoginErrorResponse( val status: Int? = null, val code: Int? = null, - val message: String? = null + val message: String? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModel.kt index 630856a7..064d5748 100644 --- a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModel.kt @@ -2,7 +2,7 @@ package com.kuit.afternote.feature.onboarding.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.auth.domain.usecase.LogoutUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +22,7 @@ class LogoutViewModel @Inject constructor( private val logoutUseCase: LogoutUseCase, - private val tokenManager: TokenManager + private val tokenManager: TokenManager, ) : ViewModel() { private val _uiState = MutableStateFlow(LogoutUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -59,7 +59,7 @@ class LogoutViewModel it.copy( isLoading = false, errorMessage = e.message ?: "로그아웃에 실패했습니다.", - logoutSuccess = true + logoutSuccess = true, ) } } @@ -91,7 +91,7 @@ class LogoutViewModel it.copy( isLoading = false, errorMessage = e.message ?: "로그아웃에 실패했습니다.", - logoutSuccess = true + logoutSuccess = true, ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/PasswordChangeViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/PasswordChangeViewModel.kt index 184a0cd2..ab332f05 100644 --- a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/PasswordChangeViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/PasswordChangeViewModel.kt @@ -25,7 +25,7 @@ import javax.inject.Inject class PasswordChangeViewModel @Inject constructor( - private val passwordChangeUseCase: PasswordChangeUseCase + private val passwordChangeUseCase: PasswordChangeUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow(PasswordChangeUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -33,10 +33,11 @@ class PasswordChangeViewModel // Race condition 방지: 진행 중 요청 식별자 private var currentRequestId = 0 - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } /** * 비밀번호 변경 시도. @@ -45,7 +46,7 @@ class PasswordChangeViewModel */ fun changePassword( currentPassword: String, - newPassword: String + newPassword: String, ) { Log.d(TAG, "changePassword called") Log.d(TAG, "currentPassword length: ${currentPassword.length}") @@ -56,10 +57,12 @@ class PasswordChangeViewModel Log.w(TAG, "Validation failed: currentPassword is blank") _uiState.update { it.copy(errorMessage = "현재 비밀번호를 입력하세요.") } } + newPassword.isBlank() -> { Log.w(TAG, "Validation failed: newPassword is blank") _uiState.update { it.copy(errorMessage = "새 비밀번호를 입력하세요.") } } + else -> { Log.d(TAG, "Validation passed, calling UseCase") runPasswordChange(currentPassword, newPassword) @@ -69,7 +72,7 @@ class PasswordChangeViewModel private fun runPasswordChange( currentPassword: String, - newPassword: String + newPassword: String, ) { viewModelScope.launch { Log.d(TAG, "runPasswordChange started - Optimistic update") @@ -84,7 +87,7 @@ class PasswordChangeViewModel isLoading = false, errorMessage = null, passwordChangeSuccess = true, - needsRollback = false + needsRollback = false, ) } @@ -93,12 +96,12 @@ class PasswordChangeViewModel .onSuccess { // 최신 요청인지 확인 (레이스 컨디션 방지) if (requestId == currentRequestId) { - Log.d(TAG, "Password change SUCCESS - API confirmed") + Log.d(TAG, "password change SUCCESS - API confirmed") } }.onFailure { e -> // 최신 요청인지 확인 (레이스 컨디션 방지) if (requestId == currentRequestId) { - Log.e(TAG, "Password change FAILED after optimistic update", e) + Log.e(TAG, "password change FAILED after optimistic update", e) Log.e(TAG, "Error message: ${e.message}") Log.e(TAG, "Error class: ${e::class.java.simpleName}") @@ -108,7 +111,7 @@ class PasswordChangeViewModel isLoading = false, passwordChangeSuccess = false, needsRollback = true, // 화면으로 돌아가야 함 - errorMessage = mapErrorToUserMessage(e) + errorMessage = mapErrorToUserMessage(e), ) } } @@ -157,35 +160,44 @@ class PasswordChangeViewModel */ private fun mapServerMessageToKorean( serverMessage: String, - code: Int + code: Int, ): String { // 서버 메시지에서 키워드를 찾아 적절한 한글 메시지 반환 return when { serverMessage.contains("format", ignoreCase = true) || - serverMessage.contains("pattern", ignoreCase = true) -> + serverMessage.contains("pattern", ignoreCase = true) -> { "비밀번호는 영문, 숫자, 특수문자를 포함한 8~20자여야 합니다." + } serverMessage.contains("current", ignoreCase = true) && - serverMessage.contains("password", ignoreCase = true) -> + serverMessage.contains("password", ignoreCase = true) -> { "현재 비밀번호가 일치하지 않습니다." + } serverMessage.contains("wrong", ignoreCase = true) || serverMessage.contains("incorrect", ignoreCase = true) || - serverMessage.contains("invalid", ignoreCase = true) -> + serverMessage.contains("invalid", ignoreCase = true) -> { "현재 비밀번호가 일치하지 않습니다." + } - serverMessage.contains("same", ignoreCase = true) -> + serverMessage.contains("same", ignoreCase = true) -> { "새 비밀번호가 현재 비밀번호와 동일합니다." + } serverMessage.contains("expired", ignoreCase = true) || - serverMessage.contains("token", ignoreCase = true) -> + serverMessage.contains("token", ignoreCase = true) -> { "로그인이 만료되었습니다. 다시 로그인해주세요." + } // 서버 메시지가 한글이면 그대로 사용 - serverMessage.any { it in '\uAC00'..'\uD7A3' } -> serverMessage + serverMessage.any { it in '\uAC00'..'\uD7A3' } -> { + serverMessage + } // 기타: 상태 코드 기반 메시지 - else -> mapHttpCodeToMessage(code) + else -> { + mapHttpCodeToMessage(code) + } } } @@ -235,5 +247,5 @@ class PasswordChangeViewModel private data class ErrorResponse( val status: Int? = null, val code: Int? = null, - val message: String? = null + val message: String? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModel.kt index d9828bbc..75dfcb09 100644 --- a/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModel.kt @@ -2,7 +2,7 @@ package com.kuit.afternote.feature.onboarding.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.auth.domain.usecase.LoginUseCase import com.kuit.afternote.feature.auth.domain.usecase.SignUpUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -25,7 +25,7 @@ class SignUpViewModel constructor( private val signUpUseCase: SignUpUseCase, private val loginUseCase: LoginUseCase, - private val tokenManager: TokenManager + private val tokenManager: TokenManager, ) : ViewModel() { private val _uiState = MutableStateFlow(SignUpUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -41,7 +41,7 @@ class SignUpViewModel email: String, password: String, name: String, - profileUrl: String? + profileUrl: String?, ) { when { email.isBlank() -> _uiState.update { it.copy(errorMessage = "이메일을 입력하세요.") } @@ -55,7 +55,7 @@ class SignUpViewModel email: String, password: String, name: String, - profileUrl: String? + profileUrl: String?, ) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } @@ -69,18 +69,17 @@ class SignUpViewModel tokenManager.saveTokens( accessToken = accessToken, refreshToken = refreshToken, - email = email + email = email, ) } _uiState.update { it.copy(isLoading = false, errorMessage = null, signUpSuccess = true) } - } - .onFailure { e -> + }.onFailure { e -> _uiState.update { it.copy( isLoading = false, - errorMessage = e.message ?: "회원가입은 완료되었습니다. 로그인해주세요." + errorMessage = e.message ?: "회원가입은 완료되었습니다. 로그인해주세요.", ) } } @@ -88,7 +87,7 @@ class SignUpViewModel _uiState.update { it.copy( isLoading = false, - errorMessage = e.message ?: "회원가입에 실패했습니다." + errorMessage = e.message ?: "회원가입에 실패했습니다.", ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/data/api/ReceivedApiService.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/data/api/ReceivedApiService.kt index 4e512e88..ec012e10 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/data/api/ReceivedApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/data/api/ReceivedApiService.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.receiver.data.api -import com.kuit.afternote.data.remote.ApiResponse +import com.kuit.afternote.data.BaseResponse import com.kuit.afternote.feature.receiver.data.dto.CreateMindRecordReceiverRequestDto import com.kuit.afternote.feature.receiver.data.dto.CreateTimeLetterReceiverRequestDto import retrofit2.http.Body @@ -12,7 +12,6 @@ import retrofit2.http.POST * - POST /api/received/mind-records: 마인드레코드 수신자 등록 */ interface ReceivedApiService { - /** * 타임레터에 수신자를 등록합니다. 여러 수신자를 한 번에 등록할 수 있습니다. * @@ -20,8 +19,8 @@ interface ReceivedApiService { */ @POST("api/received/time-letters") suspend fun registerTimeLetterReceivers( - @Body body: CreateTimeLetterReceiverRequestDto - ): ApiResponse?> + @Body body: CreateTimeLetterReceiverRequestDto, + ): BaseResponse?> /** * 마인드레코드에 수신자를 등록합니다. 여러 수신자를 한 번에 등록할 수 있습니다. @@ -30,7 +29,6 @@ interface ReceivedApiService { */ @POST("api/received/mind-records") suspend fun registerMindRecordReceivers( - @Body body: CreateMindRecordReceiverRequestDto - ): ApiResponse?> - + @Body body: CreateMindRecordReceiverRequestDto, + ): BaseResponse?> } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/data/dto/ReceivedApiDto.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/data/dto/ReceivedApiDto.kt index 15a9dbaa..f221896b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/data/dto/ReceivedApiDto.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/data/dto/ReceivedApiDto.kt @@ -17,8 +17,8 @@ import kotlinx.serialization.Serializable @Serializable data class CreateTimeLetterReceiverRequestDto( @SerialName("timeLetterID") val timeLetterId: Long, - @SerialName("receiverIds") val receiverIds: List, - @SerialName("deliveredAt") val deliveredAt: String? = null + @SerialName("receivers") val receiverIds: List, + @SerialName("deliveredAt") val deliveredAt: String? = null, ) // --- POST /api/received/mind-records (마인드레코드 수신자 등록) --- @@ -26,6 +26,5 @@ data class CreateTimeLetterReceiverRequestDto( @Serializable data class CreateMindRecordReceiverRequestDto( @SerialName("mindRecordId") val mindRecordId: Long, - @SerialName("receiverIds") val receiverIds: List + @SerialName("receivers") val receiverIds: List, ) - diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/data/repository/impl/ReceivedRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/data/repository/impl/ReceivedRepositoryImpl.kt index 11a2756f..9a6998d4 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/data/repository/impl/ReceivedRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/data/repository/impl/ReceivedRepositoryImpl.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.receiver.data.repository.impl -import com.kuit.afternote.data.remote.requireData +import com.kuit.afternote.data.requireData import com.kuit.afternote.feature.receiver.data.api.ReceivedApiService import com.kuit.afternote.feature.receiver.data.dto.CreateMindRecordReceiverRequestDto import com.kuit.afternote.feature.receiver.data.dto.CreateTimeLetterReceiverRequestDto @@ -15,38 +15,37 @@ import javax.inject.Inject class ReceivedRepositoryImpl @Inject constructor( - private val api: ReceivedApiService + private val api: ReceivedApiService, ) : ReceivedRepository { + override suspend fun registerTimeLetterReceivers( + timeLetterId: Long, + receiverIds: List, + deliveredAt: String?, + ): Result> = + runCatching { + val response = + api.registerTimeLetterReceivers( + CreateTimeLetterReceiverRequestDto( + timeLetterId = timeLetterId, + receiverIds = receiverIds, + deliveredAt = deliveredAt, + ), + ) + response.requireData() + } - override suspend fun registerTimeLetterReceivers( - timeLetterId: Long, - receiverIds: List, - deliveredAt: String? - ): Result> = - runCatching { - val response = api.registerTimeLetterReceivers( - CreateTimeLetterReceiverRequestDto( - timeLetterId = timeLetterId, - receiverIds = receiverIds, - deliveredAt = deliveredAt - ) - ) - response.requireData() - } - - override suspend fun registerMindRecordReceivers( - mindRecordId: Long, - receiverIds: List - ): Result> = - runCatching { - val response = api.registerMindRecordReceivers( - CreateMindRecordReceiverRequestDto( - mindRecordId = mindRecordId, - receiverIds = receiverIds - ) - ) - response.requireData() - } - - -} + override suspend fun registerMindRecordReceivers( + mindRecordId: Long, + receiverIds: List, + ): Result> = + runCatching { + val response = + api.registerMindRecordReceivers( + CreateMindRecordReceiverRequestDto( + mindRecordId = mindRecordId, + receiverIds = receiverIds, + ), + ) + response.requireData() + } + } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/api/ReceiverAuthApiService.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/api/ReceiverAuthApiService.kt index 59b5097c..18be3f13 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/api/ReceiverAuthApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/api/ReceiverAuthApiService.kt @@ -1,24 +1,24 @@ package com.kuit.afternote.feature.receiverauth.data.api -import com.kuit.afternote.data.remote.ApiResponse +import com.kuit.afternote.data.BaseResponse import com.kuit.afternote.feature.receiverauth.data.dto.DeliveryVerificationRequestDto import com.kuit.afternote.feature.receiverauth.data.dto.DeliveryVerificationResponseDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlRequestDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlResponseDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyRequestDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyResponseDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverMessageResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedAfternoteDetailAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedAfternoteListAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedMindRecordDetailAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedMindRecordListAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedTimeLetterDetailAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedTimeLetterListAuthResponseDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlRequestDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlResponseDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyRequestDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyResponseDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverMessageResponseDto import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header -import retrofit2.http.Path import retrofit2.http.POST +import retrofit2.http.Path /** * 수신자 인증번호 기반 콘텐츠 조회 API 서비스. (스웨거 기준) @@ -36,7 +36,6 @@ import retrofit2.http.POST * - GET /api/receiver-auth/after-notes/{afternoteId}: 애프터노트 상세 조회 */ interface ReceiverAuthApiService { - /** * 수신자 인증번호를 검증하고 수신자/발신자 정보를 반환합니다. * @@ -45,8 +44,8 @@ interface ReceiverAuthApiService { */ @POST("api/receiver-auth/verify") suspend fun verify( - @Body body: ReceiverAuthVerifyRequestDto - ): ApiResponse + @Body body: ReceiverAuthVerifyRequestDto, + ): BaseResponse /** * 수신자 파일 업로드용 Presigned URL을 생성합니다. @@ -60,8 +59,8 @@ interface ReceiverAuthApiService { @POST("api/receiver-auth/presigned-url") suspend fun getPresignedUrl( @Header("X-Auth-Code") authCode: String, - @Body body: ReceiverAuthPresignedUrlRequestDto - ): ApiResponse + @Body body: ReceiverAuthPresignedUrlRequestDto, + ): BaseResponse /** * 사망확인 서류(사망진단서, 가족관계증명서)를 제출합니다. @@ -74,8 +73,8 @@ interface ReceiverAuthApiService { @POST("api/receiver-auth/delivery-verification") suspend fun submitDeliveryVerification( @Header("X-Auth-Code") authCode: String, - @Body body: DeliveryVerificationRequestDto - ): ApiResponse + @Body body: DeliveryVerificationRequestDto, + ): BaseResponse /** * 수신자가 마지막으로 제출한 인증 요청 상태를 조회합니다. @@ -84,8 +83,8 @@ interface ReceiverAuthApiService { */ @GET("api/receiver-auth/delivery-verification/status") suspend fun getDeliveryVerificationStatus( - @Header("X-Auth-Code") authCode: String - ): ApiResponse + @Header("X-Auth-Code") authCode: String, + ): BaseResponse /** * 인증번호를 통해 발신자가 남긴 메시지를 조회합니다. @@ -94,8 +93,8 @@ interface ReceiverAuthApiService { */ @GET("api/receiver-auth/message") suspend fun getMessage( - @Header("X-Auth-Code") authCode: String - ): ApiResponse + @Header("X-Auth-Code") authCode: String, + ): BaseResponse /** * 인증번호를 통해 수신자에게 배달된 타임레터 목록을 조회합니다. @@ -104,8 +103,8 @@ interface ReceiverAuthApiService { */ @GET("api/receiver-auth/time-letters") suspend fun getTimeLetters( - @Header("X-Auth-Code") authCode: String - ): ApiResponse + @Header("X-Auth-Code") authCode: String, + ): BaseResponse /** * 인증번호를 통해 수신한 특정 타임레터를 상세 조회합니다. 읽음 처리도 함께 수행됩니다. @@ -116,8 +115,8 @@ interface ReceiverAuthApiService { @GET("api/receiver-auth/time-letters/{timeLetterReceiverId}") suspend fun getTimeLetterDetail( @Header("X-Auth-Code") authCode: String, - @Path("timeLetterReceiverId") timeLetterReceiverId: Long - ): ApiResponse + @Path("timeLetterReceiverId") timeLetterReceiverId: Long, + ): BaseResponse /** * 인증번호를 통해 수신자에게 공유된 마인드레코드 목록을 조회합니다. @@ -126,8 +125,8 @@ interface ReceiverAuthApiService { */ @GET("api/receiver-auth/mind-records") suspend fun getMindRecords( - @Header("X-Auth-Code") authCode: String - ): ApiResponse + @Header("X-Auth-Code") authCode: String, + ): BaseResponse /** * 인증번호를 통해 수신한 특정 마인드레코드의 상세 내용을 조회합니다. @@ -138,8 +137,8 @@ interface ReceiverAuthApiService { @GET("api/receiver-auth/mind-records/{mindRecordId}") suspend fun getMindRecordDetail( @Header("X-Auth-Code") authCode: String, - @Path("mindRecordId") mindRecordId: Long - ): ApiResponse + @Path("mindRecordId") mindRecordId: Long, + ): BaseResponse /** * 인증번호를 통해 수신자에게 전달된 애프터노트 목록을 조회합니다. @@ -148,8 +147,8 @@ interface ReceiverAuthApiService { */ @GET("api/receiver-auth/after-notes") suspend fun getAfterNotes( - @Header("X-Auth-Code") authCode: String - ): ApiResponse + @Header("X-Auth-Code") authCode: String, + ): BaseResponse /** * 인증번호를 통해 수신한 특정 애프터노트의 상세 내용을 조회합니다. @@ -160,6 +159,6 @@ interface ReceiverAuthApiService { @GET("api/receiver-auth/after-notes/{afternoteId}") suspend fun getAfternoteDetail( @Header("X-Auth-Code") authCode: String, - @Path("afternoteId") afternoteId: Long - ): ApiResponse + @Path("afternoteId") afternoteId: Long, + ): BaseResponse } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/repository/impl/ReceiverAuthRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/repository/impl/ReceiverAuthRepositoryImpl.kt index e85d90c3..e76b9cb9 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/repository/impl/ReceiverAuthRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/repository/impl/ReceiverAuthRepositoryImpl.kt @@ -1,24 +1,24 @@ package com.kuit.afternote.feature.receiverauth.data.repository.impl -import com.kuit.afternote.data.remote.requireData +import com.kuit.afternote.data.requireData import com.kuit.afternote.feature.receiverauth.data.api.ReceiverAuthApiService import com.kuit.afternote.feature.receiverauth.data.dto.DeliveryVerificationRequestDto import com.kuit.afternote.feature.receiverauth.data.dto.DeliveryVerificationResponseDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlRequestDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlResponseDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyRequestDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedAfternoteDetailAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedAfternoteListAuthResponseDto -import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverMessageResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedMindRecordDetailAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedMindRecordListAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedTimeLetterDetailAuthResponseDto import com.kuit.afternote.feature.receiverauth.data.dto.ReceivedTimeLetterListAuthResponseDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlRequestDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthPresignedUrlResponseDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyRequestDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverAuthVerifyResponseDto +import com.kuit.afternote.feature.receiverauth.data.dto.ReceiverMessageResponseDto import com.kuit.afternote.feature.receiverauth.data.repository.iface.ReceiverAuthRepository import com.kuit.afternote.feature.receiverauth.domain.entity.ReceiverAuthVerifyResult -import com.kuit.afternote.feature.receiverauth.domain.repository.iface.ReceiverAuthRepository as DomainReceiverAuthRepository import javax.inject.Inject +import com.kuit.afternote.feature.receiverauth.domain.repository.iface.ReceiverAuthRepository as DomainReceiverAuthRepository /** * 수신자 인증 Domain/Data Repository 구현체. @@ -29,93 +29,95 @@ import javax.inject.Inject class ReceiverAuthRepositoryImpl @Inject constructor( - private val api: ReceiverAuthApiService - ) : DomainReceiverAuthRepository, ReceiverAuthRepository { - - override suspend fun verify(authCode: String): Result = - runCatching { - val dto = api.verify( - ReceiverAuthVerifyRequestDto(authCode = authCode) - ).requireData() - dto.toDomain() - } + private val api: ReceiverAuthApiService, + ) : DomainReceiverAuthRepository, + ReceiverAuthRepository { + override suspend fun verify(authCode: String): Result = + runCatching { + val dto = + api + .verify( + ReceiverAuthVerifyRequestDto(authCode = authCode), + ).requireData() + dto.toDomain() + } - override suspend fun getPresignedUrl( - authCode: String, - extension: String - ): Result = - runCatching { - api.getPresignedUrl( - authCode = authCode, - body = ReceiverAuthPresignedUrlRequestDto(extension = extension) - ).requireData() - } + override suspend fun getPresignedUrl( + authCode: String, + extension: String, + ): Result = + runCatching { + api + .getPresignedUrl( + authCode = authCode, + body = ReceiverAuthPresignedUrlRequestDto(extension = extension), + ).requireData() + } - override suspend fun submitDeliveryVerification( - authCode: String, - request: DeliveryVerificationRequestDto - ): Result = - runCatching { - api.submitDeliveryVerification(authCode = authCode, body = request).requireData() - } + override suspend fun submitDeliveryVerification( + authCode: String, + request: DeliveryVerificationRequestDto, + ): Result = + runCatching { + api.submitDeliveryVerification(authCode = authCode, body = request).requireData() + } - override suspend fun getDeliveryVerificationStatus( - authCode: String - ): Result = - runCatching { - api.getDeliveryVerificationStatus(authCode = authCode).requireData() - } + override suspend fun getDeliveryVerificationStatus(authCode: String): Result = + runCatching { + api.getDeliveryVerificationStatus(authCode = authCode).requireData() + } - override suspend fun getMessage(authCode: String): Result = - runCatching { - api.getMessage(authCode = authCode).requireData() - } + override suspend fun getMessage(authCode: String): Result = + runCatching { + api.getMessage(authCode = authCode).requireData() + } - override suspend fun getTimeLetters(authCode: String): Result = - runCatching { - api.getTimeLetters(authCode = authCode).requireData() - } + override suspend fun getTimeLetters(authCode: String): Result = + runCatching { + api.getTimeLetters(authCode = authCode).requireData() + } - override suspend fun getTimeLetterDetail( - authCode: String, - timeLetterReceiverId: Long - ): Result = - runCatching { - api.getTimeLetterDetail(authCode = authCode, timeLetterReceiverId = timeLetterReceiverId) - .requireData() - } + override suspend fun getTimeLetterDetail( + authCode: String, + timeLetterReceiverId: Long, + ): Result = + runCatching { + api + .getTimeLetterDetail(authCode = authCode, timeLetterReceiverId = timeLetterReceiverId) + .requireData() + } - override suspend fun getMindRecords(authCode: String): Result = - runCatching { - api.getMindRecords(authCode = authCode).requireData() - } + override suspend fun getMindRecords(authCode: String): Result = + runCatching { + api.getMindRecords(authCode = authCode).requireData() + } - override suspend fun getMindRecordDetail( - authCode: String, - mindRecordId: Long - ): Result = - runCatching { - api.getMindRecordDetail(authCode = authCode, mindRecordId = mindRecordId).requireData() - } + override suspend fun getMindRecordDetail( + authCode: String, + mindRecordId: Long, + ): Result = + runCatching { + api.getMindRecordDetail(authCode = authCode, mindRecordId = mindRecordId).requireData() + } - override suspend fun getAfterNotes(authCode: String): Result = - runCatching { - api.getAfterNotes(authCode = authCode).requireData() - } + override suspend fun getAfterNotes(authCode: String): Result = + runCatching { + api.getAfterNotes(authCode = authCode).requireData() + } - override suspend fun getAfternoteDetail( - authCode: String, - afternoteId: Long - ): Result = - runCatching { - api.getAfternoteDetail(authCode = authCode, afternoteId = afternoteId).requireData() - } -} + override suspend fun getAfternoteDetail( + authCode: String, + afternoteId: Long, + ): Result = + runCatching { + api.getAfternoteDetail(authCode = authCode, afternoteId = afternoteId).requireData() + } + } private fun ReceiverAuthVerifyResponseDto.toDomain(): ReceiverAuthVerifyResult = ReceiverAuthVerifyResult( receiverId = receiverId, receiverName = receiverName, senderName = senderName, - relation = relation + relation = relation, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt index 1961a448..6194c203 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt @@ -3,18 +3,18 @@ package com.kuit.afternote.feature.receiverauth.presentation.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.data.remote.ApiException +import com.kuit.afternote.data.service.ApiException import com.kuit.afternote.feature.receiverauth.domain.usecase.GetDeliveryVerificationStatusUseCase import com.kuit.afternote.feature.receiverauth.domain.usecase.SubmitDeliveryVerificationUseCase import com.kuit.afternote.feature.receiverauth.domain.usecase.UploadReceiverDocumentUseCase import com.kuit.afternote.feature.receiverauth.domain.usecase.VerifyReceiverAuthUseCase import com.kuit.afternote.feature.receiverauth.uimodel.VerifyStep import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.IOException @@ -32,202 +32,213 @@ class VerifySelfViewModel private val verifyReceiverAuthUseCase: VerifyReceiverAuthUseCase, private val uploadReceiverDocumentUseCase: UploadReceiverDocumentUseCase, private val submitDeliveryVerificationUseCase: SubmitDeliveryVerificationUseCase, - private val getDeliveryVerificationStatusUseCase: GetDeliveryVerificationStatusUseCase - ) : ViewModel(), VerifySelfViewModelContract { + private val getDeliveryVerificationStatusUseCase: GetDeliveryVerificationStatusUseCase, + ) : ViewModel(), + VerifySelfViewModelContract { + private val _uiState = MutableStateFlow(VerifySelfUiState()) + override val uiState: StateFlow = _uiState.asStateFlow() - private val _uiState = MutableStateFlow(VerifySelfUiState()) - override val uiState: StateFlow = _uiState.asStateFlow() - - private var deliveryStatusPollingJob: Job? = null - - /** - * 마스터 키 입력값 갱신. 에러 메시지 초기화. - */ - override fun updateMasterKey(text: String) { - _uiState.update { - it.copy(masterKeyInput = text, verifyError = null) - } - } + private var deliveryStatusPollingJob: Job? = null - /** - * 마스터 키 검증 요청. - * - * 성공(200) 시 다음 단계(UPLOAD_PDF_AUTH)로 이동, 실패(400 등) 시 errorMessage 설정 후 재입력. - */ - override fun verifyMasterKey() { - val authCode = _uiState.value.masterKeyInput.trim() - if (authCode.isBlank()) { + /** + * 마스터 키 입력값 갱신. 에러 메시지 초기화. + */ + override fun updateMasterKey(text: String) { _uiState.update { - it.copy(verifyError = VerifyErrorType.Required) + it.copy(masterKeyInput = text, verifyError = null) } - return } - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, verifyError = null) } - verifyReceiverAuthUseCase(authCode) - .onSuccess { result -> - _uiState.update { - it.copy( - currentStep = VerifyStep.UPLOAD_PDF_AUTH, - isLoading = false, - verifyError = null, - verifiedReceiverId = result.receiverId, - verifiedSenderName = result.senderName - ) - } + + /** + * 마스터 키 검증 요청. + * + * 성공(200) 시 다음 단계(UPLOAD_PDF_AUTH)로 이동, 실패(400 등) 시 errorMessage 설정 후 재입력. + */ + override fun verifyMasterKey() { + val authCode = _uiState.value.masterKeyInput.trim() + if (authCode.isBlank()) { + _uiState.update { + it.copy(verifyError = VerifyErrorType.Required) } - .onFailure { e -> - Log.e(TAG, "Verify master key failed", e) - val errorType = mapVerifyErrorToType(e) - _uiState.update { - it.copy(isLoading = false, verifyError = errorType) + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, verifyError = null) } + verifyReceiverAuthUseCase(authCode) + .onSuccess { result -> + _uiState.update { + it.copy( + currentStep = VerifyStep.UPLOAD_PDF_AUTH, + isLoading = false, + verifyError = null, + verifiedReceiverId = result.receiverId, + verifiedSenderName = result.senderName, + ) + } + }.onFailure { e -> + Log.e(TAG, "Verify master key failed", e) + val errorType = mapVerifyErrorToType(e) + _uiState.update { + it.copy(isLoading = false, verifyError = errorType) + } } - } - } - } - - /** - * 다음 단계로 이동 (PDF 업로드 → 완료 등). - */ - override fun goToNextStep() { - _uiState.update { state -> - val next = when (state.currentStep) { - VerifyStep.MASTER_KEY_AUTH -> VerifyStep.UPLOAD_PDF_AUTH - VerifyStep.UPLOAD_PDF_AUTH -> VerifyStep.END - VerifyStep.END -> VerifyStep.END } - state.copy(currentStep = next) } - } - /** - * 이전 단계로 이동. - */ - override fun goToPreviousStep(): VerifyStep? { - val current = _uiState.value.currentStep - val previous = current.previous() - if (previous != null) { - if (current == VerifyStep.END) { - deliveryStatusPollingJob?.cancel() - deliveryStatusPollingJob = null + /** + * 다음 단계로 이동 (PDF 업로드 → 완료 등). + */ + override fun goToNextStep() { + _uiState.update { state -> + val next = + when (state.currentStep) { + VerifyStep.MASTER_KEY_AUTH -> VerifyStep.UPLOAD_PDF_AUTH + VerifyStep.UPLOAD_PDF_AUTH -> VerifyStep.END + VerifyStep.END -> VerifyStep.END + } + state.copy(currentStep = next) } - _uiState.update { it.copy(currentStep = previous, verifyError = null) } } - return previous - } - /** - * 에러 메시지 초기화. - */ - override fun clearVerifyError() { - _uiState.update { it.copy(verifyError = null) } - } - - /** - * 증빙 서류(사망진단서, 가족관계증명서) 업로드 후 delivery-verification 제출. - * - * 두 URI가 모두 있을 때만 업로드·제출하고, 성공 시 END 단계로 이동한 뒤 인증 상태를 조회합니다. - */ - override fun submitDocuments(deathCertUri: String?, familyCertUri: String?) { - if (deathCertUri.isNullOrBlank() || familyCertUri.isNullOrBlank()) { - _uiState.update { - it.copy(submitError = VerifyErrorType.Required) + /** + * 이전 단계로 이동. + */ + override fun goToPreviousStep(): VerifyStep? { + val current = _uiState.value.currentStep + val previous = current.previous() + if (previous != null) { + if (current == VerifyStep.END) { + deliveryStatusPollingJob?.cancel() + deliveryStatusPollingJob = null + } + _uiState.update { it.copy(currentStep = previous, verifyError = null) } } - return + return previous } - val authCode = _uiState.value.masterKeyInput.trim() - if (authCode.isBlank()) { - _uiState.update { it.copy(submitError = VerifyErrorType.Unknown) } - return + + /** + * 에러 메시지 초기화. + */ + override fun clearVerifyError() { + _uiState.update { it.copy(verifyError = null) } } - viewModelScope.launch { - _uiState.update { - it.copy(isSubmitting = true, submitError = null) - } - val deathUrlResult = uploadReceiverDocumentUseCase(authCode, deathCertUri) - val deathUrl = deathUrlResult.getOrElse { e -> - Log.e(TAG, "Upload death certificate failed", e) + + /** + * 증빙 서류(사망진단서, 가족관계증명서) 업로드 후 delivery-verification 제출. + * + * 두 URI가 모두 있을 때만 업로드·제출하고, 성공 시 END 단계로 이동한 뒤 인증 상태를 조회합니다. + */ + override fun submitDocuments( + deathCertUri: String?, + familyCertUri: String?, + ) { + if (deathCertUri.isNullOrBlank() || familyCertUri.isNullOrBlank()) { _uiState.update { - it.copy( - isSubmitting = false, - submitError = mapVerifyErrorToType(e) - ) + it.copy(submitError = VerifyErrorType.Required) } - return@launch + return } - val familyUrlResult = uploadReceiverDocumentUseCase(authCode, familyCertUri) - val familyUrl = familyUrlResult.getOrElse { e -> - Log.e(TAG, "Upload family relation certificate failed", e) + val authCode = _uiState.value.masterKeyInput.trim() + if (authCode.isBlank()) { + _uiState.update { it.copy(submitError = VerifyErrorType.Unknown) } + return + } + viewModelScope.launch { _uiState.update { - it.copy( - isSubmitting = false, - submitError = mapVerifyErrorToType(e) - ) + it.copy(isSubmitting = true, submitError = null) } - return@launch - } - submitDeliveryVerificationUseCase(authCode, deathUrl, familyUrl) - .onSuccess { - _uiState.update { - it.copy( - currentStep = VerifyStep.END, - isSubmitting = false, - submitError = null - ) + val deathUrlResult = uploadReceiverDocumentUseCase(authCode, deathCertUri) + val deathUrl = + deathUrlResult.getOrElse { e -> + Log.e(TAG, "Upload death certificate failed", e) + _uiState.update { + it.copy( + isSubmitting = false, + submitError = mapVerifyErrorToType(e), + ) + } + return@launch } - loadDeliveryVerificationStatus(authCode) - deliveryStatusPollingJob?.cancel() - deliveryStatusPollingJob = viewModelScope.launch { - while (_uiState.value.currentStep == VerifyStep.END) { - delay(POLLING_INTERVAL_MS) - if (_uiState.value.currentStep != VerifyStep.END) break - loadDeliveryVerificationStatus(authCode) + val familyUrlResult = uploadReceiverDocumentUseCase(authCode, familyCertUri) + val familyUrl = + familyUrlResult.getOrElse { e -> + Log.e(TAG, "Upload family relation certificate failed", e) + _uiState.update { + it.copy( + isSubmitting = false, + submitError = mapVerifyErrorToType(e), + ) } + return@launch } - } - .onFailure { e -> - Log.e(TAG, "Submit delivery verification failed", e) - _uiState.update { - it.copy( - isSubmitting = false, - submitError = mapVerifyErrorToType(e) - ) + submitDeliveryVerificationUseCase(authCode, deathUrl, familyUrl) + .onSuccess { + _uiState.update { + it.copy( + currentStep = VerifyStep.END, + isSubmitting = false, + submitError = null, + ) + } + loadDeliveryVerificationStatus(authCode) + deliveryStatusPollingJob?.cancel() + deliveryStatusPollingJob = + viewModelScope.launch { + while (_uiState.value.currentStep == VerifyStep.END) { + delay(POLLING_INTERVAL_MS) + if (_uiState.value.currentStep != VerifyStep.END) break + loadDeliveryVerificationStatus(authCode) + } + } + }.onFailure { e -> + Log.e(TAG, "Submit delivery verification failed", e) + _uiState.update { + it.copy( + isSubmitting = false, + submitError = mapVerifyErrorToType(e), + ) + } } - } + } } - } - private fun loadDeliveryVerificationStatus(authCode: String) { - viewModelScope.launch { - getDeliveryVerificationStatusUseCase(authCode) - .onSuccess { status -> - val isApproved = status.status.equals("APPROVED", ignoreCase = true) - if (isApproved) { - deliveryStatusPollingJob?.cancel() - deliveryStatusPollingJob = null - } - _uiState.update { - it.copy(deliveryVerificationStatus = status) + private fun loadDeliveryVerificationStatus(authCode: String) { + viewModelScope.launch { + getDeliveryVerificationStatusUseCase(authCode) + .onSuccess { status -> + val isApproved = status.status.equals("APPROVED", ignoreCase = true) + if (isApproved) { + deliveryStatusPollingJob?.cancel() + deliveryStatusPollingJob = null + } + _uiState.update { + it.copy(deliveryVerificationStatus = status) + } + }.onFailure { e -> + Log.e(TAG, "Load delivery verification status failed", e) } + } + } + + private fun mapVerifyErrorToType(e: Throwable): VerifyErrorType = + when (e) { + is ApiException -> { + e.message?.takeIf { it.isNotBlank() }?.let { VerifyErrorType.Server(it) } + ?: VerifyErrorType.Unknown + } + + is IOException -> { + VerifyErrorType.Network } - .onFailure { e -> - Log.e(TAG, "Load delivery verification status failed", e) + + else -> { + e.message?.takeIf { it.isNotBlank() }?.let { VerifyErrorType.Server(it) } + ?: VerifyErrorType.Unknown } - } - } + } - private fun mapVerifyErrorToType(e: Throwable): VerifyErrorType { - return when (e) { - is ApiException -> e.message?.takeIf { it.isNotBlank() }?.let { VerifyErrorType.Server(it) } - ?: VerifyErrorType.Unknown - is IOException -> VerifyErrorType.Network - else -> e.message?.takeIf { it.isNotBlank() }?.let { VerifyErrorType.Server(it) } - ?: VerifyErrorType.Unknown + companion object { + private const val TAG = "VerifySelfViewModel" + private const val POLLING_INTERVAL_MS = 5000L } } - - companion object { - private const val TAG = "VerifySelfViewModel" - private const val POLLING_INTERVAL_MS = 5000L - } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt index 63f8f25a..2b96a77b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt @@ -23,7 +23,7 @@ import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.util.getAfternoteDisplayRes import com.kuit.afternote.core.ui.util.getIconResForServiceName import com.kuit.afternote.core.uimodel.AfternoteListDisplayItem -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.feature.onboarding.presentation.navgraph.OnboardingRoute import com.kuit.afternote.feature.onboarding.presentation.viewmodel.LogoutViewModel import com.kuit.afternote.feature.receiver.presentation.viewmodel.ReceiverAfternotesListViewModel diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt index 17388020..bb8431c6 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt @@ -45,8 +45,8 @@ import com.kuit.afternote.core.ui.component.Label import com.kuit.afternote.core.ui.component.OutlineTextField import com.kuit.afternote.core.ui.component.SelectableRadioCard import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodOption -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.OptionRadioCardContent +import com.kuit.afternote.feature.afternote.presentation.edit.model.ProcessingMethodOption +import com.kuit.afternote.feature.afternote.presentation.edit.processingmethod.OptionRadioCardContent import com.kuit.afternote.feature.setting.presentation.component.DatePickerDialog import com.kuit.afternote.feature.setting.presentation.component.SelectedDateText import com.kuit.afternote.feature.setting.presentation.model.DeliveryMethodOption diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverFormScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverFormScreen.kt index 71a7eccc..ed8420c0 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverFormScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverFormScreen.kt @@ -35,10 +35,10 @@ import com.kuit.afternote.core.ui.component.LabelStyle import com.kuit.afternote.core.ui.component.LabeledTextFieldStyle import com.kuit.afternote.core.ui.component.OutlineTextField import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.DropdownMenuStyle -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdown -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdownLabelParams -import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.rememberSelectionDropdownState +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.DropdownMenuStyle +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdown +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.SelectionDropdownLabelParams +import com.kuit.afternote.feature.afternote.presentation.edit.dropdown.rememberSelectionDropdownState import com.kuit.afternote.feature.user.presentation.viewmodel.EditReceiverViewModel import com.kuit.afternote.feature.user.presentation.viewmodel.RegisterReceiverViewModel import com.kuit.afternote.ui.theme.AfternoteTheme diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverManagementScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverManagementScreen.kt index 7d1b90c3..f9680038 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverManagementScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverManagementScreen.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kuit.afternote.R -import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver import com.kuit.afternote.app.compositionlocal.DataProviderLocals -import com.kuit.afternote.data.provider.FakeReceiverDataProvider +import com.kuit.afternote.core.ui.component.navigation.TopBar +import com.kuit.afternote.domain.provider.FakeReceiverDataProvider +import com.kuit.afternote.feature.afternote.presentation.edit.model.AfternoteEditReceiver import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.Black import com.kuit.afternote.ui.theme.Gray1 @@ -50,11 +50,12 @@ fun ReceiverManagementScreen( onBackClick: () -> Unit, onRegisterClick: () -> Unit = {}, receivers: List = emptyList(), - onReceiverClick: (AfternoteEditReceiver) -> Unit = {} + onReceiverClick: (AfternoteEditReceiver) -> Unit = {}, ) { - val groupedReceivers = remember(receivers) { - KoreanConsonantUtil.groupByInitialConsonant(receivers) { it.name } - } + val groupedReceivers = + remember(receivers) { + KoreanConsonantUtil.groupByInitialConsonant(receivers) { it.name } + } BackHandler(onBack = onBackClick) Scaffold( @@ -64,59 +65,63 @@ fun ReceiverManagementScreen( title = "수신인 목록", onBackClick = onBackClick, onActionClick = onRegisterClick, - actionText = "등록" + actionText = "등록", ) - } + }, ) { paddingValues -> if (receivers.isEmpty()) { Column( - modifier = modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.weight(1f)) Image( painter = painterResource(R.drawable.img_empty_state), contentDescription = "빈 수신자 목록", - modifier = Modifier - .width(106.dp) - .height(109.dp) - .alpha(0.6f) + modifier = + Modifier + .width(106.dp) + .height(109.dp) + .alpha(0.6f), ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "아직 등록된 수신자가 없어요.\n수신자를 등록하여 정보를 전달하세요.", - style = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray4, - textAlign = TextAlign.Center - ) + style = + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray4, + textAlign = TextAlign.Center, + ), ) Spacer(modifier = Modifier.weight(1f)) } } else { LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(space = 28.dp) + modifier = + modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(space = 28.dp), ) { groupedReceivers.forEach { (consonant, receiversInGroup) -> item(key = "section_$consonant") { ConsonantSection( consonant = consonant, receivers = receiversInGroup, - onReceiverClick = onReceiverClick + onReceiverClick = onReceiverClick, ) } } @@ -130,39 +135,40 @@ private fun ConsonantSection( modifier: Modifier = Modifier, consonant: Char, receivers: List, - onReceiverClick: (AfternoteEditReceiver) -> Unit + onReceiverClick: (AfternoteEditReceiver) -> Unit, ) { Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(space = 16.dp) + verticalArrangement = Arrangement.spacedBy(space = 16.dp), ) { // 초성 헤더 Column { Text( text = consonant.toString(), - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 8.dp), fontSize = 14.sp, lineHeight = 14.sp, fontWeight = FontWeight.Medium, fontFamily = Sansneo, - color = Gray5 + color = Gray5, ) HorizontalDivider( thickness = 1.dp, - color = Gray4 + color = Gray4, ) } // 수신자 목록 Column( - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + verticalArrangement = Arrangement.spacedBy(space = 8.dp), ) { receivers.forEach { receiver -> ReceiverManagementItem( receiver, - onReceiverClick = onReceiverClick + onReceiverClick = onReceiverClick, ) } } @@ -172,13 +178,14 @@ private fun ConsonantSection( @Composable private fun ReceiverManagementItem( receiver: AfternoteEditReceiver, - onReceiverClick: (AfternoteEditReceiver) -> Unit + onReceiverClick: (AfternoteEditReceiver) -> Unit, ) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = { onReceiverClick(receiver) }), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = { onReceiverClick(receiver) }), + verticalAlignment = Alignment.CenterVertically, ) { Image( painter = painterResource(R.drawable.img_recipient_profile), @@ -189,23 +196,25 @@ private fun ReceiverManagementItem( Column { Text( text = receiver.name, - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Medium, - color = Black, - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Black, + ), ) Text( text = receiver.label, - style = TextStyle( - fontSize = 12.sp, - lineHeight = 18.sp, - fontFamily = Sansneo, - fontWeight = FontWeight.Normal, - color = Gray8, - ) + style = + TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Normal, + color = Gray8, + ), ) } } @@ -216,12 +225,12 @@ private fun ReceiverManagementItem( private fun ReceiverManagementScreenWithDataPreview() { AfternoteTheme { CompositionLocalProvider( - DataProviderLocals.LocalReceiverDataProvider provides FakeReceiverDataProvider() + DataProviderLocals.LocalReceiverDataProvider provides FakeReceiverDataProvider(), ) { val provider = DataProviderLocals.LocalReceiverDataProvider.current ReceiverManagementScreen( onBackClick = {}, - receivers = provider.getReceiverList() + receivers = provider.getReceiverList(), ) } } @@ -233,7 +242,7 @@ private fun ReceiverManagementScreenEmptyPreview() { AfternoteTheme { ReceiverManagementScreen( onBackClick = {}, - receivers = emptyList() + receivers = emptyList(), ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt index 2c9c9014..c6753d93 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt @@ -2,7 +2,7 @@ package com.kuit.afternote.feature.setting.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.data.local.PostDeliveryConditionPreferences +import com.kuit.afternote.data.service.PostDeliveryConditionPreferences import com.kuit.afternote.feature.setting.presentation.model.DeliveryMethodOption import com.kuit.afternote.feature.setting.presentation.model.TriggerConditionOption import com.kuit.afternote.feature.user.domain.model.DeliveryConditionType @@ -31,7 +31,7 @@ data class PostDeliveryConditionState( val inactivityPeriodDays: Int? = null, val lastGreetingMessage: String = "", val isLoading: Boolean = false, - val errorMessage: String? = null + val errorMessage: String? = null, ) /** @@ -39,10 +39,15 @@ data class PostDeliveryConditionState( */ interface PostDeliveryConditionViewModelContract { val state: StateFlow + fun onDeliveryMethodSelected(option: DeliveryMethodOption) + fun onTriggerConditionSelected(option: TriggerConditionOption) + fun onDateSelected(date: LocalDate?) + fun onLastGreetingChanged(text: String) + fun onSaveLastGreeting(lastGreetingText: String) } @@ -51,171 +56,170 @@ interface PostDeliveryConditionViewModelContract { */ @HiltViewModel class PostDeliveryConditionViewModel -@Inject -constructor( - private val getDeliveryConditionUseCase: GetDeliveryConditionUseCase, - private val updateDeliveryConditionUseCase: UpdateDeliveryConditionUseCase, - private val preferences: PostDeliveryConditionPreferences -) : ViewModel(), PostDeliveryConditionViewModelContract { - - private val _state = MutableStateFlow(PostDeliveryConditionState()) - override val state: StateFlow = _state.asStateFlow() - - init { - viewModelScope.launch { loadDeliveryCondition() } - } + @Inject + constructor( + private val getDeliveryConditionUseCase: GetDeliveryConditionUseCase, + private val updateDeliveryConditionUseCase: UpdateDeliveryConditionUseCase, + private val preferences: PostDeliveryConditionPreferences, + ) : ViewModel(), + PostDeliveryConditionViewModelContract { + private val _state = MutableStateFlow(PostDeliveryConditionState()) + override val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { loadDeliveryCondition() } + } - private suspend fun loadDeliveryCondition() { - _state.update { it.copy(isLoading = true, errorMessage = null) } - getDeliveryConditionUseCase() - .onSuccess { condition -> - val deliveryCode = preferences.getDeliveryMethod() ?: CODE_AUTOMATIC_TRANSFER - _state.update { - it.copy( - selectedDeliveryMethod = deliveryCodeToOption(deliveryCode), - selectedTriggerCondition = conditionTypeToTriggerOption(condition.conditionType), - selectedDate = condition.specificDate?.let { iso -> LocalDate.parse(iso) }, - inactivityPeriodDays = condition.inactivityPeriodDays, - lastGreetingMessage = condition.leaveMessage ?: "", - isLoading = false, - errorMessage = null - ) + private suspend fun loadDeliveryCondition() { + _state.update { it.copy(isLoading = true, errorMessage = null) } + getDeliveryConditionUseCase() + .onSuccess { condition -> + val deliveryCode = preferences.getDeliveryMethod() ?: CODE_AUTOMATIC_TRANSFER + _state.update { + it.copy( + selectedDeliveryMethod = deliveryCodeToOption(deliveryCode), + selectedTriggerCondition = conditionTypeToTriggerOption(condition.conditionType), + selectedDate = condition.specificDate?.let { iso -> LocalDate.parse(iso) }, + inactivityPeriodDays = condition.inactivityPeriodDays, + lastGreetingMessage = condition.leaveMessage ?: "", + isLoading = false, + errorMessage = null, + ) + } + }.onFailure { e -> + _state.update { state -> state.copy(isLoading = false, errorMessage = e.message) } + loadFromPreferencesAsFallback() } - } - .onFailure { e -> - _state.update { state -> state.copy(isLoading = false, errorMessage = e.message) } - loadFromPreferencesAsFallback() - } - } - - private suspend fun loadFromPreferencesAsFallback() { - val deliveryCode = preferences.getDeliveryMethod() ?: CODE_AUTOMATIC_TRANSFER - val triggerCode = preferences.getTriggerCondition() ?: CODE_APP_INACTIVITY - val dateIso = preferences.getTriggerDateIso() - _state.update { - it.copy( - selectedDeliveryMethod = deliveryCodeToOption(deliveryCode), - selectedTriggerCondition = triggerCodeToOption(triggerCode), - selectedDate = dateIso?.let { iso -> LocalDate.parse(iso) } - ) } - } - override fun onDeliveryMethodSelected(option: DeliveryMethodOption) { - _state.update { it.copy(selectedDeliveryMethod = option) } - } - - override fun onTriggerConditionSelected(option: TriggerConditionOption) { - _state.update { it.copy(selectedTriggerCondition = option, errorMessage = null) } - viewModelScope.launch { - updateDeliveryConditionApi() - persistDeliveryMethodOnly() + private suspend fun loadFromPreferencesAsFallback() { + val deliveryCode = preferences.getDeliveryMethod() ?: CODE_AUTOMATIC_TRANSFER + val triggerCode = preferences.getTriggerCondition() ?: CODE_APP_INACTIVITY + val dateIso = preferences.getTriggerDateIso() + _state.update { + it.copy( + selectedDeliveryMethod = deliveryCodeToOption(deliveryCode), + selectedTriggerCondition = triggerCodeToOption(triggerCode), + selectedDate = dateIso?.let { iso -> LocalDate.parse(iso) }, + ) + } } - } - override fun onDateSelected(date: LocalDate?) { - _state.update { it.copy(selectedDate = date, errorMessage = null) } - viewModelScope.launch { updateDeliveryConditionApi() } - } + override fun onDeliveryMethodSelected(option: DeliveryMethodOption) { + _state.update { it.copy(selectedDeliveryMethod = option) } + } - override fun onLastGreetingChanged(text: String) { - _state.update { it.copy(lastGreetingMessage = text) } - } + override fun onTriggerConditionSelected(option: TriggerConditionOption) { + _state.update { it.copy(selectedTriggerCondition = option, errorMessage = null) } + viewModelScope.launch { + updateDeliveryConditionApi() + persistDeliveryMethodOnly() + } + } - override fun onSaveLastGreeting(lastGreetingText: String) { - _state.update { it.copy(lastGreetingMessage = lastGreetingText) } - viewModelScope.launch { updateDeliveryConditionApi() } - } + override fun onDateSelected(date: LocalDate?) { + _state.update { it.copy(selectedDate = date, errorMessage = null) } + viewModelScope.launch { updateDeliveryConditionApi() } + } - private suspend fun updateDeliveryConditionApi() { - val s = _state.value - val conditionType = triggerOptionToConditionType(s.selectedTriggerCondition) - val inactivityPeriodDays = when (conditionType) { - DeliveryConditionType.INACTIVITY -> s.inactivityPeriodDays ?: DEFAULT_INACTIVITY_DAYS - else -> null + override fun onLastGreetingChanged(text: String) { + _state.update { it.copy(lastGreetingMessage = text) } } - val specificDate = when (conditionType) { - DeliveryConditionType.SPECIFIC_DATE -> s.selectedDate?.toString() - else -> null + + override fun onSaveLastGreeting(lastGreetingText: String) { + _state.update { it.copy(lastGreetingMessage = lastGreetingText) } + viewModelScope.launch { updateDeliveryConditionApi() } } - updateDeliveryConditionUseCase( - conditionType = conditionType, - inactivityPeriodDays = inactivityPeriodDays, - specificDate = specificDate, - leaveMessage = s.lastGreetingMessage.ifBlank { null } - ) - .onSuccess { condition -> + + private suspend fun updateDeliveryConditionApi() { + val s = _state.value + val conditionType = triggerOptionToConditionType(s.selectedTriggerCondition) + val inactivityPeriodDays = + when (conditionType) { + DeliveryConditionType.INACTIVITY -> s.inactivityPeriodDays ?: DEFAULT_INACTIVITY_DAYS + else -> null + } + val specificDate = + when (conditionType) { + DeliveryConditionType.SPECIFIC_DATE -> s.selectedDate?.toString() + else -> null + } + updateDeliveryConditionUseCase( + conditionType = conditionType, + inactivityPeriodDays = inactivityPeriodDays, + specificDate = specificDate, + leaveMessage = s.lastGreetingMessage.ifBlank { null }, + ).onSuccess { condition -> _state.update { it.copy( inactivityPeriodDays = condition.inactivityPeriodDays, - errorMessage = null + errorMessage = null, ) } persistTriggerToPreferences() - } - .onFailure { e -> + }.onFailure { e -> _state.update { state -> state.copy(errorMessage = e.message ?: "전달 조건 저장에 실패했습니다.") } } - } - - private suspend fun persistDeliveryMethodOnly() { - val s = _state.value - preferences.save( - deliveryMethod = optionToDeliveryCode(s.selectedDeliveryMethod), - triggerCondition = optionToTriggerCode(s.selectedTriggerCondition), - triggerDateIso = s.selectedDate?.toString() - ) - } - - private suspend fun persistTriggerToPreferences() { - val s = _state.value - preferences.save( - deliveryMethod = optionToDeliveryCode(s.selectedDeliveryMethod), - triggerCondition = optionToTriggerCode(s.selectedTriggerCondition), - triggerDateIso = s.selectedDate?.toString() - ) - } - - private fun conditionTypeToTriggerOption(type: DeliveryConditionType): TriggerConditionOption = - when (type) { - DeliveryConditionType.NONE -> TriggerConditionOption.AppInactivity - DeliveryConditionType.DEATH_CERTIFICATE -> TriggerConditionOption.ReceiverRequest - DeliveryConditionType.INACTIVITY -> TriggerConditionOption.AppInactivity - DeliveryConditionType.SPECIFIC_DATE -> TriggerConditionOption.SpecificDate } - private fun triggerOptionToConditionType(option: TriggerConditionOption): DeliveryConditionType = - when (option) { - TriggerConditionOption.AppInactivity -> DeliveryConditionType.INACTIVITY - TriggerConditionOption.SpecificDate -> DeliveryConditionType.SPECIFIC_DATE - TriggerConditionOption.ReceiverRequest -> DeliveryConditionType.DEATH_CERTIFICATE + private suspend fun persistDeliveryMethodOnly() { + val s = _state.value + preferences.save( + deliveryMethod = optionToDeliveryCode(s.selectedDeliveryMethod), + triggerCondition = optionToTriggerCode(s.selectedTriggerCondition), + triggerDateIso = s.selectedDate?.toString(), + ) } - private fun optionToDeliveryCode(option: DeliveryMethodOption): String = - when (option) { - is DeliveryMethodOption.AutomaticTransfer -> CODE_AUTOMATIC_TRANSFER - is DeliveryMethodOption.ReceiverApprovalTransfer -> CODE_RECEIVER_APPROVAL + private suspend fun persistTriggerToPreferences() { + val s = _state.value + preferences.save( + deliveryMethod = optionToDeliveryCode(s.selectedDeliveryMethod), + triggerCondition = optionToTriggerCode(s.selectedTriggerCondition), + triggerDateIso = s.selectedDate?.toString(), + ) } - private fun deliveryCodeToOption(code: String): DeliveryMethodOption = - when (code) { - CODE_RECEIVER_APPROVAL -> DeliveryMethodOption.ReceiverApprovalTransfer - else -> DeliveryMethodOption.AutomaticTransfer - } + private fun conditionTypeToTriggerOption(type: DeliveryConditionType): TriggerConditionOption = + when (type) { + DeliveryConditionType.NONE -> TriggerConditionOption.AppInactivity + DeliveryConditionType.DEATH_CERTIFICATE -> TriggerConditionOption.ReceiverRequest + DeliveryConditionType.INACTIVITY -> TriggerConditionOption.AppInactivity + DeliveryConditionType.SPECIFIC_DATE -> TriggerConditionOption.SpecificDate + } - private fun optionToTriggerCode(option: TriggerConditionOption): String = - when (option) { - is TriggerConditionOption.AppInactivity -> CODE_APP_INACTIVITY - is TriggerConditionOption.SpecificDate -> CODE_SPECIFIC_DATE - is TriggerConditionOption.ReceiverRequest -> CODE_RECEIVER_REQUEST - } + private fun triggerOptionToConditionType(option: TriggerConditionOption): DeliveryConditionType = + when (option) { + TriggerConditionOption.AppInactivity -> DeliveryConditionType.INACTIVITY + TriggerConditionOption.SpecificDate -> DeliveryConditionType.SPECIFIC_DATE + TriggerConditionOption.ReceiverRequest -> DeliveryConditionType.DEATH_CERTIFICATE + } - private fun triggerCodeToOption(code: String): TriggerConditionOption = - when (code) { - CODE_SPECIFIC_DATE -> TriggerConditionOption.SpecificDate - CODE_RECEIVER_REQUEST -> TriggerConditionOption.ReceiverRequest - else -> TriggerConditionOption.AppInactivity - } -} + private fun optionToDeliveryCode(option: DeliveryMethodOption): String = + when (option) { + is DeliveryMethodOption.AutomaticTransfer -> CODE_AUTOMATIC_TRANSFER + is DeliveryMethodOption.ReceiverApprovalTransfer -> CODE_RECEIVER_APPROVAL + } + + private fun deliveryCodeToOption(code: String): DeliveryMethodOption = + when (code) { + CODE_RECEIVER_APPROVAL -> DeliveryMethodOption.ReceiverApprovalTransfer + else -> DeliveryMethodOption.AutomaticTransfer + } + + private fun optionToTriggerCode(option: TriggerConditionOption): String = + when (option) { + is TriggerConditionOption.AppInactivity -> CODE_APP_INACTIVITY + is TriggerConditionOption.SpecificDate -> CODE_SPECIFIC_DATE + is TriggerConditionOption.ReceiverRequest -> CODE_RECEIVER_REQUEST + } + + private fun triggerCodeToOption(code: String): TriggerConditionOption = + when (code) { + CODE_SPECIFIC_DATE -> TriggerConditionOption.SpecificDate + CODE_RECEIVER_REQUEST -> TriggerConditionOption.ReceiverRequest + else -> TriggerConditionOption.AppInactivity + } + } diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt index a5ca14f7..7958b54c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt @@ -2,16 +2,16 @@ package com.kuit.afternote.feature.setting.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.user.domain.usecase.WithdrawAccountUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import retrofit2.HttpException +import javax.inject.Inject /** * 회원 탈퇴 확인 문장. @@ -43,62 +43,61 @@ class WithdrawalPasswordViewModel private val withdrawAccountUseCase: WithdrawAccountUseCase, private val tokenManager: TokenManager, ) : ViewModel() { + private val _uiState = MutableStateFlow(WithdrawalPasswordUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _uiState = MutableStateFlow(WithdrawalPasswordUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - /** - * Called when the user taps "탈퇴하기". - * 입력된 문장이 확인 문장과 일치하는지 검증한 후, API를 호출한다. - * - * @param confirmationText 사용자가 입력한 확인 문장 - */ - fun submitWithdrawal(confirmationText: String) { - if (confirmationText.trim() != CONFIRMATION_SENTENCE) { - _uiState.update { it.copy(showSentenceError = true) } - return - } - viewModelScope.launch { - _uiState.update { - it.copy(isLoading = true, showSentenceError = false, errorMessage = null) + /** + * Called when the user taps "탈퇴하기". + * 입력된 문장이 확인 문장과 일치하는지 검증한 후, API를 호출한다. + * + * @param confirmationText 사용자가 입력한 확인 문장 + */ + fun submitWithdrawal(confirmationText: String) { + if (confirmationText.trim() != CONFIRMATION_SENTENCE) { + _uiState.update { it.copy(showSentenceError = true) } + return } - withdrawAccountUseCase() - .onSuccess { - tokenManager.clearTokens() - _uiState.update { - it.copy( - isLoading = false, - errorMessage = null, - withdrawalComplete = true, - ) - } + viewModelScope.launch { + _uiState.update { + it.copy(isLoading = true, showSentenceError = false, errorMessage = null) } - .onFailure { e -> - val message = when (e) { - is HttpException -> "HTTP ${e.code()} ${e.message()}" - else -> e.message ?: "회원 탈퇴에 실패했습니다." - } - _uiState.update { - it.copy( - isLoading = false, - errorMessage = message, - ) + withdrawAccountUseCase() + .onSuccess { + tokenManager.clearTokens() + _uiState.update { + it.copy( + isLoading = false, + errorMessage = null, + withdrawalComplete = true, + ) + } + }.onFailure { e -> + val message = + when (e) { + is HttpException -> "HTTP ${e.code()} ${e.message()}" + else -> e.message ?: "회원 탈퇴에 실패했습니다." + } + _uiState.update { + it.copy( + isLoading = false, + errorMessage = message, + ) + } } - } + } } - } - /** - * Clears the sentence mismatch error when the user edits the input. - */ - fun clearSentenceError() { - _uiState.update { it.copy(showSentenceError = false) } - } + /** + * Clears the sentence mismatch error when the user edits the input. + */ + fun clearSentenceError() { + _uiState.update { it.copy(showSentenceError = false) } + } - /** - * Clears the withdrawal-complete state after the user taps "확인하기" on the completion dialog. - */ - fun clearWithdrawalComplete() { - _uiState.update { it.copy(withdrawalComplete = false) } + /** + * Clears the withdrawal-complete state after the user taps "확인하기" on the completion dialog. + */ + fun clearWithdrawalComplete() { + _uiState.update { it.copy(withdrawalComplete = false) } + } } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/api/TimeLetterApiService.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/api/TimeLetterApiService.kt index cbd13b00..58816dd3 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/api/TimeLetterApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/api/TimeLetterApiService.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.timeletter.data.api -import com.kuit.afternote.data.remote.ApiResponse +import com.kuit.afternote.data.BaseResponse import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterCreateRequest import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterDeleteRequest import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterListResponse @@ -23,32 +23,32 @@ import retrofit2.http.Path */ interface TimeLetterApiService { @GET("time-letters") - suspend fun getTimeLetters(): ApiResponse + suspend fun getTimeLetters(): BaseResponse @POST("time-letters") suspend fun createTimeLetter( - @Body body: TimeLetterCreateRequest - ): ApiResponse + @Body body: TimeLetterCreateRequest, + ): BaseResponse @GET("time-letters/{timeLetterId}") suspend fun getTimeLetter( - @Path("timeLetterId") timeLetterId: Long - ): ApiResponse + @Path("timeLetterId") timeLetterId: Long, + ): BaseResponse @PATCH("time-letters/{timeLetterId}") suspend fun updateTimeLetter( @Path("timeLetterId") timeLetterId: Long, - @Body body: TimeLetterUpdateRequest - ): ApiResponse + @Body body: TimeLetterUpdateRequest, + ): BaseResponse @POST("time-letters/delete") suspend fun deleteTimeLetters( - @Body body: TimeLetterDeleteRequest - ): ApiResponse + @Body body: TimeLetterDeleteRequest, + ): BaseResponse @GET("time-letters/temporary") - suspend fun getTemporaryTimeLetters(): ApiResponse + suspend fun getTemporaryTimeLetters(): BaseResponse @DELETE("time-letters/temporary") - suspend fun deleteAllTemporary(): ApiResponse + suspend fun deleteAllTemporary(): BaseResponse } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt index 46ba354a..8e2f4b10 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable enum class TimeLetterStatus { DRAFT, SCHEDULED, - SENT + SENT, } @Serializable @@ -19,13 +19,13 @@ enum class TimeLetterMediaType { IMAGE, VIDEO, AUDIO, - DOCUMENT + DOCUMENT, } @Serializable data class TimeLetterMediaRequest( @SerialName("mediaType") val mediaType: TimeLetterMediaType, - @SerialName("mediaUrl") val mediaUrl: String + @SerialName("mediaUrl") val mediaUrl: String, ) @Serializable @@ -35,15 +35,15 @@ data class TimeLetterCreateRequest( @SerialName("sendAt") val sendAt: String? = null, @SerialName("status") val status: TimeLetterStatus, @SerialName("mediaList") val mediaList: List? = null, - @SerialName("receiverIds") val receiverIds: List = emptyList(), - @SerialName("deliveredAt") val deliveredAt: String? = null + @SerialName("receivers") val receiverIds: List = emptyList(), + @SerialName("deliveredAt") val deliveredAt: String? = null, ) @Serializable data class TimeLetterMediaResponse( @SerialName("id") val id: Long, @SerialName("mediaType") val mediaType: TimeLetterMediaType, - @SerialName("mediaUrl") val mediaUrl: String + @SerialName("mediaUrl") val mediaUrl: String, ) @Serializable @@ -56,18 +56,18 @@ data class TimeLetterResponse( @SerialName("mediaList") val mediaList: List? = null, val receiverIds: List = emptyList(), @SerialName("createdAt") val createdAt: String? = null, - @SerialName("updatedAt") val updatedAt: String? = null + @SerialName("updatedAt") val updatedAt: String? = null, ) @Serializable data class TimeLetterListResponse( @SerialName("timeLetters") val timeLetters: List, - @SerialName("totalCount") val totalCount: Int + @SerialName("totalCount") val totalCount: Int, ) @Serializable data class TimeLetterDeleteRequest( - @SerialName("timeLetterIds") val timeLetterIds: List + @SerialName("timeLetterIds") val timeLetterIds: List, ) @Serializable @@ -76,5 +76,5 @@ data class TimeLetterUpdateRequest( @SerialName("content") val content: String? = null, @SerialName("sendAt") val sendAt: String? = null, @SerialName("status") val status: TimeLetterStatus? = null, - @SerialName("mediaList") val mediaList: List? = null + @SerialName("mediaList") val mediaList: List? = null, ) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt index 2adf420e..5f6336e8 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt @@ -2,7 +2,7 @@ package com.kuit.afternote.feature.timeletter.data.repository import android.content.Context import android.net.Uri -import com.kuit.afternote.data.remote.requireData +import com.kuit.afternote.data.requireData import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository import com.kuit.afternote.feature.user.data.api.ImageApiService import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto @@ -32,104 +32,112 @@ class TimeLetterMediaUploadRepositoryImpl @ApplicationContext private val context: Context, private val imageApi: ImageApiService, @Named("S3Upload") private val okHttpClient: OkHttpClient, - @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher + @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher, ) : TimeLetterMediaUploadRepository { + override suspend fun uploadImage(uriString: String): Result = + runCatching { + val uri = Uri.parse(uriString) + val extension = extensionFromUri(uri) + val presigned = + imageApi + .getPresignedUrl( + PresignedUrlRequestDto( + directory = DIRECTORY_TIME_LETTERS, + extension = extension, + ), + ).requireData() + ?: throw IllegalStateException("Presigned URL response data is null") - override suspend fun uploadImage(uriString: String): Result = - runCatching { - val uri = Uri.parse(uriString) - val extension = extensionFromUri(uri) - val presigned = imageApi.getPresignedUrl( - PresignedUrlRequestDto( - directory = DIRECTORY_TIME_LETTERS, - extension = extension - ) - ).requireData() - ?: throw IllegalStateException("Presigned URL response data is null") - - val bytes = withContext(ioDispatcher) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IllegalStateException("Could not read image from URI") - } + val bytes = + withContext(ioDispatcher) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalStateException("Could not read image from URI") + } - val contentType = presigned.contentType.ifBlank { "image/jpeg" } - val requestBody = bytes.toRequestBody(contentType.toMediaType()) - val putRequest = - Request.Builder() - .url(presigned.presignedUrl) - .put(requestBody) - .header("Content-Type", contentType) - .build() + val contentType = presigned.contentType.ifBlank { "image/jpeg" } + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + val putRequest = + Request + .Builder() + .url(presigned.presignedUrl) + .put(requestBody) + .header("Content-Type", contentType) + .build() - withContext(ioDispatcher) { - okHttpClient.newCall(putRequest).execute().use { response -> - check(response.isSuccessful) { - "S3 upload failed: ${response.code} ${response.message}" + withContext(ioDispatcher) { + okHttpClient.newCall(putRequest).execute().use { response -> + check(response.isSuccessful) { + "S3 upload failed: ${response.code} ${response.message}" + } } } + presigned.fileUrl } - presigned.fileUrl - } - override suspend fun uploadAudio(uriString: String): Result = - runCatching { - val uri = Uri.parse(uriString) - val (extension, defaultContentType) = audioExtensionAndContentTypeFromUri(uri) - val presigned = imageApi.getPresignedUrl( - PresignedUrlRequestDto( - directory = DIRECTORY_TIME_LETTERS, - extension = extension - ) - ).requireData() - ?: throw IllegalStateException("Presigned URL response data is null") + override suspend fun uploadAudio(uriString: String): Result = + runCatching { + val uri = Uri.parse(uriString) + val (extension, defaultContentType) = audioExtensionAndContentTypeFromUri(uri) + val presigned = + imageApi + .getPresignedUrl( + PresignedUrlRequestDto( + directory = DIRECTORY_TIME_LETTERS, + extension = extension, + ), + ).requireData() + ?: throw IllegalStateException("Presigned URL response data is null") - val bytes = withContext(ioDispatcher) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IllegalStateException("Could not read audio from URI") - } + val bytes = + withContext(ioDispatcher) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalStateException("Could not read audio from URI") + } - val contentType = presigned.contentType.ifBlank { defaultContentType } - val requestBody = bytes.toRequestBody(contentType.toMediaType()) - val putRequest = - Request.Builder() - .url(presigned.presignedUrl) - .put(requestBody) - .header("Content-Type", contentType) - .build() + val contentType = presigned.contentType.ifBlank { defaultContentType } + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + val putRequest = + Request + .Builder() + .url(presigned.presignedUrl) + .put(requestBody) + .header("Content-Type", contentType) + .build() - withContext(ioDispatcher) { - okHttpClient.newCall(putRequest).execute().use { response -> - check(response.isSuccessful) { - "S3 upload failed: ${response.code} ${response.message}" + withContext(ioDispatcher) { + okHttpClient.newCall(putRequest).execute().use { response -> + check(response.isSuccessful) { + "S3 upload failed: ${response.code} ${response.message}" + } } } + presigned.fileUrl } - presigned.fileUrl - } - private fun extensionFromUri(uri: Uri): String { - val mime = context.contentResolver.getType(uri) ?: return DEFAULT_IMAGE_EXTENSION - return when { - mime == "image/jpeg" || mime == "image/jpg" -> "jpg" - mime == "image/png" -> "png" - mime == "image/webp" -> "webp" - mime == "image/gif" -> "gif" - mime == "image/heic" -> "heic" - else -> DEFAULT_IMAGE_EXTENSION + private fun extensionFromUri(uri: Uri): String { + val mime = context.contentResolver.getType(uri) ?: return DEFAULT_IMAGE_EXTENSION + return when { + mime == "image/jpeg" || mime == "image/jpg" -> "jpg" + mime == "image/png" -> "png" + mime == "image/webp" -> "webp" + mime == "image/gif" -> "gif" + mime == "image/heic" -> "heic" + else -> DEFAULT_IMAGE_EXTENSION + } } - } - private fun audioExtensionAndContentTypeFromUri(uri: Uri): Pair { - val mime = context.contentResolver.getType(uri) - ?: return DEFAULT_AUDIO_EXTENSION to DEFAULT_AUDIO_CONTENT_TYPE - return when { - mime == "audio/mpeg" || mime == "audio/mp3" -> "mp3" to "audio/mpeg" - mime == "audio/mp4" || mime == "audio/x-m4a" -> "m4a" to DEFAULT_AUDIO_CONTENT_TYPE - mime == "audio/ogg" -> "ogg" to "audio/ogg" - mime == "audio/webm" -> "webm" to "audio/webm" - mime == "audio/amr" -> "amr" to "audio/amr" - mime == "audio/3gpp" -> "3gp" to "audio/3gpp" - else -> DEFAULT_AUDIO_EXTENSION to DEFAULT_AUDIO_CONTENT_TYPE + private fun audioExtensionAndContentTypeFromUri(uri: Uri): Pair { + val mime = + context.contentResolver.getType(uri) + ?: return DEFAULT_AUDIO_EXTENSION to DEFAULT_AUDIO_CONTENT_TYPE + return when { + mime == "audio/mpeg" || mime == "audio/mp3" -> "mp3" to "audio/mpeg" + mime == "audio/mp4" || mime == "audio/x-m4a" -> "m4a" to DEFAULT_AUDIO_CONTENT_TYPE + mime == "audio/ogg" -> "ogg" to "audio/ogg" + mime == "audio/webm" -> "webm" to "audio/webm" + mime == "audio/amr" -> "amr" to "audio/amr" + mime == "audio/3gpp" -> "3gp" to "audio/3gpp" + else -> DEFAULT_AUDIO_EXTENSION to DEFAULT_AUDIO_CONTENT_TYPE + } } } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt index 6d36d638..4c29014d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.feature.timeletter.data.repository import android.util.Log -import com.kuit.afternote.data.remote.requireData +import com.kuit.afternote.data.requireData import com.kuit.afternote.feature.timeletter.data.api.TimeLetterApiService import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterCreateRequest import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterDeleteRequest @@ -23,7 +23,7 @@ import com.kuit.afternote.feature.timeletter.domain.model.TimeLetterStatus as Do class TimeLetterRepositoryImpl @Inject constructor( - private val api: TimeLetterApiService + private val api: TimeLetterApiService, ) : TimeLetterRepository { override suspend fun getTimeLetters(): Result = runCatching { @@ -40,35 +40,39 @@ class TimeLetterRepositoryImpl status: DomainStatus, mediaList: List>?, receiverIds: List, - deliveredAt: String? + deliveredAt: String?, ): Result = runCatching { Log.d(TAG, "createTimeLetter: title=$title, status=$status") - val dtoStatus = when (status) { - DomainStatus.DRAFT -> DtoStatus.DRAFT - DomainStatus.SCHEDULED -> DtoStatus.SCHEDULED - DomainStatus.SENT -> DtoStatus.SENT - } - val dtoMediaList = mediaList?.map { (type, url) -> - val dtoType = when (type) { - DomainMediaType.IMAGE -> DtoMediaType.IMAGE - DomainMediaType.VIDEO -> DtoMediaType.VIDEO - DomainMediaType.AUDIO -> DtoMediaType.AUDIO - DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT + val dtoStatus = + when (status) { + DomainStatus.DRAFT -> DtoStatus.DRAFT + DomainStatus.SCHEDULED -> DtoStatus.SCHEDULED + DomainStatus.SENT -> DtoStatus.SENT + } + val dtoMediaList = + mediaList?.map { (type, url) -> + val dtoType = + when (type) { + DomainMediaType.IMAGE -> DtoMediaType.IMAGE + DomainMediaType.VIDEO -> DtoMediaType.VIDEO + DomainMediaType.AUDIO -> DtoMediaType.AUDIO + DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT + } + TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url) } - TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url) - } - val response = api.createTimeLetter( - TimeLetterCreateRequest( - title = title, - content = content, - sendAt = sendAt, - status = dtoStatus, - mediaList = dtoMediaList, - receiverIds = receiverIds, - deliveredAt = deliveredAt + val response = + api.createTimeLetter( + TimeLetterCreateRequest( + title = title, + content = content, + sendAt = sendAt, + status = dtoStatus, + mediaList = dtoMediaList, + receiverIds = receiverIds, + deliveredAt = deliveredAt, + ), ) - ) Log.d(TAG, "createTimeLetter: response=$response") TimeLetterMapper.toTimeLetter(response.requireData()) } @@ -87,36 +91,41 @@ class TimeLetterRepositoryImpl content: String?, sendAt: String?, status: DomainStatus?, - mediaList: List>? + mediaList: List>?, ): Result = runCatching { Log.d(TAG, "updateTimeLetter: timeLetterId=$timeLetterId, title=$title, status=$status") - val dtoStatus = status?.let { - when (it) { - DomainStatus.DRAFT -> DtoStatus.DRAFT - DomainStatus.SCHEDULED -> DtoStatus.SCHEDULED - DomainStatus.SENT -> DtoStatus.SENT + val dtoStatus = + status?.let { + when (it) { + DomainStatus.DRAFT -> DtoStatus.DRAFT + DomainStatus.SCHEDULED -> DtoStatus.SCHEDULED + DomainStatus.SENT -> DtoStatus.SENT + } } - } - val dtoMediaList = mediaList?.map { (type, url) -> - val dtoType = when (type) { - DomainMediaType.IMAGE -> DtoMediaType.IMAGE - DomainMediaType.VIDEO -> DtoMediaType.VIDEO - DomainMediaType.AUDIO -> DtoMediaType.AUDIO - DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT + val dtoMediaList = + mediaList?.map { (type, url) -> + val dtoType = + when (type) { + DomainMediaType.IMAGE -> DtoMediaType.IMAGE + DomainMediaType.VIDEO -> DtoMediaType.VIDEO + DomainMediaType.AUDIO -> DtoMediaType.AUDIO + DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT + } + TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url) } - TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url) - } - val response = api.updateTimeLetter( - timeLetterId = timeLetterId, - body = TimeLetterUpdateRequest( - title = title, - content = content, - sendAt = sendAt, - status = dtoStatus, - mediaList = dtoMediaList + val response = + api.updateTimeLetter( + timeLetterId = timeLetterId, + body = + TimeLetterUpdateRequest( + title = title, + content = content, + sendAt = sendAt, + status = dtoStatus, + mediaList = dtoMediaList, + ), ) - ) Log.d(TAG, "updateTimeLetter: response=$response") TimeLetterMapper.toTimeLetter(response.requireData()) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt index ea4ab368..7b14d9c5 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.IconButton import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,6 +17,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,13 +44,12 @@ import com.kuit.afternote.R import com.kuit.afternote.app.compositionlocal.DataProviderLocals import com.kuit.afternote.core.ui.component.ScaffoldContentWithOptionalFab import com.kuit.afternote.core.ui.component.list.TimeLetterBlockList -import com.kuit.afternote.data.provider.FakeReceiverDataProvider import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.feature.timeletter.presentation.component.LetterTheme -import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterListItemCallbacks +import com.kuit.afternote.domain.provider.FakeReceiverDataProvider import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterListItem +import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterListItemCallbacks import com.kuit.afternote.feature.timeletter.presentation.component.ViewModeToggle import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterUiState @@ -81,7 +80,7 @@ fun TimeLetterScreen( onAddClick: () -> Unit = {}, onShowAllClick: () -> Unit = {}, onLetterClick: (TimeLetterItem) -> Unit = {}, - onEditLetter: (TimeLetterItem) -> Unit = {} + onEditLetter: (TimeLetterItem) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val viewMode by viewModel.viewMode.collectAsStateWithLifecycle() @@ -102,111 +101,113 @@ fun TimeLetterScreen( Image( painter = painterResource(id = R.drawable.ic_vector), contentDescription = "뒤로가기", - modifier = Modifier.size(width = 6.dp, height = 12.dp) + modifier = Modifier.size(width = 6.dp, height = 12.dp), ) } - } + }, ) } }, bottomBar = { BottomNavigationBar( selectedItem = BottomNavItem.TIME_LETTER, - onItemSelected = onNavItemSelected + onItemSelected = onNavItemSelected, ) - } + }, ) { innerPadding -> ScaffoldContentWithOptionalFab( paddingValues = innerPadding, showFab = true, - onFabClick = onAddClick + onFabClick = onAddClick, ) { contentModifier -> Column( - modifier = contentModifier.fillMaxWidth() + modifier = contentModifier.fillMaxWidth(), ) { Spacer(modifier = Modifier.height(18.dp)) - // 상태에 따른 조건부 렌더링 - when (uiState) { - is TimeLetterUiState.Loading -> { - // 로딩 상태 (필요시 로딩 인디케이터) - Box(modifier = Modifier.weight(1f)) - } + // 상태에 따른 조건부 렌더링 + when (uiState) { + is TimeLetterUiState.Loading -> { + // 로딩 상태 (필요시 로딩 인디케이터) + Box(modifier = Modifier.weight(1f)) + } - is TimeLetterUiState.Empty -> { - // 빈 화면 (토글 없음) - LetterEmptyContent( - modifier = Modifier.weight(1f) - ) - } + is TimeLetterUiState.Empty -> { + // 빈 화면 (토글 없음) + LetterEmptyContent( + modifier = Modifier.weight(1f), + ) + } - is TimeLetterUiState.Success -> { - val successState = uiState as TimeLetterUiState.Success - val letters = successState.letters - val filterLabel = successState.selectedReceiverName ?: "전체보기" + is TimeLetterUiState.Success -> { + val successState = uiState as TimeLetterUiState.Success + val letters = successState.letters + val filterLabel = successState.selectedReceiverName ?: "전체보기" - // 필터 + 토글 Row (좌우 패딩은 ScaffoldContentWithOptionalFab contentPadding에서 적용됨) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { + // 필터 + 토글 Row (좌우 패딩은 ScaffoldContentWithOptionalFab contentPadding에서 적용됨) Row( - modifier = Modifier.clickable(onClick = onShowAllClick), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = filterLabel, - fontFamily = FontFamily(Font(R.font.sansneomedium)), - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - Image( - painterResource(R.drawable.ic_down_vector), - contentDescription = "아래 열기", - modifier = Modifier.padding(start = 13.dp).width(12.dp).height(6.dp) + Row( + modifier = Modifier.clickable(onClick = onShowAllClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = filterLabel, + fontFamily = FontFamily(Font(R.font.sansneomedium)), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + Image( + painterResource(R.drawable.ic_down_vector), + contentDescription = "아래 열기", + modifier = Modifier.padding(start = 13.dp).width(12.dp).height(6.dp), + ) + } + Spacer(modifier = Modifier.weight(1f)) + ViewModeToggle( + currentMode = viewMode, + onModeChange = { viewModel.updateViewMode(it) }, ) } - Spacer(modifier = Modifier.weight(1f)) - ViewModeToggle( - currentMode = viewMode, - onModeChange = { viewModel.updateViewMode(it) } - ) - } - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(28.dp)) - // 리스트/블록 뷰 - when (viewMode) { - ViewMode.LIST -> { - LazyColumn(modifier = Modifier.weight(1f)) { - items( - items = letters, - key = { it.id } - ) { letter -> - TimeLetterListItem( - timeLetter = letter, - callbacks = TimeLetterListItemCallbacks( - onClick = { onLetterClick(letter) }, - onEditClick = { onEditLetter(letter) }, - onDeleteClick = { viewModel.deleteTimeLetter(letter.id) } + // 리스트/블록 뷰 + when (viewMode) { + ViewMode.LIST -> { + LazyColumn(modifier = Modifier.weight(1f)) { + items( + items = letters, + key = { it.id }, + ) { letter -> + TimeLetterListItem( + timeLetter = letter, + callbacks = + TimeLetterListItemCallbacks( + onClick = { onLetterClick(letter) }, + onEditClick = { onEditLetter(letter) }, + onDeleteClick = { viewModel.deleteTimeLetter(letter.id) }, + ), ) - ) - Spacer(modifier = Modifier.height(18.dp)) + Spacer(modifier = Modifier.height(18.dp)) + } } } - } - ViewMode.BLOCK -> { - TimeLetterBlockList( - modifier = Modifier.weight(1f), - timeLetterItemList = letters, - contentPadding = PaddingValues(bottom = 16.dp), - onItemClick = onLetterClick - ) + + ViewMode.BLOCK -> { + TimeLetterBlockList( + modifier = Modifier.weight(1f), + timeLetterItemList = letters, + contentPadding = PaddingValues(bottom = 16.dp), + onItemClick = onLetterClick, + ) + } } } } } - } } } } @@ -217,14 +218,15 @@ private fun LetterEmptyContent(modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Image( painter = painterResource(id = R.drawable.img_time_letter_placeholder), contentDescription = "편지이미지", - modifier = Modifier - .width(88.dp) - .height(57.57.dp) + modifier = + Modifier + .width(88.dp) + .height(57.57.dp), ) Spacer(modifier = Modifier.height(40.dp)) Text( @@ -233,7 +235,7 @@ private fun LetterEmptyContent(modifier: Modifier = Modifier) { fontWeight = FontWeight.W400, fontFamily = FontFamily(Font(R.font.sansneoregular)), color = Color(0xFF9E9E9E), - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -241,13 +243,13 @@ private fun LetterEmptyContent(modifier: Modifier = Modifier) { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "리스트형" + name = "리스트형", ) @Composable private fun TimeLetterScreenListPreview() { AfternoteTheme { CompositionLocalProvider( - DataProviderLocals.LocalReceiverDataProvider provides FakeReceiverDataProvider() + DataProviderLocals.LocalReceiverDataProvider provides FakeReceiverDataProvider(), ) { TimeLetterScreenPreviewContent(initialViewMode = ViewMode.LIST) } @@ -257,13 +259,13 @@ private fun TimeLetterScreenListPreview() { @Preview( showBackground = true, device = "spec:width=390dp,height=844dp,dpi=420,isRound=false", - name = "블록형" + name = "블록형", ) @Composable private fun TimeLetterScreenBlockPreview() { AfternoteTheme { CompositionLocalProvider( - DataProviderLocals.LocalReceiverDataProvider provides FakeReceiverDataProvider() + DataProviderLocals.LocalReceiverDataProvider provides FakeReceiverDataProvider(), ) { TimeLetterScreenPreviewContent(initialViewMode = ViewMode.BLOCK) } @@ -289,92 +291,92 @@ private fun TimeLetterScreenPreviewContent(initialViewMode: ViewMode) { Image( painter = painterResource(id = R.drawable.ic_vector), contentDescription = "뒤로가기", - modifier = Modifier.size(width = 6.dp, height = 12.dp) + modifier = Modifier.size(width = 6.dp, height = 12.dp), ) } - } + }, ) } }, bottomBar = { BottomNavigationBar( selectedItem = BottomNavItem.TIME_LETTER, - onItemSelected = {} + onItemSelected = {}, ) - } + }, ) { innerPadding -> ScaffoldContentWithOptionalFab( paddingValues = innerPadding, showFab = true, - onFabClick = {} + onFabClick = {}, ) { contentModifier -> Column( - modifier = contentModifier.fillMaxWidth() + modifier = contentModifier.fillMaxWidth(), ) { Spacer(modifier = Modifier.height(18.dp)) when (uiState) { - is TimeLetterUiState.Loading -> { - Box(modifier = Modifier.weight(1f)) - } + is TimeLetterUiState.Loading -> { + Box(modifier = Modifier.weight(1f)) + } - is TimeLetterUiState.Empty -> { - LetterEmptyContent(modifier = Modifier.weight(1f)) - } + is TimeLetterUiState.Empty -> { + LetterEmptyContent(modifier = Modifier.weight(1f)) + } - is TimeLetterUiState.Success -> { - val letters = uiState.letters + is TimeLetterUiState.Success -> { + val letters = uiState.letters - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "전체보기", - fontFamily = FontFamily(Font(R.font.sansneomedium)), - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - Image( - painterResource(R.drawable.ic_down_vector), - contentDescription = "아래 열기", - modifier = Modifier.padding(start = 13.dp).width(12.dp).height(6.dp) - ) - Spacer(modifier = Modifier.weight(1f)) - ViewModeToggle( - currentMode = currentViewMode, - onModeChange = { currentViewMode = it } - ) - } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "전체보기", + fontFamily = FontFamily(Font(R.font.sansneomedium)), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + Image( + painterResource(R.drawable.ic_down_vector), + contentDescription = "아래 열기", + modifier = Modifier.padding(start = 13.dp).width(12.dp).height(6.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + ViewModeToggle( + currentMode = currentViewMode, + onModeChange = { currentViewMode = it }, + ) + } - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(28.dp)) - when (currentViewMode) { - ViewMode.LIST -> { - LazyColumn(modifier = Modifier.weight(1f)) { - items( - items = letters, - key = { it.id } - ) { letter -> - TimeLetterListItem( - timeLetter = letter - ) - Spacer(modifier = Modifier.height(18.dp)) + when (currentViewMode) { + ViewMode.LIST -> { + LazyColumn(modifier = Modifier.weight(1f)) { + items( + items = letters, + key = { it.id }, + ) { letter -> + TimeLetterListItem( + timeLetter = letter, + ) + Spacer(modifier = Modifier.height(18.dp)) + } } } - } - ViewMode.BLOCK -> { - TimeLetterBlockList( - modifier = Modifier.weight(1f), - timeLetterItemList = letters, - contentPadding = PaddingValues(bottom = 16.dp) - ) + ViewMode.BLOCK -> { + TimeLetterBlockList( + modifier = Modifier.weight(1f), + timeLetterItemList = letters, + contentPadding = PaddingValues(bottom = 16.dp), + ) + } } } } } - } } } } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt index 705fb831..57d328a5 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt @@ -32,14 +32,14 @@ class DraftLetterViewModel private val deleteTimeLettersUseCase: DeleteTimeLettersUseCase, private val deleteAllTemporaryUseCase: DeleteAllTemporaryUseCase, private val getUserIdUseCase: GetUserIdUseCase, - private val getReceiversUseCase: GetReceiversUseCase + private val getReceiversUseCase: GetReceiversUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow(DraftLetterUiState()) val uiState: StateFlow = _uiState.asStateFlow() /** * 임시저장 목록 로드 (GET /time-letters/temporary) - * 수신자 목록(GET /users/receivers)으로 receiverIds → 이름 매핑 후 표시 + * 수신자 목록(GET /users/receivers)으로 receivers → 이름 매핑 후 표시 */ fun loadTemporaryLetters() { viewModelScope.launch { @@ -51,14 +51,14 @@ class DraftLetterViewModel _uiState.update { it.copy( draftLetters = items, - isLoading = false + isLoading = false, ) } }.onFailure { _uiState.update { it.copy( draftLetters = emptyList(), - isLoading = false + isLoading = false, ) } } @@ -72,14 +72,20 @@ class DraftLetterViewModel private fun resolveReceiverDisplayText( receiverIds: List, - receivers: List + receivers: List, ): String = when { - receiverIds.isEmpty() -> "-" + receiverIds.isEmpty() -> { + "-" + } + receiverIds.size == 1 -> { receivers.find { it.receiverId == receiverIds[0] }?.name?.let { name -> "${name}님께" } ?: "-" } - else -> "${receiverIds.size}명에게" + + else -> { + "${receiverIds.size}명에게" + } } /** @@ -105,11 +111,12 @@ class DraftLetterViewModel */ fun toggleSelection(id: String) { _uiState.update { state -> - val newSet = if (state.selectedIds.contains(id)) { - state.selectedIds - id - } else { - state.selectedIds + id - } + val newSet = + if (state.selectedIds.contains(id)) { + state.selectedIds - id + } else { + state.selectedIds + id + } state.copy(selectedIds = newSet) } } @@ -126,7 +133,7 @@ class DraftLetterViewModel _uiState.update { it.copy( isEditMode = false, - selectedIds = emptySet() + selectedIds = emptySet(), ) } loadTemporaryLetters() @@ -146,7 +153,7 @@ class DraftLetterViewModel it.copy( isEditMode = false, selectedIds = emptySet(), - draftLetters = emptyList() + draftLetters = emptyList(), ) } onSuccess() @@ -156,13 +163,13 @@ class DraftLetterViewModel private fun toDraftLetterItem( t: TimeLetter, - receivers: List + receivers: List, ): DraftLetterItem = DraftLetterItem( id = t.id.toString(), receiverName = resolveReceiverDisplayText(t.receiverIds, receivers), sendDate = formatSendAtForDisplay(t.sendAt), - title = t.title ?: "" + title = t.title ?: "", ) /** @@ -182,5 +189,5 @@ data class DraftLetterUiState( val draftLetters: List = emptyList(), val isLoading: Boolean = false, val isEditMode: Boolean = false, - val selectedIds: Set = emptySet() + val selectedIds: Set = emptySet(), ) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt index f109b13a..c7b17f76 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt @@ -3,12 +3,12 @@ package com.kuit.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kuit.afternote.feature.timeletter.presentation.navgraph.SELECTED_RECEIVER_ID_KEY import com.kuit.afternote.feature.timeletter.domain.model.TimeLetter import com.kuit.afternote.feature.timeletter.domain.model.TimeLetterMediaType import com.kuit.afternote.feature.timeletter.domain.usecase.DeleteTimeLettersUseCase import com.kuit.afternote.feature.timeletter.domain.usecase.GetTimeLettersUseCase import com.kuit.afternote.feature.timeletter.presentation.component.LetterTheme +import com.kuit.afternote.feature.timeletter.presentation.navgraph.SELECTED_RECEIVER_ID_KEY import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterUiState import com.kuit.afternote.feature.timeletter.presentation.uimodel.ViewMode @@ -36,7 +36,7 @@ class TimeLetterViewModel private val deleteTimeLettersUseCase: DeleteTimeLettersUseCase, private val getUserIdUseCase: GetUserIdUseCase, private val getReceiversUseCase: GetReceiversUseCase, - private val savedStateHandle: SavedStateHandle + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private val _viewMode = MutableStateFlow(ViewMode.LIST) val viewMode: StateFlow = _viewMode.asStateFlow() @@ -53,7 +53,7 @@ class TimeLetterViewModel /** * 타임레터 목록 로드 (GET /time-letters) - * 수신자 목록(GET /users/receivers)으로 receiverIds → 이름 매핑 후 표시. + * 수신자 목록(GET /users/receivers)으로 receivers → 이름 매핑 후 표시. * SavedStateHandle에 선택 수신자 ID가 있으면 해당 수신자로 필터링하고 헤더에 이름을 노출한다. */ private fun loadLetters() { @@ -69,21 +69,23 @@ class TimeLetterViewModel } else { list.timeLetters } - val items = filteredLetters.mapIndexed { index, timeLetter -> - toTimeLetterItem(timeLetter, index, receivers) - } + val items = + filteredLetters.mapIndexed { index, timeLetter -> + toTimeLetterItem(timeLetter, index, receivers) + } val selectedReceiverName = selectedReceiverId?.let { id -> receivers.find { it.receiverId == id }?.name } - _uiState.value = if (items.isEmpty()) { - TimeLetterUiState.Empty - } else { - TimeLetterUiState.Success( - letters = items, - selectedReceiverName = selectedReceiverName - ) - } + _uiState.value = + if (items.isEmpty()) { + TimeLetterUiState.Empty + } else { + TimeLetterUiState.Success( + letters = items, + selectedReceiverName = selectedReceiverName, + ) + } }.onFailure { _uiState.value = TimeLetterUiState.Empty } @@ -97,14 +99,20 @@ class TimeLetterViewModel private fun resolveReceiverDisplayText( receiverIds: List, - receivers: List + receivers: List, ): String = when { - receiverIds.isEmpty() -> "-" + receiverIds.isEmpty() -> { + "-" + } + receiverIds.size == 1 -> { receivers.find { it.receiverId == receiverIds[0] }?.name?.let { name -> "${name}님께" } ?: "-" } - else -> "${receiverIds.size}명에게" + + else -> { + "${receiverIds.size}명에게" + } } /** @@ -129,23 +137,26 @@ class TimeLetterViewModel /** * Domain TimeLetter → UI TimeLetterItem 변환 - * receivername은 receiverIds + 수신자 목록으로 해석 + * receivername은 receivers + 수신자 목록으로 해석 */ private fun toTimeLetterItem( t: TimeLetter, index: Int, - receivers: List + receivers: List, ): TimeLetterItem { val themes = listOf(LetterTheme.PEACH, LetterTheme.BLUE, LetterTheme.YELLOW) - val imageUrls = t.mediaList - .filter { it.mediaType == TimeLetterMediaType.IMAGE } - .map { it.mediaUrl } - val audioUrls = t.mediaList - .filter { it.mediaType == TimeLetterMediaType.AUDIO } - .map { it.mediaUrl } - val linkUrls = t.mediaList - .filter { it.mediaType == TimeLetterMediaType.DOCUMENT } - .map { it.mediaUrl } + val imageUrls = + t.mediaList + .filter { it.mediaType == TimeLetterMediaType.IMAGE } + .map { it.mediaUrl } + val audioUrls = + t.mediaList + .filter { it.mediaType == TimeLetterMediaType.AUDIO } + .map { it.mediaUrl } + val linkUrls = + t.mediaList + .filter { it.mediaType == TimeLetterMediaType.DOCUMENT } + .map { it.mediaUrl } return TimeLetterItem( id = t.id.toString(), receivername = resolveReceiverDisplayText(t.receiverIds, receivers), @@ -157,7 +168,7 @@ class TimeLetterViewModel createDate = formatSendAtForDisplay(t.createdAt), mediaUrls = imageUrls, audioUrls = audioUrls, - linkUrls = linkUrls + linkUrls = linkUrls, ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/api/ImageApiService.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/api/ImageApiService.kt index 73d39ac1..0c4667d8 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/api/ImageApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/api/ImageApiService.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.user.data.api -import com.kuit.afternote.data.remote.ApiResponse +import com.kuit.afternote.data.BaseResponse import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto import com.kuit.afternote.feature.user.data.dto.PresignedUrlResponseDto import retrofit2.http.Body @@ -14,6 +14,6 @@ import retrofit2.http.POST fun interface ImageApiService { @POST("files/presigned-url") suspend fun getPresignedUrl( - @Body body: PresignedUrlRequestDto - ): ApiResponse + @Body body: PresignedUrlRequestDto, + ): BaseResponse } diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt index e5905cbc..2e7f6396 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt @@ -1,12 +1,12 @@ package com.kuit.afternote.feature.user.data.api -import com.kuit.afternote.data.remote.ApiResponse +import com.kuit.afternote.data.BaseResponse import com.kuit.afternote.feature.user.data.dto.DeliveryConditionRequestDto import com.kuit.afternote.feature.user.data.dto.DeliveryConditionResponseDto import com.kuit.afternote.feature.user.data.dto.ReceiverDailyQuestionsResponseDto -import com.kuit.afternote.feature.user.data.dto.ReceiverMindRecordsResponseDto import com.kuit.afternote.feature.user.data.dto.ReceiverDetailResponseDto import com.kuit.afternote.feature.user.data.dto.ReceiverItemDto +import com.kuit.afternote.feature.user.data.dto.ReceiverMindRecordsResponseDto import com.kuit.afternote.feature.user.data.dto.RegisterReceiverRequestDto import com.kuit.afternote.feature.user.data.dto.RegisterReceiverResponseDto import com.kuit.afternote.feature.user.data.dto.UserPushSettingResponse @@ -32,14 +32,14 @@ import retrofit2.http.Query interface UserApiService { @GET("users/me") suspend fun getMyProfile( - @Query("userId") userId: Long - ): ApiResponse + @Query("userId") userId: Long, + ): BaseResponse @PATCH("users/me") suspend fun updateMyProfile( @Query("userId") userId: Long, - @Body body: UserUpdateProfileRequest - ): ApiResponse + @Body body: UserUpdateProfileRequest, + ): BaseResponse /** * DELETE /users/me — 회원 탈퇴. 로그인한 사용자의 계정을 삭제합니다. 모든 데이터가 영구 삭제되며 복구할 수 없습니다. @@ -47,7 +47,7 @@ interface UserApiService { * @return 200 OK, data: {} */ @DELETE("users/me") - suspend fun withdrawAccount(): ApiResponse + suspend fun withdrawAccount(): BaseResponse /** * 푸시 알림 설정 조회. @@ -58,14 +58,14 @@ interface UserApiService { */ @GET("users/push-settings") suspend fun getMyPushSettings( - @Query("userId") userId: Long - ): ApiResponse + @Query("userId") userId: Long, + ): BaseResponse @PATCH("users/push-settings") suspend fun updateMyPushSettings( @Query("userId") userId: Long, - @Body body: UserUpdatePushSettingRequest - ): ApiResponse + @Body body: UserUpdatePushSettingRequest, + ): BaseResponse /** * 수신인 목록 조회. @@ -76,13 +76,13 @@ interface UserApiService { */ @GET("users/receivers") suspend fun getReceivers( - @Query("userId") userId: Long - ): ApiResponse?> + @Query("userId") userId: Long, + ): BaseResponse?> @POST("users/receivers") suspend fun registerReceiver( - @Body body: RegisterReceiverRequestDto - ): ApiResponse + @Body body: RegisterReceiverRequestDto, + ): BaseResponse /** * 수신인 상세 조회. @@ -94,8 +94,8 @@ interface UserApiService { */ @GET("users/receivers/{receiverId}") suspend fun getReceiverDetail( - @Path("receiverId") receiverId: Long - ): ApiResponse + @Path("receiverId") receiverId: Long, + ): BaseResponse /** * 수신인 수정. @@ -104,8 +104,8 @@ interface UserApiService { @PATCH("users/receivers/{receiverId}") suspend fun updateReceiver( @Path("receiverId") receiverId: Long, - @Body body: RegisterReceiverRequestDto - ): ApiResponse + @Body body: RegisterReceiverRequestDto, + ): BaseResponse /** * 수신인별 마음의 기록 전체 조회 (일기, 깊은 생각, 데일리 질문 답변). @@ -120,8 +120,8 @@ interface UserApiService { suspend fun getReceiverMindRecords( @Path("receiverId") receiverId: Long, @Query("page") page: Int, - @Query("size") size: Int - ): ApiResponse + @Query("size") size: Int, + ): BaseResponse /** * 수신인별 데일리 질문 답변 목록 조회 (페이지네이션). @@ -136,8 +136,8 @@ interface UserApiService { suspend fun getReceiverDailyQuestions( @Path("receiverId") receiverId: Long, @Query("page") page: Int, - @Query("size") size: Int - ): ApiResponse + @Query("size") size: Int, + ): BaseResponse /** * 전달 조건 조회. @@ -146,7 +146,7 @@ interface UserApiService { * @return data: conditionType, inactivityPeriodDays, specificDate, conditionFulfilled, conditionMet */ @GET("users/delivery-condition") - suspend fun getDeliveryCondition(): ApiResponse + suspend fun getDeliveryCondition(): BaseResponse /** * 전달 조건 수정. @@ -157,6 +157,6 @@ interface UserApiService { */ @PATCH("users/delivery-condition") suspend fun updateDeliveryCondition( - @Body body: DeliveryConditionRequestDto - ): ApiResponse + @Body body: DeliveryConditionRequestDto, + ): BaseResponse } diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt index 9a255391..25d558e5 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt @@ -1,9 +1,9 @@ package com.kuit.afternote.feature.user.data.repository import android.util.Log -import com.kuit.afternote.data.remote.ApiException -import com.kuit.afternote.data.remote.requireData -import com.kuit.afternote.data.remote.requireSuccess +import com.kuit.afternote.data.requireData +import com.kuit.afternote.data.requireStatus +import com.kuit.afternote.data.service.ApiException import com.kuit.afternote.feature.user.data.api.UserApiService import com.kuit.afternote.feature.user.data.dto.RegisterReceiverRequestDto import com.kuit.afternote.feature.user.data.dto.UserUpdateProfileRequest @@ -26,7 +26,7 @@ import javax.inject.Inject class UserRepositoryImpl @Inject constructor( - private val api: UserApiService + private val api: UserApiService, ) : UserRepository { override suspend fun getMyProfile(userId: Long): Result = runCatching { @@ -43,18 +43,20 @@ class UserRepositoryImpl userId: Long, name: String?, phone: String?, - profileImageUrl: String? + profileImageUrl: String?, ): Result = runCatching { Log.d(TAG, "updateMyProfile: userId=$userId, name=$name, phone=$phone") - val response = api.updateMyProfile( - userId = userId, - body = UserUpdateProfileRequest( - name = name, - phone = phone, - profileImageUrl = profileImageUrl + val response = + api.updateMyProfile( + userId = userId, + body = + UserUpdateProfileRequest( + name = name, + phone = phone, + profileImageUrl = profileImageUrl, + ), ) - ) Log.d(TAG, "updateMyProfile: response=$response") UserMapper.toUserProfile(response.requireData()) } @@ -63,7 +65,7 @@ class UserRepositoryImpl runCatching { Log.d(TAG, "withdrawAccount: request") val response = api.withdrawAccount() - response.requireSuccess() + response.requireStatus() Log.d(TAG, "withdrawAccount: success") Unit } @@ -80,18 +82,20 @@ class UserRepositoryImpl userId: Long, timeLetter: Boolean?, mindRecord: Boolean?, - afterNote: Boolean? + afterNote: Boolean?, ): Result = runCatching { Log.d(TAG, "updateMyPushSettings: userId=$userId, timeLetter=$timeLetter, mindRecord=$mindRecord, afterNote=$afterNote") - val response = api.updateMyPushSettings( - userId = userId, - body = UserUpdatePushSettingRequest( - timeLetter = timeLetter, - mindRecord = mindRecord, - afterNote = afterNote + val response = + api.updateMyPushSettings( + userId = userId, + body = + UserUpdatePushSettingRequest( + timeLetter = timeLetter, + mindRecord = mindRecord, + afterNote = afterNote, + ), ) - ) Log.d(TAG, "updateMyPushSettings: response=$response") UserMapper.toPushSettings(response.requireData()) } @@ -109,31 +113,28 @@ class UserRepositoryImpl name: String, relation: String, phone: String?, - email: String? + email: String?, ): Result = runCatching { Log.d(TAG, "registerReceiver: name=$name, relation=$relation") - val response = api.registerReceiver( - RegisterReceiverRequestDto( - name = name, - relation = relation, - phone = phone, - email = email + val response = + api.registerReceiver( + RegisterReceiverRequestDto( + name = name, + relation = relation, + phone = phone, + email = email, + ), ) - ) Log.d(TAG, "registerReceiver: response=$response") - if (response.status != 200 && response.status != 201) { + if (response.status != 201) { throw ApiException( status = response.status, code = response.code, - message = response.message + message = response.message ?: "Status 201 아님", ) } - val data = response.data ?: throw ApiException( - status = response.status, - code = response.code, - message = response.message.ifBlank { "data is null" } - ) + val data = response.requireData() data.receiverId } @@ -150,25 +151,27 @@ class UserRepositoryImpl name: String, relation: String, phone: String?, - email: String? + email: String?, ): Result = runCatching { Log.d(TAG, "updateReceiver: receiverId=$receiverId, name=$name") - val response = api.updateReceiver( - receiverId = receiverId, - body = RegisterReceiverRequestDto( - name = name, - relation = relation, - phone = phone, - email = email + val response = + api.updateReceiver( + receiverId = receiverId, + body = + RegisterReceiverRequestDto( + name = name, + relation = relation, + phone = phone, + email = email, + ), ) - ) Log.d(TAG, "updateReceiver: response=$response") if (response.status !in 200..299) { throw ApiException( status = response.status, code = response.code, - message = response.message + message = response.message ?: "Status 200 이상 299 이하가 아님", ) } Unit @@ -177,15 +180,16 @@ class UserRepositoryImpl override suspend fun getReceiverDailyQuestions( receiverId: Long, page: Int, - size: Int + size: Int, ): Result = runCatching { Log.d(TAG, "getReceiverDailyQuestions: receiverId=$receiverId, page=$page, size=$size") - val response = api.getReceiverDailyQuestions( - receiverId = receiverId, - page = page, - size = size - ) + val response = + api.getReceiverDailyQuestions( + receiverId = receiverId, + page = page, + size = size, + ) Log.d(TAG, "getReceiverDailyQuestions: response=$response") val body = response.requireData() val items = body.items.map(UserMapper::toDailyQuestionAnswerItem) @@ -195,15 +199,16 @@ class UserRepositoryImpl override suspend fun getReceiverMindRecords( receiverId: Long, page: Int, - size: Int + size: Int, ): Result = runCatching { Log.d(TAG, "getReceiverMindRecords: receiverId=$receiverId, page=$page, size=$size") - val response = api.getReceiverMindRecords( - receiverId = receiverId, - page = page, - size = size - ) + val response = + api.getReceiverMindRecords( + receiverId = receiverId, + page = page, + size = size, + ) Log.d(TAG, "getReceiverMindRecords: response=$response") val body = response.requireData() val items = (body?.items ?: emptyList()).map(UserMapper::toReceiverMindRecordItem) @@ -222,16 +227,17 @@ class UserRepositoryImpl conditionType: DeliveryConditionType, inactivityPeriodDays: Int?, specificDate: String?, - leaveMessage: String? + leaveMessage: String?, ): Result = runCatching { Log.d(TAG, "updateDeliveryCondition: conditionType=$conditionType") - val body = UserMapper.toDeliveryConditionRequestDto( - conditionType = conditionType, - inactivityPeriodDays = inactivityPeriodDays, - specificDate = specificDate, - leaveMessage = leaveMessage - ) + val body = + UserMapper.toDeliveryConditionRequestDto( + conditionType = conditionType, + inactivityPeriodDays = inactivityPeriodDays, + specificDate = specificDate, + leaveMessage = leaveMessage, + ) val response = api.updateDeliveryCondition(body) Log.d(TAG, "updateDeliveryCondition: response=$response") UserMapper.toDeliveryCondition(response.requireData()) diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetUserIdUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetUserIdUseCase.kt index 5e3d227e..cab458b1 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetUserIdUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetUserIdUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.user.domain.usecase -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import javax.inject.Inject /** @@ -12,7 +12,7 @@ import javax.inject.Inject class GetUserIdUseCase @Inject constructor( - private val tokenManager: TokenManager + private val tokenManager: TokenManager, ) { /** * JWT 토큰에서 userId를 추출합니다. diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt index 0a6891e2..35b8e89e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.user.domain.usecase -import com.kuit.afternote.data.upload.PhotoUploadRepository +import com.kuit.afternote.domain.repository.PhotoUploadRepository import javax.inject.Inject private const val DIRECTORY_PROFILES = "profiles" @@ -11,8 +11,7 @@ private const val DIRECTORY_PROFILES = "profiles" class UploadProfileImageUseCase @Inject constructor( - private val photoUploadRepository: PhotoUploadRepository + private val photoUploadRepository: PhotoUploadRepository, ) { - suspend operator fun invoke(uriString: String): Result = - photoUploadRepository.upload(uriString, DIRECTORY_PROFILES) -} + suspend operator fun invoke(uriString: String): Result = photoUploadRepository.upload(uriString, DIRECTORY_PROFILES) + } diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/data/repository/AfternoteRepositoryImplTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/data/repositoryimpl/AfternoteRepositoryImplTest.kt similarity index 74% rename from app/src/test/java/com/kuit/afternote/feature/afternote/data/repository/AfternoteRepositoryImplTest.kt rename to app/src/test/java/com/kuit/afternote/feature/afternote/data/repositoryimpl/AfternoteRepositoryImplTest.kt index 66e528c7..7fa75e4c 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/data/repository/AfternoteRepositoryImplTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/data/repositoryimpl/AfternoteRepositoryImplTest.kt @@ -1,21 +1,21 @@ -package com.kuit.afternote.feature.afternote.data.repository +package com.kuit.afternote.feature.afternote.data.repositoryimpl -import com.kuit.afternote.data.remote.ApiResponse -import com.kuit.afternote.feature.afternote.data.api.AfternoteApiService -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateGalleryRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreatePlaylistRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateSocialRequestDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentialsDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteDetailReceiverDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteDetailResponseDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteIdResponseDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteListItemDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteListResponseDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteMemorialVideoDto -import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylistDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteSongDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto +import com.kuit.afternote.data.dto.response.BaseResponse +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateGalleryRequest +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreatePlaylistRequest +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCreateSocialRequest +import com.kuit.afternote.feature.afternote.data.dto.AfternoteCredentials +import com.kuit.afternote.feature.afternote.data.dto.AfternoteDetailReceiver +import com.kuit.afternote.feature.afternote.data.dto.AfternoteDetailResponse +import com.kuit.afternote.feature.afternote.data.dto.AfternoteIdResponse +import com.kuit.afternote.feature.afternote.data.dto.AfternoteListItem +import com.kuit.afternote.feature.afternote.data.dto.AfternoteListResponse +import com.kuit.afternote.feature.afternote.data.dto.AfternoteMemorialVideo +import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylist +import com.kuit.afternote.feature.afternote.data.dto.AfternoteReceiverRef +import com.kuit.afternote.feature.afternote.data.dto.AfternoteSong +import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequest +import com.kuit.afternote.feature.afternote.data.service.AfternoteApiService import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -32,7 +32,6 @@ import org.junit.Test * entrypoints are actually used and verified against the Retrofit service. */ class AfternoteRepositoryImplTest { - private lateinit var api: AfternoteApiService private lateinit var repository: AfternoteRepositoryImpl @@ -46,22 +45,22 @@ class AfternoteRepositoryImplTest { fun getAfternotes_whenSuccess_returnsMappedItems() = runTest { val listDto = - AfternoteListResponseDto( + AfternoteListResponse( content = listOf( - AfternoteListItemDto( + AfternoteListItem( afternoteId = 10L, title = "인스타그램", category = "SOCIAL", - createdAt = "2025-11-26T14:30:00" - ) + createdAt = "2025-11-26T14:30:00", + ), ), page = 0, size = 10, - hasNext = false + hasNext = false, ) coEvery { api.getAfternotes(category = null, page = 0, size = 10) } returns - ApiResponse(status = 200, code = 0, message = "OK", data = listDto) + BaseResponse(status = 200, code = 0, message = "OK", data = listDto) val result = repository.getAfternotes(category = null, page = 0, size = 10) @@ -77,7 +76,7 @@ class AfternoteRepositoryImplTest { fun createSocial_whenSuccess_returnsId() = runTest { coEvery { api.createAfternoteSocial(any()) } returns - ApiResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponseDto(afternoteId = 3L)) + BaseResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponse(afternoteId = 3L)) val result = repository.createSocial( @@ -86,22 +85,22 @@ class AfternoteRepositoryImplTest { actions = listOf("게시물 내리기"), leaveMessage = "감사했습니다", credentialsId = "id", - credentialsPassword = "pw" + credentialsPassword = "pw", ) assertTrue(result.isSuccess) assertEquals(3L, result.getOrNull()) coVerify(exactly = 1) { api.createAfternoteSocial( - AfternoteCreateSocialRequestDto( + AfternoteCreateSocialRequest( category = "SOCIAL", title = "인스타그램", processMethod = "MEMORIAL", actions = listOf("게시물 내리기"), leaveMessage = "감사했습니다", - credentials = AfternoteCredentialsDto(id = "id", password = "pw"), - receivers = emptyList() - ) + credentials = AfternoteCredentials(id = "id", password = "pw"), + receivers = emptyList(), + ), ) } } @@ -110,7 +109,7 @@ class AfternoteRepositoryImplTest { fun createGallery_whenSuccess_returnsId() = runTest { coEvery { api.createAfternoteGallery(any()) } returns - ApiResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponseDto(afternoteId = 5L)) + BaseResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponse(afternoteId = 5L)) val result = repository.createGallery( @@ -118,14 +117,14 @@ class AfternoteRepositoryImplTest { processMethod = "DELETE", actions = listOf("사진 백업"), leaveMessage = "소중한 추억들", - receiverIds = listOf(1L, 2L) + receiverIds = listOf(1L, 2L), ) assertTrue(result.isSuccess) assertEquals(5L, result.getOrNull()) coVerify(exactly = 1) { api.createAfternoteGallery( - AfternoteCreateGalleryRequestDto( + AfternoteCreateGalleryRequest( category = "GALLERY", title = "가족 사진", processMethod = "DELETE", @@ -133,10 +132,10 @@ class AfternoteRepositoryImplTest { leaveMessage = "소중한 추억들", receivers = listOf( - AfternoteReceiverRefDto(receiverId = 1L), - AfternoteReceiverRefDto(receiverId = 2L) - ) - ) + AfternoteReceiverRef(receiverId = 1L), + AfternoteReceiverRef(receiverId = 2L), + ), + ), ) } } @@ -145,28 +144,28 @@ class AfternoteRepositoryImplTest { fun getAfternoteDetail_whenSuccess_returnsDetail() = runTest { val detail = - AfternoteDetailResponseDto( + AfternoteDetailResponse( afternoteId = 10L, category = "SOCIAL", title = "인스타그램", createdAt = "2025-11-26T14:30:00", updatedAt = "2025-11-26T14:30:00", - credentials = AfternoteCredentialsDto(id = "id", password = "pw"), + credentials = AfternoteCredentials(id = "id", password = "pw"), receivers = listOf( - AfternoteDetailReceiverDto( + AfternoteDetailReceiver( name = "수신인", relation = "친구", - phone = "010-0000-0000" - ) + phone = "010-0000-0000", + ), ), processMethod = "MEMORIAL", actions = listOf("게시물 내리기"), leaveMessage = "감사했습니다", - playlist = null + playlist = null, ) coEvery { api.getAfternoteDetail(afternoteId = 10L) } returns - ApiResponse(status = 200, code = 0, message = "OK", data = detail) + BaseResponse(status = 200, code = 0, message = "OK", data = detail) val result = repository.getAfternoteDetail(afternoteId = 10L) @@ -180,45 +179,45 @@ class AfternoteRepositoryImplTest { fun createPlaylist_whenSuccess_returnsId() = runTest { coEvery { api.createAfternotePlaylist(any()) } returns - ApiResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponseDto(afternoteId = 7L)) + BaseResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponse(afternoteId = 7L)) val playlist = - AfternotePlaylistDto( + AfternotePlaylist( profilePhoto = null, atmosphere = "차분하게", songs = listOf( - AfternoteSongDto( + AfternoteSong( id = null, title = "보고싶다", artist = "김범수", - coverUrl = "https://example.com" - ) + coverUrl = "https://example.com", + ), ), memorialVideo = - AfternoteMemorialVideoDto( + AfternoteMemorialVideo( videoUrl = "https://video", - thumbnailUrl = "https://thumb" - ) + thumbnailUrl = "https://thumb", + ), ) val result = repository.createPlaylist( title = "마지막 플레이리스트", playlist = playlist, - receiverIds = emptyList() + receiverIds = emptyList(), ) assertTrue(result.isSuccess) assertEquals(7L, result.getOrNull()) coVerify(exactly = 1) { api.createAfternotePlaylist( - AfternoteCreatePlaylistRequestDto( + AfternoteCreatePlaylistRequest( category = "PLAYLIST", title = "마지막 플레이리스트", playlist = playlist, - receivers = emptyList() - ) + receivers = emptyList(), + ), ) } } @@ -227,18 +226,18 @@ class AfternoteRepositoryImplTest { fun updateAfternote_whenSuccess_returnsId() = runTest { coEvery { api.updateAfternote(any(), any()) } returns - ApiResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponseDto(afternoteId = 10L)) + BaseResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponse(afternoteId = 10L)) val body = - AfternoteUpdateRequestDto( + AfternoteUpdateRequest( category = "SOCIAL", title = "수정된 제목", processMethod = "MEMORIAL", actions = listOf("게시물 유지"), leaveMessage = "수정된 메시지", - credentials = AfternoteCredentialsDto(id = null, password = "new_pw"), + credentials = AfternoteCredentials(id = null, password = "new_pw"), receivers = null, - playlist = null + playlist = null, ) val result = repository.updateAfternote(afternoteId = 10L, body = body) @@ -252,7 +251,7 @@ class AfternoteRepositoryImplTest { fun deleteAfternote_whenSuccess_returnsUnit() = runTest { coEvery { api.deleteAfternote(afternoteId = 10L) } returns - ApiResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponseDto(afternoteId = 10L)) + BaseResponse(status = 200, code = 0, message = "OK", data = AfternoteIdResponse(afternoteId = 10L)) val result = repository.deleteAfternote(afternoteId = 10L) @@ -260,4 +259,3 @@ class AfternoteRepositoryImplTest { coVerify(exactly = 1) { api.deleteAfternote(afternoteId = 10L) } } } - diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCaseTest.kt index 861a632b..e1dd67ca 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCaseTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateGalleryAfternoteUseCaseTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class CreateGalleryAfternoteUseCaseTest { - private lateinit var repository: AfternoteRepository private lateinit var useCase: CreateGalleryAfternoteUseCase @@ -30,7 +29,7 @@ class CreateGalleryAfternoteUseCaseTest { processMethod = any(), actions = any(), leaveMessage = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.success(5L) @@ -40,7 +39,7 @@ class CreateGalleryAfternoteUseCaseTest { processMethod = "DELETE", actions = listOf("사진 백업"), leaveMessage = "소중한 추억들", - receiverIds = listOf(1L, 2L) + receiverIds = listOf(1L, 2L), ) assertTrue(result.isSuccess) @@ -51,7 +50,7 @@ class CreateGalleryAfternoteUseCaseTest { processMethod = "DELETE", actions = listOf("사진 백업"), leaveMessage = "소중한 추억들", - receiverIds = listOf(1L, 2L) + receiverIds = listOf(1L, 2L), ) } } @@ -65,7 +64,7 @@ class CreateGalleryAfternoteUseCaseTest { processMethod = any(), actions = any(), leaveMessage = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(RuntimeException("Bad request")) @@ -75,7 +74,7 @@ class CreateGalleryAfternoteUseCaseTest { processMethod = "DELETE", actions = emptyList(), leaveMessage = null, - receiverIds = emptyList() + receiverIds = emptyList(), ) assertTrue(result.isFailure) diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCaseTest.kt index b2805ab9..ce7c9c51 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCaseTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreatePlaylistAfternoteUseCaseTest.kt @@ -1,8 +1,8 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylistDto -import com.kuit.afternote.feature.afternote.data.dto.AfternoteSongDto -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.data.dto.AfternotePlaylist +import com.kuit.afternote.feature.afternote.data.dto.AfternoteSong +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -13,7 +13,6 @@ import org.junit.Before import org.junit.Test class CreatePlaylistAfternoteUseCaseTest { - private lateinit var repository: AfternoteRepository private lateinit var useCase: CreatePlaylistAfternoteUseCase @@ -27,17 +26,17 @@ class CreatePlaylistAfternoteUseCaseTest { fun invoke_whenSuccess_returnsId() = runTest { val playlist = - AfternotePlaylistDto( + AfternotePlaylist( profilePhoto = null, atmosphere = "차분하게", - songs = listOf(AfternoteSongDto(id = null, title = "보고싶다", artist = "김범수", coverUrl = "https://example.com")), - memorialVideo = null + songs = listOf(AfternoteSong(id = null, title = "보고싶다", artist = "김범수", coverUrl = "https://example.com")), + memorialVideo = null, ) coEvery { repository.createPlaylist( title = any(), playlist = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.success(7L) @@ -49,7 +48,7 @@ class CreatePlaylistAfternoteUseCaseTest { repository.createPlaylist( title = "마지막 플레이리스트", playlist = playlist, - receiverIds = emptyList() + receiverIds = emptyList(), ) } } @@ -58,17 +57,17 @@ class CreatePlaylistAfternoteUseCaseTest { fun invoke_whenFailure_returnsFailure() = runTest { val playlist = - AfternotePlaylistDto( + AfternotePlaylist( profilePhoto = null, atmosphere = null, songs = emptyList(), - memorialVideo = null + memorialVideo = null, ) coEvery { repository.createPlaylist( title = any(), playlist = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(RuntimeException("Validation failed")) diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCaseTest.kt index 07fd16cb..d24d7b9b 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCaseTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/CreateSocialAfternoteUseCaseTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class CreateSocialAfternoteUseCaseTest { - private lateinit var repository: AfternoteRepository private lateinit var useCase: CreateSocialAfternoteUseCase @@ -32,7 +31,7 @@ class CreateSocialAfternoteUseCaseTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.success(3L) @@ -43,7 +42,7 @@ class CreateSocialAfternoteUseCaseTest { actions = listOf("게시물 내리기"), leaveMessage = "감사했습니다", credentialsId = "id", - credentialsPassword = "pw" + credentialsPassword = "pw", ) assertTrue(result.isSuccess) @@ -56,7 +55,7 @@ class CreateSocialAfternoteUseCaseTest { leaveMessage = "감사했습니다", credentialsId = "id", credentialsPassword = "pw", - receiverIds = emptyList() + receiverIds = emptyList(), ) } } @@ -72,7 +71,7 @@ class CreateSocialAfternoteUseCaseTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(RuntimeException("Server error")) @@ -83,7 +82,7 @@ class CreateSocialAfternoteUseCaseTest { actions = emptyList(), leaveMessage = null, credentialsId = null, - credentialsPassword = null + credentialsPassword = null, ) assertTrue(result.isFailure) diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCaseTest.kt index 2a45bb95..158831c2 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCaseTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/DeleteAfternoteUseCaseTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class DeleteAfternoteUseCaseTest { - private lateinit var repository: AfternoteRepository private lateinit var useCase: DeleteAfternoteUseCase diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCaseTest.kt index fc02780f..9de04b03 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCaseTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/GetAfternoteDetailUseCaseTest.kt @@ -1,9 +1,9 @@ package com.kuit.afternote.feature.afternote.domain.usecase +import com.kuit.afternote.core.domain.model.AfternoteServiceType import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailReceiver -import com.kuit.afternote.core.domain.model.AfternoteServiceType -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import com.kuit.afternote.feature.user.domain.model.ReceiverListItem import com.kuit.afternote.feature.user.domain.usecase.GetReceiversUseCase import com.kuit.afternote.feature.user.domain.usecase.GetUserIdUseCase @@ -17,7 +17,6 @@ import org.junit.Before import org.junit.Test class GetAfternoteDetailUseCaseTest { - private lateinit var repository: AfternoteRepository private lateinit var getReceiversUseCase: GetReceiversUseCase private lateinit var getUserIdUseCase: GetUserIdUseCase @@ -28,11 +27,12 @@ class GetAfternoteDetailUseCaseTest { repository = mockk() getReceiversUseCase = mockk() getUserIdUseCase = mockk() - useCase = GetAfternoteDetailUseCase( - repository = repository, - getReceiversUseCase = getReceiversUseCase, - getUserIdUseCase = getUserIdUseCase - ) + useCase = + GetAfternoteDetailUseCase( + repository = repository, + getReceiversUseCase = getReceiversUseCase, + getUserIdUseCase = getUserIdUseCase, + ) } @Test @@ -48,22 +48,24 @@ class GetAfternoteDetailUseCaseTest { type = AfternoteServiceType.GALLERY_AND_FILES, credentialsId = null, credentialsPassword = null, - receivers = listOf( - AfternoteDetailReceiver( - receiverId = 1L, - name = "", - relation = "", - phone = "" - ) - ), + receivers = + listOf( + AfternoteDetailReceiver( + receiverId = 1L, + name = "", + relation = "", + phone = "", + ), + ), processMethod = "TRANSFER", actions = emptyList(), leaveMessage = null, - playlist = null + playlist = null, + ) + val receiversList = + listOf( + ReceiverListItem(receiverId = 1L, name = "김수신", relation = "친구"), ) - val receiversList = listOf( - ReceiverListItem(receiverId = 1L, name = "김수신", relation = "친구") - ) coEvery { repository.getAfternoteDetail(afternoteId = 10L) } returns Result.success(detail) coEvery { getUserIdUseCase() } returns 100L coEvery { getReceiversUseCase(userId = 100L) } returns Result.success(receiversList) @@ -72,8 +74,22 @@ class GetAfternoteDetailUseCaseTest { assertTrue(result.isSuccess) assertEquals(10L, result.getOrNull()?.id) - assertEquals("김수신", result.getOrNull()?.receivers?.single()?.name) - assertEquals("친구", result.getOrNull()?.receivers?.single()?.relation) + assertEquals( + "김수신", + result + .getOrNull() + ?.receivers + ?.single() + ?.name, + ) + assertEquals( + "친구", + result + .getOrNull() + ?.receivers + ?.single() + ?.relation, + ) coVerify(exactly = 1) { repository.getAfternoteDetail(afternoteId = 10L) } coVerify(exactly = 1) { getUserIdUseCase() } coVerify(exactly = 1) { getReceiversUseCase(userId = 100L) } @@ -105,18 +121,19 @@ class GetAfternoteDetailUseCaseTest { type = AfternoteServiceType.GALLERY_AND_FILES, credentialsId = null, credentialsPassword = null, - receivers = listOf( - AfternoteDetailReceiver( - receiverId = 1L, - name = "", - relation = "", - phone = "" - ) - ), + receivers = + listOf( + AfternoteDetailReceiver( + receiverId = 1L, + name = "", + relation = "", + phone = "", + ), + ), processMethod = "TRANSFER", actions = emptyList(), leaveMessage = null, - playlist = null + playlist = null, ) coEvery { repository.getAfternoteDetail(afternoteId = 10L) } returns Result.success(detail) coEvery { getUserIdUseCase() } returns null @@ -125,7 +142,14 @@ class GetAfternoteDetailUseCaseTest { assertTrue(result.isSuccess) assertEquals(10L, result.getOrNull()?.id) - assertEquals("", result.getOrNull()?.receivers?.single()?.name) + assertEquals( + "", + result + .getOrNull() + ?.receivers + ?.single() + ?.name, + ) coVerify(exactly = 0) { getReceiversUseCase(any()) } } } diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCaseTest.kt index e0078f35..d163e56f 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCaseTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/domain/usecase/UpdateAfternoteUseCaseTest.kt @@ -1,7 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.usecase -import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto -import com.kuit.afternote.feature.afternote.domain.repository.iface.AfternoteRepository +import com.kuit.afternote.feature.afternote.data.dto.AfternoteUpdateRequest +import com.kuit.afternote.feature.afternote.domain.repository.AfternoteRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -12,7 +12,6 @@ import org.junit.Before import org.junit.Test class UpdateAfternoteUseCaseTest { - private lateinit var repository: AfternoteRepository private lateinit var useCase: UpdateAfternoteUseCase @@ -26,7 +25,7 @@ class UpdateAfternoteUseCaseTest { fun invoke_whenSuccess_returnsId() = runTest { val body = - AfternoteUpdateRequestDto( + AfternoteUpdateRequest( category = "SOCIAL", title = "수정된 제목", processMethod = "MEMORIAL", @@ -34,12 +33,12 @@ class UpdateAfternoteUseCaseTest { leaveMessage = "수정된 메시지", credentials = null, receivers = null, - playlist = null + playlist = null, ) coEvery { repository.updateAfternote( afternoteId = 10L, - body = any() + input = any(), ) } returns Result.success(10L) @@ -50,7 +49,7 @@ class UpdateAfternoteUseCaseTest { coVerify(exactly = 1) { repository.updateAfternote( afternoteId = 10L, - body = body + input = body, ) } } @@ -59,7 +58,7 @@ class UpdateAfternoteUseCaseTest { fun invoke_whenFailure_returnsFailure() = runTest { val body = - AfternoteUpdateRequestDto( + AfternoteUpdateRequest( category = "GALLERY", title = "제목", processMethod = null, @@ -67,12 +66,12 @@ class UpdateAfternoteUseCaseTest { leaveMessage = null, credentials = null, receivers = null, - playlist = null + playlist = null, ) coEvery { repository.updateAfternote( afternoteId = 10L, - body = any() + input = any(), ) } returns Result.failure(RuntimeException("404 Not found")) diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailViewModelTest.kt index 99048ba9..04e5b03b 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteDetailViewModelTest.kt @@ -1,10 +1,11 @@ package com.kuit.afternote.feature.afternote.presentation.screen +import com.kuit.afternote.core.domain.model.AfternoteServiceType import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetail import com.kuit.afternote.feature.afternote.domain.model.AfternoteDetailReceiver -import com.kuit.afternote.core.domain.model.AfternoteServiceType import com.kuit.afternote.feature.afternote.domain.usecase.DeleteAfternoteUseCase import com.kuit.afternote.feature.afternote.domain.usecase.GetAfternoteDetailUseCase +import com.kuit.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel import com.kuit.afternote.util.MainCoroutineRule import io.mockk.coEvery import io.mockk.mockk @@ -25,7 +26,6 @@ import retrofit2.Response @OptIn(ExperimentalCoroutinesApi::class) class AfternoteDetailViewModelTest { - @get:Rule val mainRule = MainCoroutineRule() @@ -43,13 +43,14 @@ class AfternoteDetailViewModelTest { type = AfternoteServiceType.SOCIAL_NETWORK, credentialsId = "id", credentialsPassword = "pw", - receivers = listOf( - AfternoteDetailReceiver(name = "수신인", relation = "친구", phone = "010-0000-0000") - ), + receivers = + listOf( + AfternoteDetailReceiver(name = "수신인", relation = "친구", phone = "010-0000-0000"), + ), processMethod = "MEMORIAL", actions = listOf("게시물 내리기"), leaveMessage = "감사했습니다", - playlist = null + playlist = null, ) @Before @@ -83,14 +84,18 @@ class AfternoteDetailViewModelTest { assertFalse(viewModel.uiState.value.isLoading) assertNull(viewModel.uiState.value.detail) - assertTrue(viewModel.uiState.value.error?.contains("Not found") == true) + assertTrue( + viewModel.uiState.value.error + ?.contains("Not found") == true, + ) } @Test fun loadDetail_when400BadRequest_setsError() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Bad request"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":400,"code":400,"message":"Bad request"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(400, errorBody)) coEvery { getDetailUseCase(afternoteId = 10L) } returns Result.failure(httpException) @@ -98,14 +103,18 @@ class AfternoteDetailViewModelTest { advanceUntilIdle() assertFalse(viewModel.uiState.value.isLoading) - assertTrue(viewModel.uiState.value.error?.contains("400") == true) + assertTrue( + viewModel.uiState.value.error + ?.contains("400") == true, + ) } @Test fun loadDetail_when401Unauthorized_setsError() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Unauthorized"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":401,"code":401,"message":"Unauthorized"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(401, errorBody)) coEvery { getDetailUseCase(afternoteId = 10L) } returns Result.failure(httpException) @@ -113,14 +122,18 @@ class AfternoteDetailViewModelTest { advanceUntilIdle() assertFalse(viewModel.uiState.value.isLoading) - assertTrue(viewModel.uiState.value.error?.contains("401") == true) + assertTrue( + viewModel.uiState.value.error + ?.contains("401") == true, + ) } @Test fun loadDetail_when404NotFound_setsError() = runTest { - val errorBody = """{"status":404,"code":404,"message":"Not found"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":404,"code":404,"message":"Not found"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(404, errorBody)) coEvery { getDetailUseCase(afternoteId = 10L) } returns Result.failure(httpException) @@ -128,14 +141,18 @@ class AfternoteDetailViewModelTest { advanceUntilIdle() assertFalse(viewModel.uiState.value.isLoading) - assertTrue(viewModel.uiState.value.error?.contains("404") == true) + assertTrue( + viewModel.uiState.value.error + ?.contains("404") == true, + ) } @Test fun loadDetail_when500ServerError_setsError() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { getDetailUseCase(afternoteId = 10L) } returns Result.failure(httpException) @@ -143,7 +160,10 @@ class AfternoteDetailViewModelTest { advanceUntilIdle() assertFalse(viewModel.uiState.value.isLoading) - assertTrue(viewModel.uiState.value.error?.contains("500") == true) + assertTrue( + viewModel.uiState.value.error + ?.contains("500") == true, + ) } @Test @@ -183,14 +203,18 @@ class AfternoteDetailViewModelTest { assertFalse(viewModel.uiState.value.isDeleting) assertFalse(viewModel.uiState.value.deleteSuccess) - assertTrue(viewModel.uiState.value.deleteError?.contains("Forbidden") == true) + assertTrue( + viewModel.uiState.value.deleteError + ?.contains("Forbidden") == true, + ) } @Test fun deleteAfternote_when404NotFound_setsDeleteError() = runTest { - val errorBody = """{"status":404,"code":404,"message":"Not found"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":404,"code":404,"message":"Not found"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(404, errorBody)) coEvery { deleteUseCase(afternoteId = 10L) } returns Result.failure(httpException) @@ -199,14 +223,18 @@ class AfternoteDetailViewModelTest { assertFalse(viewModel.uiState.value.isDeleting) assertFalse(viewModel.uiState.value.deleteSuccess) - assertTrue(viewModel.uiState.value.deleteError?.contains("404") == true) + assertTrue( + viewModel.uiState.value.deleteError + ?.contains("404") == true, + ) } @Test fun deleteAfternote_when500ServerError_setsDeleteError() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { deleteUseCase(afternoteId = 10L) } returns Result.failure(httpException) @@ -215,7 +243,10 @@ class AfternoteDetailViewModelTest { assertFalse(viewModel.uiState.value.isDeleting) assertFalse(viewModel.uiState.value.deleteSuccess) - assertTrue(viewModel.uiState.value.deleteError?.contains("500") == true) + assertTrue( + viewModel.uiState.value.deleteError + ?.contains("500") == true, + ) } @Test diff --git a/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModelTest.kt index ec02ce2a..a8c755ea 100644 --- a/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModelTest.kt @@ -8,6 +8,8 @@ import com.kuit.afternote.feature.afternote.domain.usecase.GetAfternoteDetailUse import com.kuit.afternote.feature.afternote.domain.usecase.UpdateAfternoteUseCase import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialThumbnailUseCase import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialVideoUseCase +import com.kuit.afternote.feature.afternote.presentation.RegisterAfternotePayload +import com.kuit.afternote.feature.afternote.presentation.edit.AfternoteEditViewModel import com.kuit.afternote.feature.user.domain.usecase.GetReceiversUseCase import com.kuit.afternote.feature.user.domain.usecase.GetUserIdUseCase import com.kuit.afternote.util.MainCoroutineRule @@ -29,7 +31,6 @@ import retrofit2.Response @OptIn(ExperimentalCoroutinesApi::class) class AfternoteEditViewModelTest { - @get:Rule val mainRule = MainCoroutineRule() @@ -54,7 +55,7 @@ class AfternoteEditViewModelTest { accountProcessingMethod = "MEMORIAL", informationProcessingMethod = "", processingMethods = listOf(AfternoteProcessingMethod("1", "게시물 내리기")), - galleryProcessingMethods = emptyList() + galleryProcessingMethods = emptyList(), ) @Before @@ -79,7 +80,7 @@ class AfternoteEditViewModelTest { getReceiversUseCase = getReceiversUseCase, getUserIdUseCase = getUserIdUseCase, uploadMemorialThumbnailUseCase = uploadMemorialThumbnailUseCase, - uploadMemorialVideoUseCase = uploadMemorialVideoUseCase + uploadMemorialVideoUseCase = uploadMemorialVideoUseCase, ) } @@ -94,7 +95,7 @@ class AfternoteEditViewModelTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.success(3L) @@ -103,7 +104,7 @@ class AfternoteEditViewModelTest { category = "소셜네트워크", payload = socialPayload, receivers = emptyList(), - playlistStateHolder = null + playlistStateHolder = null, ) advanceUntilIdle() @@ -119,7 +120,7 @@ class AfternoteEditViewModelTest { coEvery { updateUseCase( afternoteId = any(), - body = any() + body = any(), ) } returns Result.success(10L) @@ -128,7 +129,7 @@ class AfternoteEditViewModelTest { category = "소셜네트워크", payload = socialPayload, receivers = emptyList(), - playlistStateHolder = null + playlistStateHolder = null, ) advanceUntilIdle() @@ -140,8 +141,9 @@ class AfternoteEditViewModelTest { @Test fun saveAfternote_whenCreateSocial400BadRequest_setsError() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Bad request"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":400,"code":400,"message":"Bad request"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(400, errorBody)) coEvery { createSocialUseCase( @@ -151,7 +153,7 @@ class AfternoteEditViewModelTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(httpException) @@ -160,20 +162,24 @@ class AfternoteEditViewModelTest { category = "소셜네트워크", payload = socialPayload, receivers = emptyList(), - playlistStateHolder = null + playlistStateHolder = null, ) advanceUntilIdle() assertFalse(viewModel.saveState.value.isSaving) assertFalse(viewModel.saveState.value.saveSuccess) - assertTrue(viewModel.saveState.value.error?.contains("400") == true) + assertTrue( + viewModel.saveState.value.error + ?.contains("400") == true, + ) } @Test fun saveAfternote_whenCreateSocial401Unauthorized_setsError() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Unauthorized"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":401,"code":401,"message":"Unauthorized"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(401, errorBody)) coEvery { createSocialUseCase( @@ -183,7 +189,7 @@ class AfternoteEditViewModelTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(httpException) @@ -192,20 +198,24 @@ class AfternoteEditViewModelTest { category = "소셜네트워크", payload = socialPayload, receivers = emptyList(), - playlistStateHolder = null + playlistStateHolder = null, ) advanceUntilIdle() assertFalse(viewModel.saveState.value.isSaving) assertFalse(viewModel.saveState.value.saveSuccess) - assertTrue(viewModel.saveState.value.error?.contains("401") == true) + assertTrue( + viewModel.saveState.value.error + ?.contains("401") == true, + ) } @Test fun saveAfternote_whenCreateSocial404NotFound_setsError() = runTest { - val errorBody = """{"status":404,"code":404,"message":"Not found"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":404,"code":404,"message":"Not found"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(404, errorBody)) coEvery { createSocialUseCase( @@ -215,7 +225,7 @@ class AfternoteEditViewModelTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(httpException) @@ -224,20 +234,24 @@ class AfternoteEditViewModelTest { category = "소셜네트워크", payload = socialPayload, receivers = emptyList(), - playlistStateHolder = null + playlistStateHolder = null, ) advanceUntilIdle() assertFalse(viewModel.saveState.value.isSaving) assertFalse(viewModel.saveState.value.saveSuccess) - assertTrue(viewModel.saveState.value.error?.contains("404") == true) + assertTrue( + viewModel.saveState.value.error + ?.contains("404") == true, + ) } @Test fun saveAfternote_whenCreateSocial500ServerError_setsError() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { createSocialUseCase( @@ -247,7 +261,7 @@ class AfternoteEditViewModelTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(httpException) @@ -256,13 +270,16 @@ class AfternoteEditViewModelTest { category = "소셜네트워크", payload = socialPayload, receivers = emptyList(), - playlistStateHolder = null + playlistStateHolder = null, ) advanceUntilIdle() assertFalse(viewModel.saveState.value.isSaving) assertFalse(viewModel.saveState.value.saveSuccess) - assertTrue(viewModel.saveState.value.error?.contains("500") == true) + assertTrue( + viewModel.saveState.value.error + ?.contains("500") == true, + ) } @Test @@ -276,7 +293,7 @@ class AfternoteEditViewModelTest { leaveMessage = any(), credentialsId = any(), credentialsPassword = any(), - receiverIds = any() + receiverIds = any(), ) } returns Result.failure(java.io.IOException("Network unavailable")) @@ -285,7 +302,7 @@ class AfternoteEditViewModelTest { category = "소셜네트워크", payload = socialPayload, receivers = emptyList(), - playlistStateHolder = null + playlistStateHolder = null, ) advanceUntilIdle() diff --git a/app/src/test/java/com/kuit/afternote/feature/auth/api/AuthApiIntegrationTest.kt b/app/src/test/java/com/kuit/afternote/feature/auth/api/AuthApiIntegrationTest.kt index c68150c0..bc46c82d 100644 --- a/app/src/test/java/com/kuit/afternote/feature/auth/api/AuthApiIntegrationTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/auth/api/AuthApiIntegrationTest.kt @@ -60,30 +60,33 @@ class AuthApiIntegrationTest { private const val INVALID_TOKEN = "invalid_token_12345" } - private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - isLenient = true - coerceInputValues = true - } + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + isLenient = true + coerceInputValues = true + } @Before fun setUp() { // 인증 없는 API 클라이언트 - val okHttpClient = OkHttpClient - .Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .addInterceptor( - HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } - ).build() - - val retrofit = Retrofit - .Builder() - .baseUrl(BASE_URL) - .client(okHttpClient) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() + val okHttpClient = + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }, + ).build() + + val retrofit = + Retrofit + .Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() authApi = retrofit.create(AuthApiService::class.java) } @@ -94,38 +97,43 @@ class AuthApiIntegrationTest { private suspend fun loginAndGetTokens() { if (accessToken != null && refreshToken != null) return - val response = authApi.login( - LoginRequest(email = EXISTING_EMAIL, password = CORRECT_PASSWORD) - ) + val response = + authApi.login( + LoginRequest(email = EXISTING_EMAIL, password = CORRECT_PASSWORD), + ) accessToken = response.data?.accessToken refreshToken = response.data?.refreshToken // 인증된 API 클라이언트 생성 - val authInterceptor = Interceptor { chain -> - val request = chain - .request() - .newBuilder() - .addHeader("Authorization", "Bearer $accessToken") - .build() - chain.proceed(request) - } + val authInterceptor = + Interceptor { chain -> + val request = + chain + .request() + .newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + chain.proceed(request) + } + + val authenticatedClient = + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }, + ).build() - val authenticatedClient = OkHttpClient - .Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .addInterceptor(authInterceptor) - .addInterceptor( - HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } - ).build() - - authenticatedApi = Retrofit - .Builder() - .baseUrl(BASE_URL) - .client(authenticatedClient) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() - .create(AuthApiService::class.java) + authenticatedApi = + Retrofit + .Builder() + .baseUrl(BASE_URL) + .client(authenticatedClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(AuthApiService::class.java) } // ========== Login API Tests ========== @@ -133,9 +141,10 @@ class AuthApiIntegrationTest { @Test fun login_withCorrectCredentials_returns200() = runBlocking { - val response = authApi.login( - LoginRequest(email = EXISTING_EMAIL, password = CORRECT_PASSWORD) - ) + val response = + authApi.login( + LoginRequest(email = EXISTING_EMAIL, password = CORRECT_PASSWORD), + ) // 성공 시 200 OK, data에 토큰이 포함됨 assertEquals(200, response.status) @@ -146,7 +155,7 @@ class AuthApiIntegrationTest { runBlocking { try { authApi.login( - LoginRequest(email = EXISTING_EMAIL, password = WRONG_PASSWORD) + LoginRequest(email = EXISTING_EMAIL, password = WRONG_PASSWORD), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -164,7 +173,7 @@ class AuthApiIntegrationTest { runBlocking { try { authApi.login( - LoginRequest(email = NON_EXISTENT_EMAIL, password = WRONG_PASSWORD) + LoginRequest(email = NON_EXISTENT_EMAIL, password = WRONG_PASSWORD), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -183,7 +192,7 @@ class AuthApiIntegrationTest { runBlocking { try { authApi.sendEmailCode( - SendEmailCodeRequest(email = "invalid-email-format") + SendEmailCodeRequest(email = "invalid-email-format"), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -197,7 +206,7 @@ class AuthApiIntegrationTest { runBlocking { try { authApi.verifyEmail( - VerifyEmailRequest(email = EXISTING_EMAIL, certificateCode = "000000") + VerifyEmailRequest(email = EXISTING_EMAIL, certificateCode = "000000"), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -220,8 +229,8 @@ class AuthApiIntegrationTest { email = EXISTING_EMAIL, password = "TestPassword123!", name = "TestName", - profileUrl = null - ) + profileUrl = null, + ), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -240,8 +249,8 @@ class AuthApiIntegrationTest { email = "newuser_${System.currentTimeMillis()}@test.com", password = "weak", // 약한 비밀번호 name = "TestName", - profileUrl = null - ) + profileUrl = null, + ), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -300,7 +309,7 @@ class AuthApiIntegrationTest { // 401 또는 400 중 하나여야 함 assertTrue( "Expected 400 or 401 but got ${e.code()}", - e.code() == 400 || e.code() == 401 + e.code() == 400 || e.code() == 401, ) } } @@ -329,39 +338,44 @@ class AuthApiIntegrationTest { fun logout_withValidRefreshToken_returnsExpectedStatusCode() = runBlocking { // 새로운 로그인을 통해 fresh 토큰 획득 - val loginResponse = authApi.login( - LoginRequest(email = EXISTING_EMAIL, password = CORRECT_PASSWORD) - ) + val loginResponse = + authApi.login( + LoginRequest(email = EXISTING_EMAIL, password = CORRECT_PASSWORD), + ) val freshRefreshToken = loginResponse.data?.refreshToken val freshAccessToken = loginResponse.data?.accessToken assertNotNull("RefreshToken should not be null", freshRefreshToken) // Authorization 헤더가 있는 클라이언트로 로그아웃 시도 - val authInterceptor = Interceptor { chain -> - val request = chain - .request() - .newBuilder() - .addHeader("Authorization", "Bearer $freshAccessToken") - .build() - chain.proceed(request) - } - - val authenticatedClient = OkHttpClient - .Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .addInterceptor(authInterceptor) - .addInterceptor( - HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } - ).build() + val authInterceptor = + Interceptor { chain -> + val request = + chain + .request() + .newBuilder() + .addHeader("Authorization", "Bearer $freshAccessToken") + .build() + chain.proceed(request) + } - val authenticatedApi = Retrofit - .Builder() - .baseUrl(BASE_URL) - .client(authenticatedClient) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() - .create(AuthApiService::class.java) + val authenticatedClient = + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }, + ).build() + + val authenticatedApi = + Retrofit + .Builder() + .baseUrl(BASE_URL) + .client(authenticatedClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(AuthApiService::class.java) try { val response = authenticatedApi.logout(LogoutRequest(refreshToken = freshRefreshToken!!)) @@ -372,7 +386,7 @@ class AuthApiIntegrationTest { // API가 401을 반환하면 기록 (Authorization 헤더가 있어도) assertTrue( "Expected 200 or 401 but got ${e.code()}", - e.code() == 401 || e.code() == 200 + e.code() == 401 || e.code() == 200, ) } @@ -392,7 +406,7 @@ class AuthApiIntegrationTest { // 401 또는 400 중 하나여야 함 assertTrue( "Expected 400 or 401 but got ${e.code()}", - e.code() == 400 || e.code() == 401 + e.code() == 400 || e.code() == 401, ) } } @@ -409,7 +423,7 @@ class AuthApiIntegrationTest { } } - // ========== Password Change API Tests ========== + // ========== password Change API Tests ========== @Test fun passwordChange_withCorrectCurrentPassword_returns200() = @@ -419,21 +433,23 @@ class AuthApiIntegrationTest { // 비밀번호 변경 후 다시 원래대로 변경 (테스트 계정 보호) val newPassword = "NewPassword123!" - val response = authenticatedApi.passwordChange( - PasswordChangeRequest( - currentPassword = CORRECT_PASSWORD, - newPassword = newPassword + val response = + authenticatedApi.passwordChange( + PasswordChangeRequest( + currentPassword = CORRECT_PASSWORD, + newPassword = newPassword, + ), ) - ) assertEquals(200, response.status) // 원래 비밀번호로 복구 - val restoreResponse = authenticatedApi.passwordChange( - PasswordChangeRequest( - currentPassword = newPassword, - newPassword = CORRECT_PASSWORD + val restoreResponse = + authenticatedApi.passwordChange( + PasswordChangeRequest( + currentPassword = newPassword, + newPassword = CORRECT_PASSWORD, + ), ) - ) assertEquals(200, restoreResponse.status) // 토큰 초기화 @@ -450,8 +466,8 @@ class AuthApiIntegrationTest { authenticatedApi.passwordChange( PasswordChangeRequest( currentPassword = WRONG_PASSWORD, - newPassword = "NewPassword123!" - ) + newPassword = "NewPassword123!", + ), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -459,7 +475,7 @@ class AuthApiIntegrationTest { // 400 또는 401 중 하나여야 함 assertTrue( "Expected 400 or 401 but got ${e.code()}", - e.code() == 400 || e.code() == 401 + e.code() == 400 || e.code() == 401, ) } } @@ -473,8 +489,8 @@ class AuthApiIntegrationTest { authenticatedApi.passwordChange( PasswordChangeRequest( currentPassword = CORRECT_PASSWORD, - newPassword = "weak" // 약한 비밀번호 - ) + newPassword = "weak", // 약한 비밀번호 + ), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -491,8 +507,8 @@ class AuthApiIntegrationTest { authApi.passwordChange( PasswordChangeRequest( currentPassword = CORRECT_PASSWORD, - newPassword = "NewPassword123!" - ) + newPassword = "NewPassword123!", + ), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -522,7 +538,7 @@ class AuthApiIntegrationTest { runBlocking { try { authApi.verifyEmail( - VerifyEmailRequest(email = NON_EXISTENT_EMAIL, certificateCode = "123456") + VerifyEmailRequest(email = NON_EXISTENT_EMAIL, certificateCode = "123456"), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -530,7 +546,7 @@ class AuthApiIntegrationTest { // 400 또는 404 중 하나여야 함 assertTrue( "Expected 400 or 404 but got ${e.code()}", - e.code() == 400 || e.code() == 404 + e.code() == 400 || e.code() == 404, ) } } @@ -540,7 +556,7 @@ class AuthApiIntegrationTest { runBlocking { try { authApi.verifyEmail( - VerifyEmailRequest(email = EXISTING_EMAIL, certificateCode = "") + VerifyEmailRequest(email = EXISTING_EMAIL, certificateCode = ""), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -560,8 +576,8 @@ class AuthApiIntegrationTest { email = "invalid-email-format", password = "ValidPassword123!", name = "TestName", - profileUrl = null - ) + profileUrl = null, + ), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -579,8 +595,8 @@ class AuthApiIntegrationTest { email = "newuser_${System.currentTimeMillis()}@test.com", password = "ValidPassword123!", name = "", - profileUrl = null - ) + profileUrl = null, + ), ) throw AssertionError("Expected HttpException but got success response") } catch (e: HttpException) { @@ -721,7 +737,7 @@ class AuthApiIntegrationTest { results["logout_empty_token"] = e.code() } - // ===== Password Change ===== + // ===== password Change ===== try { authApi.passwordChange(PasswordChangeRequest(CORRECT_PASSWORD, "NewPass123!")) results["password_change_no_auth"] = "200 (unexpected)" diff --git a/app/src/test/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImplTest.kt b/app/src/test/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImplTest.kt index 613ecfd4..5f2310e6 100644 --- a/app/src/test/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImplTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/auth/data/repository/AuthRepositoryImplTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.auth.data.repository -import com.kuit.afternote.data.remote.ApiResponse +import com.kuit.afternote.data.dto.response.BaseResponse import com.kuit.afternote.feature.auth.data.api.AuthApiService import com.kuit.afternote.feature.auth.data.dto.LoginData import com.kuit.afternote.feature.auth.data.dto.LoginRequest @@ -23,7 +23,6 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import okhttp3.MediaType.Companion.toMediaType import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals @@ -49,7 +48,7 @@ class AuthRepositoryImplTest { @Test fun sendEmailCode_whenSuccess_returnsUnit() = runTest { - coEvery { api.sendEmailCode(any()) } returns ApiResponse(200, 200, "Success", null) + coEvery { api.sendEmailCode(any()) } returns BaseResponse(200, 200, "Success", null) val result = repository.sendEmailCode("test@example.com") @@ -60,14 +59,16 @@ class AuthRepositoryImplTest { @Test fun verifyEmail_whenSuccess_returnsEmailVerifyResult() = runTest { - val response = ApiResponse( - status = 200, - code = 200, - message = "Success", - data = buildJsonObject { - put("isVerified", JsonPrimitive(true)) - } - ) + val response = + BaseResponse( + status = 200, + code = 200, + message = "Success", + data = + buildJsonObject { + put("isVerified", JsonPrimitive(true)) + }, + ) coEvery { api.verifyEmail(any()) } returns response val result = repository.verifyEmail("test@example.com", "123456") @@ -80,7 +81,7 @@ class AuthRepositoryImplTest { @Test fun verifyEmail_whenSuccessWithNullData_returnsEmailVerifyResultDefaultTrue() = runTest { - val response = ApiResponse(200, 200, "Success", null) + val response = BaseResponse(200, 200, "Success", null) coEvery { api.verifyEmail(any()) } returns response val result = repository.verifyEmail("test@example.com", "123456") @@ -92,12 +93,13 @@ class AuthRepositoryImplTest { @Test fun signUp_whenSuccess_returnsSignUpResult() = runTest { - val response = ApiResponse( - status = 200, - code = 200, - message = "Success", - data = SignUpData(userId = 1L, email = "test@example.com") - ) + val response = + BaseResponse( + status = 200, + code = 200, + message = "Success", + data = SignUpData(userId = 1L, email = "test@example.com"), + ) coEvery { api.signUp(any()) } returns response val result = repository.signUp("test@example.com", "password123!", "홍길동", null) @@ -112,12 +114,13 @@ class AuthRepositoryImplTest { @Test fun login_whenSuccess_returnsLoginResult() = runTest { - val response = ApiResponse( - status = 200, - code = 200, - message = "Success", - data = LoginData(accessToken = "at", refreshToken = "rt") - ) + val response = + BaseResponse( + status = 200, + code = 200, + message = "Success", + data = LoginData(accessToken = "at", refreshToken = "rt"), + ) coEvery { api.login(any()) } returns response val result = repository.login("test@example.com", "password123!") @@ -130,12 +133,13 @@ class AuthRepositoryImplTest { @Test fun reissue_whenSuccess_returnsReissueResult() = runTest { - val response = ApiResponse( - status = 200, - code = 200, - message = "Success", - data = ReissueData(accessToken = "new_at", refreshToken = "new_rt") - ) + val response = + BaseResponse( + status = 200, + code = 200, + message = "Success", + data = ReissueData(accessToken = "new_at", refreshToken = "new_rt"), + ) coEvery { api.reissue(any()) } returns response val result = repository.reissue("old_refresh_token") @@ -148,7 +152,7 @@ class AuthRepositoryImplTest { @Test fun logout_whenSuccess_returnsUnit() = runTest { - coEvery { api.logout(any()) } returns ApiResponse(200, 200, "Success", null) + coEvery { api.logout(any()) } returns BaseResponse(200, 200, "Success", null) val result = repository.logout("refresh_token") @@ -159,7 +163,7 @@ class AuthRepositoryImplTest { @Test fun passwordChange_whenSuccess_returnsUnit() = runTest { - coEvery { api.passwordChange(any()) } returns ApiResponse(200, 200, "Success", null) + coEvery { api.passwordChange(any()) } returns BaseResponse(200, 200, "Success", null) val result = repository.passwordChange("oldPwd", "newPwd") @@ -172,11 +176,13 @@ class AuthRepositoryImplTest { @Test fun login_when404NotFound_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":404,"code":404,"message":"User not found"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.login(any()) } throws HttpException( - Response.error(404, errorBody) - ) + val errorBody = + """{"status":404,"code":404,"message":"User not found"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.login(any()) } throws + HttpException( + Response.error(404, errorBody), + ) val result = repository.login("nonexistent@example.com", "password123!") @@ -189,11 +195,13 @@ class AuthRepositoryImplTest { @Test fun login_when401Unauthorized_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Invalid credentials"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.login(any()) } throws HttpException( - Response.error(401, errorBody) - ) + val errorBody = + """{"status":401,"code":401,"message":"Invalid credentials"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.login(any()) } throws + HttpException( + Response.error(401, errorBody), + ) val result = repository.login("test@example.com", "wrongPassword") @@ -206,11 +214,13 @@ class AuthRepositoryImplTest { @Test fun login_when400BadRequest_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid email format"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.login(any()) } throws HttpException( - Response.error(400, errorBody) - ) + val errorBody = + """{"status":400,"code":400,"message":"Invalid email format"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.login(any()) } throws + HttpException( + Response.error(400, errorBody), + ) val result = repository.login("invalid-email", "password123!") @@ -223,11 +233,13 @@ class AuthRepositoryImplTest { @Test fun login_when500ServerError_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.login(any()) } throws HttpException( - Response.error(500, errorBody) - ) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.login(any()) } throws + HttpException( + Response.error(500, errorBody), + ) val result = repository.login("test@example.com", "password123!") @@ -240,11 +252,13 @@ class AuthRepositoryImplTest { @Test fun signUp_when409Conflict_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":409,"code":409,"message":"Email already exists"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.signUp(any()) } throws HttpException( - Response.error(409, errorBody) - ) + val errorBody = + """{"status":409,"code":409,"message":"Email already exists"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.signUp(any()) } throws + HttpException( + Response.error(409, errorBody), + ) val result = repository.signUp("existing@example.com", "password123!", "홍길동", null) @@ -257,11 +271,13 @@ class AuthRepositoryImplTest { @Test fun sendEmailCode_when429TooManyRequests_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":429,"code":429,"message":"Too many requests"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.sendEmailCode(any()) } throws HttpException( - Response.error(429, errorBody) - ) + val errorBody = + """{"status":429,"code":429,"message":"Too many requests"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.sendEmailCode(any()) } throws + HttpException( + Response.error(429, errorBody), + ) val result = repository.sendEmailCode("test@example.com") @@ -274,11 +290,13 @@ class AuthRepositoryImplTest { @Test fun verifyEmail_when400InvalidCode_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid verification code"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.verifyEmail(any()) } throws HttpException( - Response.error(400, errorBody) - ) + val errorBody = + """{"status":400,"code":400,"message":"Invalid verification code"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.verifyEmail(any()) } throws + HttpException( + Response.error(400, errorBody), + ) val result = repository.verifyEmail("test@example.com", "000000") @@ -291,11 +309,13 @@ class AuthRepositoryImplTest { @Test fun reissue_when401ExpiredToken_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Refresh token expired"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.reissue(any()) } throws HttpException( - Response.error(401, errorBody) - ) + val errorBody = + """{"status":401,"code":401,"message":"Refresh token expired"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.reissue(any()) } throws + HttpException( + Response.error(401, errorBody), + ) val result = repository.reissue("expired_refresh_token") @@ -308,11 +328,13 @@ class AuthRepositoryImplTest { @Test fun passwordChange_when401Unauthorized_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Current password is incorrect"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.passwordChange(any()) } throws HttpException( - Response.error(401, errorBody) - ) + val errorBody = + """{"status":401,"code":401,"message":"Current password is incorrect"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.passwordChange(any()) } throws + HttpException( + Response.error(401, errorBody), + ) val result = repository.passwordChange("wrongOldPwd", "newPwd") @@ -325,11 +347,13 @@ class AuthRepositoryImplTest { @Test fun logout_when401InvalidToken_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Invalid refresh token"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.logout(any()) } throws HttpException( - Response.error(401, errorBody) - ) + val errorBody = + """{"status":401,"code":401,"message":"Invalid refresh token"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.logout(any()) } throws + HttpException( + Response.error(401, errorBody), + ) val result = repository.logout("invalid_refresh_token") @@ -344,11 +368,13 @@ class AuthRepositoryImplTest { @Test fun signUp_when400BadRequest_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid password format"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.signUp(any()) } throws HttpException( - Response.error(400, errorBody) - ) + val errorBody = + """{"status":400,"code":400,"message":"Invalid password format"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.signUp(any()) } throws + HttpException( + Response.error(400, errorBody), + ) val result = repository.signUp("test@example.com", "weak", "홍길동", null) @@ -361,11 +387,13 @@ class AuthRepositoryImplTest { @Test fun signUp_when500ServerError_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.signUp(any()) } throws HttpException( - Response.error(500, errorBody) - ) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.signUp(any()) } throws + HttpException( + Response.error(500, errorBody), + ) val result = repository.signUp("test@example.com", "password123!", "홍길동", null) @@ -380,11 +408,13 @@ class AuthRepositoryImplTest { @Test fun sendEmailCode_when400BadRequest_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid email format"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.sendEmailCode(any()) } throws HttpException( - Response.error(400, errorBody) - ) + val errorBody = + """{"status":400,"code":400,"message":"Invalid email format"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.sendEmailCode(any()) } throws + HttpException( + Response.error(400, errorBody), + ) val result = repository.sendEmailCode("invalid-email") @@ -397,11 +427,13 @@ class AuthRepositoryImplTest { @Test fun sendEmailCode_when500ServerError_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Failed to send email"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.sendEmailCode(any()) } throws HttpException( - Response.error(500, errorBody) - ) + val errorBody = + """{"status":500,"code":500,"message":"Failed to send email"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.sendEmailCode(any()) } throws + HttpException( + Response.error(500, errorBody), + ) val result = repository.sendEmailCode("test@example.com") @@ -416,11 +448,13 @@ class AuthRepositoryImplTest { @Test fun verifyEmail_when401CodeExpired_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Verification code expired"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.verifyEmail(any()) } throws HttpException( - Response.error(401, errorBody) - ) + val errorBody = + """{"status":401,"code":401,"message":"Verification code expired"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.verifyEmail(any()) } throws + HttpException( + Response.error(401, errorBody), + ) val result = repository.verifyEmail("test@example.com", "123456") @@ -433,11 +467,13 @@ class AuthRepositoryImplTest { @Test fun verifyEmail_when404EmailNotFound_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":404,"code":404,"message":"Email not found"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.verifyEmail(any()) } throws HttpException( - Response.error(404, errorBody) - ) + val errorBody = + """{"status":404,"code":404,"message":"Email not found"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.verifyEmail(any()) } throws + HttpException( + Response.error(404, errorBody), + ) val result = repository.verifyEmail("nonexistent@example.com", "123456") @@ -450,11 +486,13 @@ class AuthRepositoryImplTest { @Test fun verifyEmail_when500ServerError_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.verifyEmail(any()) } throws HttpException( - Response.error(500, errorBody) - ) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.verifyEmail(any()) } throws + HttpException( + Response.error(500, errorBody), + ) val result = repository.verifyEmail("test@example.com", "123456") @@ -469,11 +507,13 @@ class AuthRepositoryImplTest { @Test fun reissue_when400InvalidToken_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid token format"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.reissue(any()) } throws HttpException( - Response.error(400, errorBody) - ) + val errorBody = + """{"status":400,"code":400,"message":"Invalid token format"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.reissue(any()) } throws + HttpException( + Response.error(400, errorBody), + ) val result = repository.reissue("invalid_format_token") @@ -486,11 +526,13 @@ class AuthRepositoryImplTest { @Test fun reissue_when500ServerError_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.reissue(any()) } throws HttpException( - Response.error(500, errorBody) - ) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.reissue(any()) } throws + HttpException( + Response.error(500, errorBody), + ) val result = repository.reissue("valid_refresh_token") @@ -505,11 +547,13 @@ class AuthRepositoryImplTest { @Test fun logout_when400InvalidToken_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid token format"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.logout(any()) } throws HttpException( - Response.error(400, errorBody) - ) + val errorBody = + """{"status":400,"code":400,"message":"Invalid token format"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.logout(any()) } throws + HttpException( + Response.error(400, errorBody), + ) val result = repository.logout("invalid_format_token") @@ -522,11 +566,13 @@ class AuthRepositoryImplTest { @Test fun logout_when500ServerError_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.logout(any()) } throws HttpException( - Response.error(500, errorBody) - ) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.logout(any()) } throws + HttpException( + Response.error(500, errorBody), + ) val result = repository.logout("refresh_token") @@ -541,11 +587,13 @@ class AuthRepositoryImplTest { @Test fun passwordChange_when400BadRequest_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid password format"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.passwordChange(any()) } throws HttpException( - Response.error(400, errorBody) - ) + val errorBody = + """{"status":400,"code":400,"message":"Invalid password format"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.passwordChange(any()) } throws + HttpException( + Response.error(400, errorBody), + ) val result = repository.passwordChange("oldPwd", "weak") @@ -558,11 +606,13 @@ class AuthRepositoryImplTest { @Test fun passwordChange_when404UserNotFound_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":404,"code":404,"message":"User not found"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.passwordChange(any()) } throws HttpException( - Response.error(404, errorBody) - ) + val errorBody = + """{"status":404,"code":404,"message":"User not found"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.passwordChange(any()) } throws + HttpException( + Response.error(404, errorBody), + ) val result = repository.passwordChange("oldPwd", "newPwd123!") @@ -575,11 +625,13 @@ class AuthRepositoryImplTest { @Test fun passwordChange_when500ServerError_returnsFailureWithHttpException() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) - coEvery { api.passwordChange(any()) } throws HttpException( - Response.error(500, errorBody) - ) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) + coEvery { api.passwordChange(any()) } throws + HttpException( + Response.error(500, errorBody), + ) val result = repository.passwordChange("oldPwd", "newPwd123!") diff --git a/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModelTest.kt index 3a6083d1..12b626e2 100644 --- a/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LoginViewModelTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.onboarding.presentation.viewmodel -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.auth.domain.model.LoginResult import com.kuit.afternote.feature.auth.domain.usecase.KakaoLoginUseCase import com.kuit.afternote.feature.auth.domain.usecase.LoginUseCase @@ -114,8 +114,9 @@ class LoginViewModelTest { @Test fun login_when404NotFound_setsErrorMessage() = runTest { - val errorBody = """{"status":404,"code":404,"message":"User not found"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":404,"code":404,"message":"User not found"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(404, errorBody)) coEvery { loginUseCase(any(), any()) } returns Result.failure(httpException) @@ -124,7 +125,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("404") == true + ?.contains("404") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) assertFalse(viewModel.uiState.value.isLoading) @@ -133,8 +134,9 @@ class LoginViewModelTest { @Test fun login_when401Unauthorized_setsErrorMessage() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Invalid credentials"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":401,"code":401,"message":"Invalid credentials"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(401, errorBody)) coEvery { loginUseCase(any(), any()) } returns Result.failure(httpException) @@ -143,7 +145,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("401") == true + ?.contains("401") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) } @@ -151,8 +153,9 @@ class LoginViewModelTest { @Test fun login_when400BadRequest_setsErrorMessage() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid email format"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":400,"code":400,"message":"Invalid email format"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(400, errorBody)) coEvery { loginUseCase(any(), any()) } returns Result.failure(httpException) @@ -161,7 +164,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("400") == true + ?.contains("400") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) } @@ -169,8 +172,9 @@ class LoginViewModelTest { @Test fun login_when500ServerError_setsErrorMessage() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { loginUseCase(any(), any()) } returns Result.failure(httpException) @@ -179,7 +183,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("500") == true + ?.contains("500") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) } @@ -189,9 +193,10 @@ class LoginViewModelTest { @Test fun login_whenNetworkError_setsErrorMessage() = runTest { - coEvery { loginUseCase(any(), any()) } returns Result.failure( - java.io.IOException("Network unavailable") - ) + coEvery { loginUseCase(any(), any()) } returns + Result.failure( + java.io.IOException("Network unavailable"), + ) viewModel.login("test@example.com", "password123!") advanceUntilIdle() @@ -213,9 +218,10 @@ class LoginViewModelTest { @Test fun kakaoLogin_whenSuccess_setsLoginSuccess() = runTest { - coEvery { kakaoLoginUseCase(any()) } returns Result.success( - LoginResult(accessToken = "at", refreshToken = "rt") - ) + coEvery { kakaoLoginUseCase(any()) } returns + Result.success( + LoginResult(accessToken = "at", refreshToken = "rt"), + ) viewModel.kakaoLogin("kakaoAccessToken") advanceUntilIdle() @@ -228,8 +234,9 @@ class LoginViewModelTest { @Test fun kakaoLogin_when400BadRequest_setsErrorMessage() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid access token"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":400,"code":400,"message":"Invalid access token"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(400, errorBody)) coEvery { kakaoLoginUseCase(any()) } returns Result.failure(httpException) @@ -238,7 +245,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("400") == true + ?.contains("400") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) assertFalse(viewModel.uiState.value.isLoading) @@ -247,8 +254,9 @@ class LoginViewModelTest { @Test fun kakaoLogin_when401Unauthorized_setsErrorMessage() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Unauthorized"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":401,"code":401,"message":"Unauthorized"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(401, errorBody)) coEvery { kakaoLoginUseCase(any()) } returns Result.failure(httpException) @@ -257,7 +265,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("401") == true + ?.contains("401") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) assertFalse(viewModel.uiState.value.isLoading) @@ -266,8 +274,9 @@ class LoginViewModelTest { @Test fun kakaoLogin_when404NotFound_setsErrorMessage() = runTest { - val errorBody = """{"status":404,"code":404,"message":"Not found"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":404,"code":404,"message":"Not found"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(404, errorBody)) coEvery { kakaoLoginUseCase(any()) } returns Result.failure(httpException) @@ -276,7 +285,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("404") == true + ?.contains("404") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) assertFalse(viewModel.uiState.value.isLoading) @@ -285,8 +294,9 @@ class LoginViewModelTest { @Test fun kakaoLogin_when500ServerError_setsErrorMessage() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { kakaoLoginUseCase(any()) } returns Result.failure(httpException) @@ -295,7 +305,7 @@ class LoginViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("500") == true + ?.contains("500") == true, ) assertFalse(viewModel.uiState.value.loginSuccess) assertFalse(viewModel.uiState.value.isLoading) @@ -304,9 +314,10 @@ class LoginViewModelTest { @Test fun kakaoLogin_whenNetworkError_setsErrorMessage() = runTest { - coEvery { kakaoLoginUseCase(any()) } returns Result.failure( - java.io.IOException("Network unavailable") - ) + coEvery { kakaoLoginUseCase(any()) } returns + Result.failure( + java.io.IOException("Network unavailable"), + ) viewModel.kakaoLogin("token") advanceUntilIdle() diff --git a/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModelTest.kt index 71b55544..436eed5c 100644 --- a/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/LogoutViewModelTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.onboarding.presentation.viewmodel -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.auth.domain.usecase.LogoutUseCase import com.kuit.afternote.util.MainCoroutineRule import io.mockk.coEvery @@ -110,8 +110,9 @@ class LogoutViewModelTest { @Test fun logout_when401Unauthorized_stillClearsTokensAndSucceeds() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Invalid token"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":401,"code":401,"message":"Invalid token"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(401, errorBody)) coEvery { logoutUseCase(any()) } returns Result.failure(httpException) @@ -126,8 +127,9 @@ class LogoutViewModelTest { @Test fun logout_when500ServerError_stillClearsTokensAndSucceeds() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { logoutUseCase(any()) } returns Result.failure(httpException) @@ -143,9 +145,10 @@ class LogoutViewModelTest { @Test fun logout_whenNetworkError_stillClearsTokensAndSucceeds() = runTest { - coEvery { logoutUseCase(any()) } returns Result.failure( - java.io.IOException("Network unavailable") - ) + coEvery { logoutUseCase(any()) } returns + Result.failure( + java.io.IOException("Network unavailable"), + ) viewModel.logout() advanceUntilIdle() diff --git a/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModelTest.kt index 29a9a24b..2724af22 100644 --- a/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/onboarding/presentation/viewmodel/SignUpViewModelTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.onboarding.presentation.viewmodel -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.auth.domain.model.LoginResult import com.kuit.afternote.feature.auth.domain.model.SignUpResult import com.kuit.afternote.feature.auth.domain.usecase.LoginUseCase @@ -71,9 +71,10 @@ class SignUpViewModelTest { fun signUp_whenSuccess_thenLoginSuccess_setsSignUpSuccess() = runTest { coEvery { signUpUseCase(any(), any(), any(), any()) } returns Result.success(SignUpResult(1L, "a@b.com")) - coEvery { loginUseCase(any(), any()) } returns Result.success( - LoginResult(accessToken = "access", refreshToken = "refresh") - ) + coEvery { loginUseCase(any(), any()) } returns + Result.success( + LoginResult(accessToken = "access", refreshToken = "refresh"), + ) coJustRun { tokenManager.saveTokens(any(), any(), any()) } viewModel.signUp("a@b.com", "pwd1!", "name", null) @@ -88,9 +89,10 @@ class SignUpViewModelTest { fun signUp_withProfileUrl_callsUseCaseWithProfileUrl() = runTest { coEvery { signUpUseCase(any(), any(), any(), any()) } returns Result.success(SignUpResult(2L, "b@c.com")) - coEvery { loginUseCase(any(), any()) } returns Result.success( - LoginResult(accessToken = "a", refreshToken = "r") - ) + coEvery { loginUseCase(any(), any()) } returns + Result.success( + LoginResult(accessToken = "a", refreshToken = "r"), + ) coJustRun { tokenManager.saveTokens(any(), any(), any()) } viewModel.signUp("b@c.com", "pwd2!", "nick", "https://img/p.jpg") @@ -116,9 +118,10 @@ class SignUpViewModelTest { fun clearSignUpSuccess_resetsSignUpSuccess() = runTest { coEvery { signUpUseCase(any(), any(), any(), any()) } returns Result.success(SignUpResult(1L, "a@b.com")) - coEvery { loginUseCase(any(), any()) } returns Result.success( - LoginResult(accessToken = "a", refreshToken = "r") - ) + coEvery { loginUseCase(any(), any()) } returns + Result.success( + LoginResult(accessToken = "a", refreshToken = "r"), + ) coJustRun { tokenManager.saveTokens(any(), any(), any()) } viewModel.signUp("a@b.com", "pwd", "n", null) advanceUntilIdle() @@ -144,8 +147,9 @@ class SignUpViewModelTest { @Test fun signUp_when400BadRequest_setsErrorMessage() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Invalid email format"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":400,"code":400,"message":"Invalid email format"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(400, errorBody)) coEvery { signUpUseCase(any(), any(), any(), any()) } returns Result.failure(httpException) @@ -154,7 +158,7 @@ class SignUpViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("400") == true + ?.contains("400") == true, ) assertFalse(viewModel.uiState.value.signUpSuccess) assertFalse(viewModel.uiState.value.isLoading) @@ -163,8 +167,9 @@ class SignUpViewModelTest { @Test fun signUp_when409Conflict_setsErrorMessage() = runTest { - val errorBody = """{"status":409,"code":409,"message":"Email already exists"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":409,"code":409,"message":"Email already exists"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(409, errorBody)) coEvery { signUpUseCase(any(), any(), any(), any()) } returns Result.failure(httpException) @@ -173,7 +178,7 @@ class SignUpViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("409") == true + ?.contains("409") == true, ) assertFalse(viewModel.uiState.value.signUpSuccess) } @@ -181,8 +186,9 @@ class SignUpViewModelTest { @Test fun signUp_when500ServerError_setsErrorMessage() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Internal server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Internal server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { signUpUseCase(any(), any(), any(), any()) } returns Result.failure(httpException) @@ -191,7 +197,7 @@ class SignUpViewModelTest { assertTrue( viewModel.uiState.value.errorMessage - ?.contains("500") == true + ?.contains("500") == true, ) assertFalse(viewModel.uiState.value.signUpSuccess) } @@ -201,9 +207,10 @@ class SignUpViewModelTest { @Test fun signUp_whenNetworkError_setsErrorMessage() = runTest { - coEvery { signUpUseCase(any(), any(), any(), any()) } returns Result.failure( - java.io.IOException("Network unavailable") - ) + coEvery { signUpUseCase(any(), any(), any(), any()) } returns + Result.failure( + java.io.IOException("Network unavailable"), + ) viewModel.signUp("a@b.com", "pwd1!", "name", null) advanceUntilIdle() diff --git a/app/src/test/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModelTest.kt index 6c92911c..f4dd9317 100644 --- a/app/src/test/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModelTest.kt @@ -27,7 +27,6 @@ import java.io.IOException */ @OptIn(ExperimentalCoroutinesApi::class) class VerifySelfViewModelTest { - @get:Rule val mainRule = MainCoroutineRule() @@ -43,12 +42,13 @@ class VerifySelfViewModelTest { uploadReceiverDocumentUseCase = mockk() submitDeliveryVerificationUseCase = mockk() getDeliveryVerificationStatusUseCase = mockk() - viewModel = VerifySelfViewModel( - verifyReceiverAuthUseCase = verifyReceiverAuthUseCase, - uploadReceiverDocumentUseCase = uploadReceiverDocumentUseCase, - submitDeliveryVerificationUseCase = submitDeliveryVerificationUseCase, - getDeliveryVerificationStatusUseCase = getDeliveryVerificationStatusUseCase - ) + viewModel = + VerifySelfViewModel( + verifyReceiverAuthUseCase = verifyReceiverAuthUseCase, + uploadReceiverDocumentUseCase = uploadReceiverDocumentUseCase, + submitDeliveryVerificationUseCase = submitDeliveryVerificationUseCase, + getDeliveryVerificationStatusUseCase = getDeliveryVerificationStatusUseCase, + ) } @Test @@ -61,47 +61,54 @@ class VerifySelfViewModelTest { } @Test - fun verifyMasterKey_whenSuccess_advancesToUploadPdfStep() = runTest { - coEvery { verifyReceiverAuthUseCase(any()) } returns Result.success( - ReceiverAuthVerifyResult(receiverId = 1L) - ) - viewModel.updateMasterKey("valid-key") - viewModel.verifyMasterKey() - advanceUntilIdle() - - assertEquals(VerifyStep.UPLOAD_PDF_AUTH, viewModel.uiState.value.currentStep) - assertNull(viewModel.uiState.value.verifyError) - assertFalse(viewModel.uiState.value.isLoading) - } + fun verifyMasterKey_whenSuccess_advancesToUploadPdfStep() = + runTest { + coEvery { verifyReceiverAuthUseCase(any()) } returns + Result.success( + ReceiverAuthVerifyResult(receiverId = 1L), + ) + viewModel.updateMasterKey("valid-key") + viewModel.verifyMasterKey() + advanceUntilIdle() + + assertEquals(VerifyStep.UPLOAD_PDF_AUTH, viewModel.uiState.value.currentStep) + assertNull(viewModel.uiState.value.verifyError) + assertFalse(viewModel.uiState.value.isLoading) + } @Test - fun verifyMasterKey_when400BadRequest_setsVerifyErrorAndKeepsStep() = runTest { - coEvery { verifyReceiverAuthUseCase(any()) } returns Result.failure( - com.kuit.afternote.data.remote.ApiException(status = 400, code = 400, message = "잘못된 인증번호입니다.") - ) - viewModel.updateMasterKey("wrong-key") - viewModel.verifyMasterKey() - advanceUntilIdle() - - assertEquals(VerifyStep.MASTER_KEY_AUTH, viewModel.uiState.value.currentStep) - assertTrue(viewModel.uiState.value.verifyError is VerifyErrorType.Server) - assertEquals("잘못된 인증번호입니다.", (viewModel.uiState.value.verifyError as VerifyErrorType.Server).message) - assertFalse(viewModel.uiState.value.isLoading) - } + fun verifyMasterKey_when400BadRequest_setsVerifyErrorAndKeepsStep() = + runTest { + coEvery { verifyReceiverAuthUseCase(any()) } returns + Result.failure( + com.kuit.afternote.data.service + .ApiException(status = 400, code = 400, message = "잘못된 인증번호입니다."), + ) + viewModel.updateMasterKey("wrong-key") + viewModel.verifyMasterKey() + advanceUntilIdle() + + assertEquals(VerifyStep.MASTER_KEY_AUTH, viewModel.uiState.value.currentStep) + assertTrue(viewModel.uiState.value.verifyError is VerifyErrorType.Server) + assertEquals("잘못된 인증번호입니다.", (viewModel.uiState.value.verifyError as VerifyErrorType.Server).message) + assertFalse(viewModel.uiState.value.isLoading) + } @Test - fun verifyMasterKey_whenNetworkError_setsVerifyErrorNetwork() = runTest { - coEvery { verifyReceiverAuthUseCase(any()) } returns Result.failure( - IOException("Network unavailable") - ) - viewModel.updateMasterKey("key") - viewModel.verifyMasterKey() - advanceUntilIdle() - - assertEquals(VerifyStep.MASTER_KEY_AUTH, viewModel.uiState.value.currentStep) - assertTrue(viewModel.uiState.value.verifyError is VerifyErrorType.Network) - assertFalse(viewModel.uiState.value.isLoading) - } + fun verifyMasterKey_whenNetworkError_setsVerifyErrorNetwork() = + runTest { + coEvery { verifyReceiverAuthUseCase(any()) } returns + Result.failure( + IOException("Network unavailable"), + ) + viewModel.updateMasterKey("key") + viewModel.verifyMasterKey() + advanceUntilIdle() + + assertEquals(VerifyStep.MASTER_KEY_AUTH, viewModel.uiState.value.currentStep) + assertTrue(viewModel.uiState.value.verifyError is VerifyErrorType.Network) + assertFalse(viewModel.uiState.value.isLoading) + } @Test fun updateMasterKey_clearsVerifyError() { @@ -114,19 +121,21 @@ class VerifySelfViewModelTest { } @Test - fun goToPreviousStep_fromUploadPdf_returnsMasterKeyStep() = runTest { - coEvery { verifyReceiverAuthUseCase(any()) } returns Result.success( - ReceiverAuthVerifyResult(receiverId = 1L) - ) - viewModel.updateMasterKey("key") - viewModel.verifyMasterKey() - advanceUntilIdle() - - assertEquals(VerifyStep.UPLOAD_PDF_AUTH, viewModel.uiState.value.currentStep) - val previous = viewModel.goToPreviousStep() - assertEquals(VerifyStep.MASTER_KEY_AUTH, previous) - assertEquals(VerifyStep.MASTER_KEY_AUTH, viewModel.uiState.value.currentStep) - } + fun goToPreviousStep_fromUploadPdf_returnsMasterKeyStep() = + runTest { + coEvery { verifyReceiverAuthUseCase(any()) } returns + Result.success( + ReceiverAuthVerifyResult(receiverId = 1L), + ) + viewModel.updateMasterKey("key") + viewModel.verifyMasterKey() + advanceUntilIdle() + + assertEquals(VerifyStep.UPLOAD_PDF_AUTH, viewModel.uiState.value.currentStep) + val previous = viewModel.goToPreviousStep() + assertEquals(VerifyStep.MASTER_KEY_AUTH, previous) + assertEquals(VerifyStep.MASTER_KEY_AUTH, viewModel.uiState.value.currentStep) + } @Test fun submitDocuments_whenDeathUriNull_setsSubmitErrorRequired() { @@ -147,54 +156,61 @@ class VerifySelfViewModelTest { } @Test - fun submitDocuments_whenSuccess_advancesToEndAndLoadsStatus() = runTest { - viewModel.updateMasterKey("auth-code") - coEvery { uploadReceiverDocumentUseCase(any(), any()) } returns - Result.success("https://file1.pdf") - coEvery { submitDeliveryVerificationUseCase(any(), any(), any()) } returns - Result.success(Unit) - val status = DeliveryVerificationStatus( - id = 1L, - status = "PENDING", - adminNote = null, - createdAt = "2025-01-01T00:00:00" - ) - coEvery { getDeliveryVerificationStatusUseCase(any()) } returns Result.success(status) - - viewModel.submitDocuments("content://death", "content://family") - advanceUntilIdle() - - assertEquals(VerifyStep.END, viewModel.uiState.value.currentStep) - assertNull(viewModel.uiState.value.submitError) - assertFalse(viewModel.uiState.value.isSubmitting) - assertEquals(status, viewModel.uiState.value.deliveryVerificationStatus) - } + fun submitDocuments_whenSuccess_advancesToEndAndLoadsStatus() = + runTest { + viewModel.updateMasterKey("auth-code") + coEvery { uploadReceiverDocumentUseCase(any(), any()) } returns + Result.success("https://file1.pdf") + coEvery { submitDeliveryVerificationUseCase(any(), any(), any()) } returns + Result.success(Unit) + val status = + DeliveryVerificationStatus( + id = 1L, + status = "PENDING", + adminNote = null, + createdAt = "2025-01-01T00:00:00", + ) + coEvery { getDeliveryVerificationStatusUseCase(any()) } returns Result.success(status) + + viewModel.submitDocuments("content://death", "content://family") + advanceUntilIdle() + + assertEquals(VerifyStep.END, viewModel.uiState.value.currentStep) + assertNull(viewModel.uiState.value.submitError) + assertFalse(viewModel.uiState.value.isSubmitting) + assertEquals(status, viewModel.uiState.value.deliveryVerificationStatus) + } @Test - fun submitDocuments_whenUploadFails_setsSubmitError() = runTest { - viewModel.updateMasterKey("auth-code") - coEvery { uploadReceiverDocumentUseCase(any(), any()) } returns - Result.failure(IOException("Network error")) + fun submitDocuments_whenUploadFails_setsSubmitError() = + runTest { + viewModel.updateMasterKey("auth-code") + coEvery { uploadReceiverDocumentUseCase(any(), any()) } returns + Result.failure(IOException("Network error")) - viewModel.submitDocuments("content://death", "content://family") - advanceUntilIdle() + viewModel.submitDocuments("content://death", "content://family") + advanceUntilIdle() - assertTrue(viewModel.uiState.value.submitError is VerifyErrorType.Network) - assertFalse(viewModel.uiState.value.isSubmitting) - } + assertTrue(viewModel.uiState.value.submitError is VerifyErrorType.Network) + assertFalse(viewModel.uiState.value.isSubmitting) + } @Test - fun submitDocuments_whenSubmitFails_setsSubmitError() = runTest { - viewModel.updateMasterKey("auth-code") - coEvery { uploadReceiverDocumentUseCase(any(), any()) } returns - Result.success("https://file1.pdf") - coEvery { submitDeliveryVerificationUseCase(any(), any(), any()) } returns - Result.failure(com.kuit.afternote.data.remote.ApiException(400, 400, "Invalid URL")) - - viewModel.submitDocuments("content://death", "content://family") - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.submitError is VerifyErrorType.Server) - assertFalse(viewModel.uiState.value.isSubmitting) - } + fun submitDocuments_whenSubmitFails_setsSubmitError() = + runTest { + viewModel.updateMasterKey("auth-code") + coEvery { uploadReceiverDocumentUseCase(any(), any()) } returns + Result.success("https://file1.pdf") + coEvery { submitDeliveryVerificationUseCase(any(), any(), any()) } returns + Result.failure( + com.kuit.afternote.data.service + .ApiException(400, 400, "Invalid URL"), + ) + + viewModel.submitDocuments("content://death", "content://family") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.submitError is VerifyErrorType.Server) + assertFalse(viewModel.uiState.value.isSubmitting) + } } diff --git a/app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt index b5ea9336..645de096 100644 --- a/app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt @@ -1,6 +1,6 @@ package com.kuit.afternote.feature.setting.presentation.viewmodel -import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.data.service.TokenManager import com.kuit.afternote.feature.user.domain.usecase.WithdrawAccountUseCase import com.kuit.afternote.util.MainCoroutineRule import io.mockk.coEvery @@ -95,15 +95,19 @@ class WithdrawalPasswordViewModelTest { @Test fun submitWithdrawal_when400BadRequest_setsErrorMessage() = runTest { - val errorBody = """{"status":400,"code":400,"message":"Bad request"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":400,"code":400,"message":"Bad request"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(400, errorBody)) coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) viewModel.submitWithdrawal("탈퇴하겠습니다.") advanceUntilIdle() - assertTrue(viewModel.uiState.value.errorMessage!!.contains("400")) + assertTrue( + viewModel.uiState.value.errorMessage!! + .contains("400"), + ) assertFalse(viewModel.uiState.value.withdrawalComplete) assertFalse(viewModel.uiState.value.showSentenceError) assertFalse(viewModel.uiState.value.isLoading) @@ -112,15 +116,19 @@ class WithdrawalPasswordViewModelTest { @Test fun submitWithdrawal_when401Unauthorized_setsErrorMessage() = runTest { - val errorBody = """{"status":401,"code":401,"message":"Unauthorized"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":401,"code":401,"message":"Unauthorized"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(401, errorBody)) coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) viewModel.submitWithdrawal("탈퇴하겠습니다.") advanceUntilIdle() - assertTrue(viewModel.uiState.value.errorMessage!!.contains("401")) + assertTrue( + viewModel.uiState.value.errorMessage!! + .contains("401"), + ) assertFalse(viewModel.uiState.value.withdrawalComplete) assertFalse(viewModel.uiState.value.isLoading) } @@ -128,15 +136,19 @@ class WithdrawalPasswordViewModelTest { @Test fun submitWithdrawal_when404NotFound_setsErrorMessage() = runTest { - val errorBody = """{"status":404,"code":404,"message":"Not found"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":404,"code":404,"message":"Not found"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(404, errorBody)) coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) viewModel.submitWithdrawal("탈퇴하겠습니다.") advanceUntilIdle() - assertTrue(viewModel.uiState.value.errorMessage!!.contains("404")) + assertTrue( + viewModel.uiState.value.errorMessage!! + .contains("404"), + ) assertFalse(viewModel.uiState.value.withdrawalComplete) assertFalse(viewModel.uiState.value.isLoading) } @@ -144,15 +156,19 @@ class WithdrawalPasswordViewModelTest { @Test fun submitWithdrawal_when500ServerError_setsErrorMessage() = runTest { - val errorBody = """{"status":500,"code":500,"message":"Server error"}""" - .toResponseBody("application/json".toMediaType()) + val errorBody = + """{"status":500,"code":500,"message":"Server error"}""" + .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) viewModel.submitWithdrawal("탈퇴하겠습니다.") advanceUntilIdle() - assertTrue(viewModel.uiState.value.errorMessage!!.contains("500")) + assertTrue( + viewModel.uiState.value.errorMessage!! + .contains("500"), + ) assertFalse(viewModel.uiState.value.withdrawalComplete) assertFalse(viewModel.uiState.value.isLoading) } @@ -160,16 +176,18 @@ class WithdrawalPasswordViewModelTest { @Test fun submitWithdrawal_whenNetworkError_setsErrorMessage() = runTest { - coEvery { withdrawAccountUseCase() } returns Result.failure( - java.io.IOException("Network unavailable") - ) + coEvery { withdrawAccountUseCase() } returns + Result.failure( + java.io.IOException("Network unavailable"), + ) viewModel.submitWithdrawal("탈퇴하겠습니다.") advanceUntilIdle() assertTrue( - viewModel.uiState.value.errorMessage!!.contains("Network") || - viewModel.uiState.value.errorMessage == "회원 탈퇴에 실패했습니다." + viewModel.uiState.value.errorMessage!! + .contains("Network") || + viewModel.uiState.value.errorMessage == "회원 탈퇴에 실패했습니다.", ) assertFalse(viewModel.uiState.value.withdrawalComplete) assertFalse(viewModel.uiState.value.isLoading) diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270 2d50982937878141a5cce5c9b5d1e61f_all.csv" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270 2d50982937878141a5cce5c9b5d1e61f_all.csv" deleted file mode 100644 index ddf2bce0..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270 2d50982937878141a5cce5c9b5d1e61f_all.csv" +++ /dev/null @@ -1,42 +0,0 @@ -기능,Method,URL,header 포함 여부,백엔드 관리자,상태,설명,카테고리,프론트 관리자,프론트 마감 기한 -로그인,POST,/auth/login,포함 X,황규운,개발 완료,email과 비밀 번호로 로그인 합니다.,Auth,정일혁, -회원 가입,POST,/auth/sign-up,포함 X,황규운,개발 완료,"이메일,비밀 번호, 휴대폰 번호로 회원가입을 합니다.",Auth,정일혁, -이메일 인증번호 보내기,POST,/auth/email/send,포함 X,황규운,개발 완료,"이메일로 인증 번호를 보냅니다. ",Auth,정일혁, -이메일 인증번호 확인,POST,/auth/email/verify,포함 X,황규운,개발 완료,받은 인증 번호로 인증을 합니다.,Auth,정일혁, -모든 afternote 목록,GET,/afternotes?category=SOCIAL?page=0&size=10,포함 O,황규운,개발 진행 중,category 없으면 전체,Afternote,, -afternote 상세 목록,GET,/afternotes/{afternote_id},포함 O,황규운,개발 진행 중,상세 목록,Afternote,, -afternote 수정,PATCH,/afternotes/{afternote_id},포함 O,황규운,개발 진행 중,,Afternote,, -afternote 삭제,DELETE,/afternotes/{afternote_id},포함 O,황규운,개발 진행 중,,Afternote,, -afternote 생성,POST,/afternotes,포함 O,황규운,개발 진행 중,,Afternote,, -프로필 조회,GET,/users/me,포함 O,김소희,개발 완료,프로필 수정 진입 시 기존 정보를 표시합니다.,User,, -프로필 수정,PATCH,/users/me,포함 O,김소희,개발 완료,,User,, -전체 조회,GET,/time-letters,포함 O,영탁 조,개발 완료,List,Time-Letters,박경민, -"단일 조회, 임시저장 불러오기",GET,/time-letters/{timeLetterId},포함 O,영탁 조,개발 완료,,Time-Letters,박경민, -등록,POST,/time-letters,포함 O,영탁 조,개발 완료,"날짜, 시간, 제목, 내용, 첨부파일, 임시저장여부, 수신자",Time-Letters,박경민, -임시저장 전체 조회,GET,/time-letters/temporary,포함 O,영탁 조,개발 완료,List,Time-Letters,박경민, -"단일, 다건 (종류 무관) 삭제",POST,/time-letters/delete,포함 O,영탁 조,개발 완료,Body: List,Time-Letters,박경민, -임시저장 전체 삭제,DELETE,/time-letters/temporary,포함 O,영탁 조,개발 완료,,Time-Letters,박경민, -수정,PATCH,/time-letters/{timeLetterId},포함 O,영탁 조,개발 완료,,Time-Letters,박경민, -Mind-Record 조회,GET,receiver/mind-records/{등록자Id},포함 X,영탁 조,시작 전,,Received,박경민, -Time-Letter 조회,GET,receiver/time-letters/{등록자Id},포함 X,영탁 조,시작 전,,Received,박경민, -After-Note 조회,GET,receiver/afternotes/{등록자Id},포함 X,영탁 조,시작 전,,Received,박경민, -마음의기록 목록 조회,GET,/mind-records,포함 O,김소희,개발 완료,"기록 목록을 조회합니다. type 파라미터로 데일리 질문, 일기, 깊은생각 구분합니다.",Mind-Record,, -마음의기록 단건 수정화면 조회,GET,/mind-records/{recordId},포함 O,김소희,개발 완료,기록 수정 화면을 조회합니다.,Mind-Record,, -마음의기록 작성,POST,/mind-records,포함 O,김소희,개발 완료,기록을 작성합니다. type 값으로 기록 유형 구분합니다.,Mind-Record,, -마음의기록 수정,PATCH,/mind-records/{recordId},포함 O,김소희,개발 완료,기록 ID를 기준으로 내용을 수정합니다.,Mind-Record,, -마음의기록 삭제,DELETE,/mind-records/{recordId},포함 O,김소희,개발 완료,기록 ID를 기준으로 기록을 삭제합니다.,Mind-Record,, -주간 리포트 조회,GET,/mind-records/weekly,포함 O,,시작 전,,Mind-Record,, -토큰 재발급,POST,/auth/reissue,포함 X,황규운,개발 완료,토큰을 다시 받습니다.,Auth,정일혁, -카카오 로그인..?,POST,/auth/social/login,포함 X,황규운,개발 진행 중,,Auth,정일혁, -비밀번호 변경,POST,/auth/password/change,포함 O,황규운,개발 완료,비밀번호를 변경합니다.,Auth,정일혁, -로그아웃,POST,/auth/logout,포함 O,황규운,개발 완료,,Auth,정일혁, -푸시 알림 상태 조회,GET,/users/push-settings,포함 O,김소희,개발 완료,,User,, -푸시 알림 설정 변경,PATCH,/users/push-settings,포함 O,김소희,개발 완료,,User,, -수신인 목록 조회,GET,/users/receivers,포함 O,김소희,개발 완료,수신인 리스트 목록을 조회합니다.,User,, -수신인 상세 조회,GET,/users/receivers/{receiverId},포함 O,김소희,개발 완료,특정 수신인의 상세 정보를 조회합니다,User,, -수신인별 데일리 질문 답변 목록 조회,GET,/users/receivers/{receiverId}/daily-questions,포함 O,김소희,시작 전,특정 수신인에게 전달될 데일리 질문 답변 목록을 최신순으로 조회합니다.,User,, -수신인별 타임레터 목록 조회,GET,/users/receivers/{receiverId}/time-letters,포함 O,김소희,시작 전,특정 수신인에게 전달될 타임레터 목록을 조회합니다.,User,, -수신인별 애프터노트 목록 조회,GET,/users/receivers/{receiverId}/after-notes,포함 O,김소희,시작 전,특정 수신인에게 전달될 애프터노트 출처별 목록을 조회합니다.,User,, -수신자 등록,POST,/users/receivers,포함 O,김소희,개발 완료,수신인을 등록합니다/,User,, -데일리 질문 조회,GET,/daily-question,포함 O,김소희,개발 진행 중,,,, -new Endpoint,,,포함 O,,시작 전,,,, \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/After-Note \354\241\260\355\232\214 2db0982937878091aba3d05e1f0f19d4.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/After-Note \354\241\260\355\232\214 2db0982937878091aba3d05e1f0f19d4.md" deleted file mode 100644 index e9e7f9d7..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/After-Note \354\241\260\355\232\214 2db0982937878091aba3d05e1f0f19d4.md" +++ /dev/null @@ -1,42 +0,0 @@ -# After-Note 조회 - -카테고리: Received -Method: GET -URL: receiver/afternotes/{등록자Id} -header 포함 여부: 포함 X -백엔드 관리자: 영탁 조 -상태: 시작 전 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/Mind-Record \354\241\260\355\232\214 2db098293787800bb37ef06c80360e6b.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/Mind-Record \354\241\260\355\232\214 2db098293787800bb37ef06c80360e6b.md" deleted file mode 100644 index dfd68085..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/Mind-Record \354\241\260\355\232\214 2db098293787800bb37ef06c80360e6b.md" +++ /dev/null @@ -1,42 +0,0 @@ -# Mind-Record 조회 - -카테고리: Received -Method: GET -URL: receiver/mind-records/{등록자Id} -header 포함 여부: 포함 X -백엔드 관리자: 영탁 조 -상태: 시작 전 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/Time-Letter \354\241\260\355\232\214 2db098293787805ea876e0b04ceaa2b6.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/Time-Letter \354\241\260\355\232\214 2db098293787805ea876e0b04ceaa2b6.md" deleted file mode 100644 index d3bcbf40..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/Time-Letter \354\241\260\355\232\214 2db098293787805ea876e0b04ceaa2b6.md" +++ /dev/null @@ -1,42 +0,0 @@ -# Time-Letter 조회 - -카테고리: Received -Method: GET -URL: receiver/time-letters/{등록자Id} -header 포함 여부: 포함 X -백엔드 관리자: 영탁 조 -상태: 시작 전 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\202\255\354\240\234 2d609829378780aa9ad3c7353e560588.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\202\255\354\240\234 2d609829378780aa9ad3c7353e560588.md" deleted file mode 100644 index cecfdc72..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\202\255\354\240\234 2d609829378780aa9ad3c7353e560588.md" +++ /dev/null @@ -1,32 +0,0 @@ -# afternote 삭제 - -카테고리: Afternote -Method: DELETE -URL: /afternotes/{afternote_id} -header 포함 여부: 포함 O -백엔드 관리자: 황규운 -상태: 개발 진행 중 - -### Request - -body 없음~ - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "애프터노트 삭제에 성공하였습니다.", - "data": { - "afternote_id" : 3, - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\201\354\204\270 \353\252\251\353\241\235 2d60982937878027adefeebc56d4eaf8.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\201\354\204\270 \353\252\251\353\241\235 2d60982937878027adefeebc56d4eaf8.md" deleted file mode 100644 index a10ec357..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\201\354\204\270 \353\252\251\353\241\235 2d60982937878027adefeebc56d4eaf8.md" +++ /dev/null @@ -1,93 +0,0 @@ -# afternote 상세 목록 - -카테고리: Afternote -설명: 상세 목록 -Method: GET -URL: /afternotes/{afternote_id} -header 포함 여부: 포함 O -백엔드 관리자: 황규운 -상태: 개발 진행 중 - -**Path Variable** - -```java -afternote_id : int -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "상세 조회에 성공하였습니다.", - "data": { - // ========================================== - // [1] 공통 영역 (모든 카테고리 필수) - // ========================================== - "afternoteId": 10, - "category": "SOCIAL", // "GALLERY", "PLAYLIST" - "title": "인스타그램", - "createdAt": "2025-11-26T14:30:00", - "updatedAt": "2025-11-26T14:30:00", - - // ========================================== - // [2] 카테고리별 전용 영역 (나머지는 null) - // ========================================== - - // (A) SOCIAL 일 때만 값 있음 - "credentials": { - "id": "아이디", - "password": "qwerty123" - }, - - // (B) GALLERY 일 때만 값 있음 (현재는 SOCIAL이라 null) - "receivers": null, - /* "receivers": [ - { "name": "황규운", "relation": "친구", "phone": "010-0000-0000" } - ] - */ - - // (C)SOCIAL 혹은 GALLERY 일 때만 - - // 이 메모에 대해 선택한 처리 방식 (라디오 버튼 값) - // SOCIAL: 'MEMORIAL', 'DELETE', 'TRANSFER' - // GALLERY: 'TRANSFER', 'ADDITIONAL' - "processMethod": "MEMORIAL", - - // 체크리스트 (사용자가 선택/입력한 할 일 목록) - "actions": [ - "게시물 내리기", - "추모 게시물 올리기", - "내 흑역사 폴더 영구 삭제" - ], - - // 남기신 말씀 (텍스트 에어리어) - "leaveMessage": "사망 후 추모 계정으로 전환해줘. 우리 가족 사진은 지키고 싶어.", - - - // (D) PLAYLIST 일 때만 값 있음 (현재는 SOCIAL이라 null) - "playlist": null - /* - "playlist": { - "profilePhoto": "http://...", - "atmosphere": "차분하고 조용하게 보내주세요.", - "songs": [ - { "id": 1, "title": "노래제목", "artist": "가수", "coverUrl": "..." } - ], - "memorialVideo": { - "videoUrl": "...", - "thumbnailUrl": "..." - } - } - */ - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\235\354\204\261 2d60982937878054a9e4e1327b2750bb.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\235\354\204\261 2d60982937878054a9e4e1327b2750bb.md" deleted file mode 100644 index 5852a58b..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\235\354\204\261 2d60982937878054a9e4e1327b2750bb.md" +++ /dev/null @@ -1,100 +0,0 @@ -# afternote 생성 - -카테고리: Afternote -Method: POST -URL: /afternotes -header 포함 여부: 포함 O -백엔드 관리자: 황규운 -상태: 개발 진행 중 - -### Request - -### SOCIAL 카테고리 - -**필수 필드**: category, title, processMethod, actions, credentials - -**null이어야 하는 필드**: receivers, playlist - -```java -{ - "category": "SOCIAL", - "title": "인스타그램", - "processMethod": "MEMORIAL", - "actions": ["계정 비공개 전환", "마지막 게시물 작성"], - "leaveMessage": "감사했습니다", - "credentials": { - "id": "my_insta_id", - "password": "password123" - } - //receivers:null, -> 이 형태도 상관없고 아예 없어도 됌 -} -``` - -### GALLERY 카테고리 - -**필수 필드**: category, title, processMethod, actions, receivers - -**null이어야 하는 필드**: credentials, playlist - -```java -{ - "category": "GALLERY", - "title": "가족 사진", - "processMethod": "DELETE", - "actions": ["사진 백업", "클라우드 정리"], - "leaveMessage": "소중한 추억들", - "receivers": [ - { "receiverId": 1 }, - { "receiverId": 2 } - ] -} -``` - -### PLAYLIST 카테고리 - -**필수 필드**: category, title, playlist - -**null이어야 하는 필드**: processMethod, actions, leaveMessage, credentials, receivers - -```java -{ - "category": "PLAYLIST", - "title": "나의 마지막 플레이리스트", - "playlist": { - "atmosphere": "차분하고 조용하게", - "songs": [ - { - "title": "보고싶다", - "artist": "김범수", - "coverUrl": "https://..." - } - ], - "memorialVideo": { - "videoUrl": "https://...", - "thumbnailUrl": "https://..." - } - } -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "애프터노트 생성에 성공하였습니다.", - "data": { - "afternote_id" : 3, - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | - -[암호화](afternote%20%EC%83%9D%EC%84%B1/%EC%95%94%ED%98%B8%ED%99%94%202f709829378780c191f9cd52f0ddf8b4.md) \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\235\354\204\261/\354\225\224\355\230\270\355\231\224 2f709829378780c191f9cd52f0ddf8b4.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\235\354\204\261/\354\225\224\355\230\270\355\231\224 2f709829378780c191f9cd52f0ddf8b4.md" deleted file mode 100644 index 8c9d5961..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\203\235\354\204\261/\354\225\224\355\230\270\355\231\224 2f709829378780c191f9cd52f0ddf8b4.md" +++ /dev/null @@ -1,13 +0,0 @@ -# 암호화 - -### ChaCha20-Poly1305의 장점 - -1. **모바일 최적화**: ARM 프로세서에서 AES보다 2~3배 빠름 -2. **AEAD**: 인증 암호화 내장 (변조 감지) -3. **안전한 Nonce**: 매번 랜덤 nonce 생성으로 더 안전 -4. **Google 권장**: Android 공식 권장 암호화 - -### 기술적 차이 - -- **AES-256**: IV 고정 (재사용 위험) -- **ChaCha20**: Nonce를 매번 랜덤 생성 (암호문과 함께 저장) \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\210\230\354\240\225 2d609829378780248fbefbde0d3d3f42.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\210\230\354\240\225 2d609829378780248fbefbde0d3d3f42.md" deleted file mode 100644 index b6a838a6..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/afternote \354\210\230\354\240\225 2d609829378780248fbefbde0d3d3f42.md" +++ /dev/null @@ -1,81 +0,0 @@ -# afternote 수정 - -카테고리: Afternote -Method: PATCH -URL: /afternotes/{afternote_id} -header 포함 여부: 포함 O -백엔드 관리자: 황규운 -상태: 개발 진행 중 - -### Request - -## **PATCH (수정) 요청 규칙** - -### 공통 규칙 - -- **카테고리 변경 불가**: category 필드를 보내면 에러 발생 -- **수정하지 않을 필드는 보내지 않으면 됨**: 기존 값 유지 -- **카테고리에 맞지 않는 필드를 보내면 400 에러** - -### SOCIAL 카테고리 수정 - -**수정 가능**: title, processMethod, actions, leaveMessage, credentials - -**null이어야 하는 필드**: receivers, playlist - -```java -{ - "title": "수정된 제목", - "credentials": { - "password": "new_password" - } -} -``` - -### GALLERY 카테고리 수정 - -**수정 가능**: title, processMethod, actions, leaveMessage, receivers - -**null이어야 하는 필드**: credentials, playlist - -```java -{ - "receivers": [ - { "receiverId": 3 } - ] -} -``` - -### PLAYLIST 카테고리 수정 - -**수정 가능**: title, playlist - -**null이어야 하는 필드**: processMethod, actions, leaveMessage, credentials, receivers - -```java -{ - "playlist": { - "atmosphere": "새로운 분위기" - } -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "애프터노트 수정에 성공하였습니다.", - "data": { - "afternote_id" : 3, - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\213\250\354\235\274 \354\241\260\355\232\214, \354\236\204\354\213\234\354\240\200\354\236\245 \353\266\210\353\237\254\354\230\244\352\270\260 2db0982937878018bd06d994ccaf09df.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\213\250\354\235\274 \354\241\260\355\232\214, \354\236\204\354\213\234\354\240\200\354\236\245 \353\266\210\353\237\254\354\230\244\352\270\260 2db0982937878018bd06d994ccaf09df.md" deleted file mode 100644 index b515b186..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\213\250\354\235\274 \354\241\260\355\232\214, \354\236\204\354\213\234\354\240\200\354\236\245 \353\266\210\353\237\254\354\230\244\352\270\260 2db0982937878018bd06d994ccaf09df.md" +++ /dev/null @@ -1,42 +0,0 @@ -# 단일 조회, 임시저장 불러오기 - -카테고리: Time-Letters -Method: GET -URL: /time-letters/{timeLetterId} -header 포함 여부: 포함 O -백엔드 관리자: 영탁 조 -상태: 개발 완료 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\213\250\354\235\274, \353\213\244\352\261\264 (\354\242\205\353\245\230 \353\254\264\352\264\200) \354\202\255\354\240\234 2db0982937878076a28af1bcf5051392.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\213\250\354\235\274, \353\213\244\352\261\264 (\354\242\205\353\245\230 \353\254\264\352\264\200) \354\202\255\354\240\234 2db0982937878076a28af1bcf5051392.md" deleted file mode 100644 index 71de2306..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\213\250\354\235\274, \353\213\244\352\261\264 (\354\242\205\353\245\230 \353\254\264\352\264\200) \354\202\255\354\240\234 2db0982937878076a28af1bcf5051392.md" +++ /dev/null @@ -1,43 +0,0 @@ -# 단일, 다건 (종류 무관) 삭제 - -카테고리: Time-Letters -설명: Body: List -Method: POST -URL: /time-letters/delete -header 포함 여부: 포함 O -백엔드 관리자: 영탁 조 -상태: 개발 완료 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\215\260\354\235\274\353\246\254 \354\247\210\353\254\270 \354\241\260\355\232\214 2f4098293787801eb444e33518d7996c.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\215\260\354\235\274\353\246\254 \354\247\210\353\254\270 \354\241\260\355\232\214 2f4098293787801eb444e33518d7996c.md" deleted file mode 100644 index 4f18a7b3..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\215\260\354\235\274\353\246\254 \354\247\210\353\254\270 \354\241\260\355\232\214 2f4098293787801eb444e33518d7996c.md" +++ /dev/null @@ -1,60 +0,0 @@ -# 데일리 질문 조회 - -Method: GET -URL: /daily-question -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 진행 중 - -질문은 서버에서 날짜 기준으로 하루 1개를 전달해줍니다. - -해당 날짜에 이미 노출된 질문이 있으면 기존 질문을 그대로 반환하고, 해당 날짜에 질문이 없으면 서버에서 자동으로 새 질문을 생성 후 변환합니다. - -- 날짜 기준은 서버 시간 기준으로 자정(00:00) - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| -questionId - | -데일리 질문 ID - | -Long - | | -X - | -12 - | -| content | 질문 내용 - | String | | -X | 오늘 가장 기억에 남는 순간은? | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - "questionId": 12, - "content": "오늘 가장 기억에 남는 순간은 무엇이었나요?", -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 성공 | -| 400 | INVALID_INPUT_VALUE | -| 404 | DAILY_QUESTION_NOT_FOUND | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\223\261\353\241\235 2db09829378780a8a70edd8b95e41e07.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\223\261\353\241\235 2db09829378780a8a70edd8b95e41e07.md" deleted file mode 100644 index da17df85..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\223\261\353\241\235 2db09829378780a8a70edd8b95e41e07.md" +++ /dev/null @@ -1,43 +0,0 @@ -# 등록 - -카테고리: Time-Letters -설명: 날짜, 시간, 제목, 내용, 첨부파일, 임시저장여부, 수신자 -Method: POST -URL: /time-letters -header 포함 여부: 포함 O -백엔드 관리자: 영탁 조 -상태: 개발 완료 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\225\204\354\233\203 2e809829378780d8a12cee48e838b579.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\225\204\354\233\203 2e809829378780d8a12cee48e838b579.md" deleted file mode 100644 index 10d87599..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\225\204\354\233\203 2e809829378780d8a12cee48e838b579.md" +++ /dev/null @@ -1,35 +0,0 @@ -# 로그아웃 - -카테고리: Auth -Method: POST -URL: /auth/logout -header 포함 여부: 포함 O -백엔드 관리자: 황규운 -상태: 개발 완료 -프론트 관리자: 정일혁 - -### Request - -```java -{ - "refreshToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "로그아웃을 성공하였습니다.", - "data": null -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\235\270 2d509829378781289e51d8096c6cf3ac.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\235\270 2d509829378781289e51d8096c6cf3ac.md" deleted file mode 100644 index 6352d62e..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\235\270 2d509829378781289e51d8096c6cf3ac.md" +++ /dev/null @@ -1,43 +0,0 @@ -# 로그인 - -카테고리: Auth -설명: email과 비밀 번호로 로그인 합니다. -Method: POST -URL: /auth/login -header 포함 여부: 포함 X -백엔드 관리자: 황규운 -상태: 개발 완료 -프론트 관리자: 정일혁 - -![image.png](%EB%A1%9C%EA%B7%B8%EC%9D%B8/image.png) - -### Request - -```java -{ - "email": "user@example.com", - "password": "strongPassword123!" -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "로그인에 성공하였습니다.", - "data": { - "refreshToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", - "accessToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", - - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\235\270/image.png" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\235\270/image.png" deleted file mode 100644 index 7667f3c0..00000000 Binary files "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\241\234\352\267\270\354\235\270/image.png" and /dev/null differ diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\213\250\352\261\264 \354\210\230\354\240\225\355\231\224\353\251\264 \354\241\260\355\232\214 2e50982937878032b573fe11b3a46edb.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\213\250\352\261\264 \354\210\230\354\240\225\355\231\224\353\251\264 \354\241\260\355\232\214 2e50982937878032b573fe11b3a46edb.md" deleted file mode 100644 index 2484f4e1..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\213\250\352\261\264 \354\210\230\354\240\225\355\231\224\353\251\264 \354\241\260\355\232\214 2e50982937878032b573fe11b3a46edb.md" +++ /dev/null @@ -1,65 +0,0 @@ -# 마음의기록 단건 수정화면 조회 - -카테고리: Mind-Record -설명: 기록 수정 화면을 조회합니다. -Method: GET -URL: /mind-records/{recordId} -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -기록 수정 화면 진입 시 사용되는 API입니다. -기존에 작성된 기록을 수정하기 위해, 기록의 타입에 따라 필요한 데이터를 조회하여 응답합니다. - -### Request - -**Path parameter** - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| **`recordId`** | 조회할 기록 ID | Long | X | | | - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| **`recordId`** | 기록 ID | Long | | X | 12 | -| **`type`** | 기록 유형 | String | DAILY_QUESTION / DIARY / DEEP_THOUGHT | X | DIARY | -| **`title`** | 기록 제목 | String | | X | 오늘의 기록 | -| **`content`** | 기록 내용 | String | | X | 오늘은 날씨가 정말 좋았다!! | -| **`date`** | 기록 날짜 | String | yyyy-MM-dd | X | 2026-01-11 | -| **`isDraft`** | 임시저장 여부 | Boolean | | X | false | -| | | | | | | -| **`category`** | 깊은 생각 카테고리 | String | DEEP_THOUGHT일 때만 | O | 나의 가치관 | -- type = DEEP_THOUGHT → category만 포함 - -**Example** - -```jsx -{ - "recordId": 12, - "type": "DIARY", - "title": "오늘의 기록", - "content": "오늘은 날씨가 정말 좋았다!!", - "date": "2026-01-11", - "isDraft": false -} -``` - -```jsx -{ - "recordId": 7, - "type": "DAILY_QUESTION", - "title": "오늘 가장 기억에 남는 순간은?", - "content": "저녁 노을을 본 순간이 인상 깊었다.", - "date": "2026-01-10", - "isDraft": false, -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\252\251\353\241\235 \354\241\260\355\232\214 2e509829378780f4b856d1dd6ce9915d.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\252\251\353\241\235 \354\241\260\355\232\214 2e509829378780f4b856d1dd6ce9915d.md" deleted file mode 100644 index 509b3485..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\252\251\353\241\235 \354\241\260\355\232\214 2e509829378780f4b856d1dd6ce9915d.md" +++ /dev/null @@ -1,75 +0,0 @@ -# 마음의기록 목록 조회 - -카테고리: Mind-Record -설명: 기록 목록을 조회합니다. type 파라미터로 데일리 질문, 일기, 깊은생각 구분합니다. -Method: GET -URL: /mind-records -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -![스크린샷 2026-01-11 16.28.39.png](%EB%A7%88%EC%9D%8C%EC%9D%98%EA%B8%B0%EB%A1%9D%20%EB%AA%A9%EB%A1%9D%20%EC%A1%B0%ED%9A%8C/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-01-11_16.28.39.png) - -Mind-Record의 리스트형 및 캘린더형 화면에서 공통으로 사용하는 조회 API입니다. -type 파라미터를 통해 데일리 질문, 일기, 깊은 생각 기록을 구분하여 조회합니다. - -### Request - -**Query parameter** - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| **`type`** | 기록 유형 | String | DAILY_QUESTION, DIARY, DEEP_THOUGHT - | X | DIARY | -| **`view`** | 조회 화면 타입 | String | LIST, CALENDAR | O | LIST | -| **`year`** | 연도 (캘린더 조회 시) | Int | | O | 2025 | -| **`month`** | 월 (캘린더 조회 시) | Int | 1~12 | O | 1 | - - - -- view=CALENDAR일 경우 year, month 사용 - - 해당 월 전체와 달력에 표시된 날짜들을 내려줍니다. -- view=LIST일 경우 date 선택 사항 - - 기록된 전체 항목을 내려줍니다. - -### Response - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| records | 기록 목록 | Array | X | | | -| records[].recordId | 기록 ID | Long | X | 12 | | -| records[].type | 기록 유형 | String | X | DIARY | | -| records[].title | 제목 | String | O | 오늘의 기록 | | -| records[].date | 기록 날짜 | String | yyyy-MM-dd | X | 2026-01-11 | -| records[].isDraft | 임시저장 여부 | Boolean | X | false | | -| markedDates | 기록이 존재하는 날짜 목록 (캘린더용) | Array(String) | O | ["2026-01-03"] | | -| | | | | | | - -**Example** - -```jsx -{ - "records": [ - { - "recordId": 12, - "type": "DIARY", - "title": "오늘의 기록", - "date": "2026-01-11", - "isDraft": false // 임시저장 여부 (true: 임시저장, false: 정식 저장) - } - ], - "markedDates": ["2026-01-03", "2026-01-11"] // view=CALENDAR일때 캘린더 점 찍기용 날짜 목록 -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 기록 목록 조회 성공 | -| 400 | 잘못된 요청 파라미터 또는 인증 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\252\251\353\241\235 \354\241\260\355\232\214/\354\212\244\355\201\254\353\246\260\354\203\267_2026-01-11_16.28.39.png" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\252\251\353\241\235 \354\241\260\355\232\214/\354\212\244\355\201\254\353\246\260\354\203\267_2026-01-11_16.28.39.png" deleted file mode 100644 index 2e25ec90..00000000 Binary files "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \353\252\251\353\241\235 \354\241\260\355\232\214/\354\212\244\355\201\254\353\246\260\354\203\267_2026-01-11_16.28.39.png" and /dev/null differ diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\202\255\354\240\234 2e50982937878010905efe16d18f7fe4.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\202\255\354\240\234 2e50982937878010905efe16d18f7fe4.md" deleted file mode 100644 index 3e04a5c8..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\202\255\354\240\234 2e50982937878010905efe16d18f7fe4.md" +++ /dev/null @@ -1,33 +0,0 @@ -# 마음의기록 삭제 - -카테고리: Mind-Record -설명: 기록 ID를 기준으로 기록을 삭제합니다. -Method: DELETE -URL: /mind-records/{recordId} -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -### Request - -**Path parameter** - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| recordId | 삭제할 기록 ID | Long | | X | 12 | - -### Response - -**Example** - -```jsx -{} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 기록 삭제 성공 | -| 400 | 잘못된 요청 또는 인증 오류 | -| 404 | 존재하지 않는 기록 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\210\230\354\240\225 2e509829378780c483caed596d87dae8.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\210\230\354\240\225 2e509829378780c483caed596d87dae8.md" deleted file mode 100644 index 4f246a29..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\210\230\354\240\225 2e509829378780c483caed596d87dae8.md" +++ /dev/null @@ -1,64 +0,0 @@ -# 마음의기록 수정 - -카테고리: Mind-Record -설명: 기록 ID를 기준으로 내용을 수정합니다. -Method: PATCH -URL: /mind-records/{recordId} -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -기존에 작성된 마음의 기록을 수정하는 API입니다. - -기록의 타입은 변경할 수 없으며, 내용만 수정할 수 있습니다. - -### Request - -**Path parameter** - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| recordId | 수정할 기록 ID | Long | | X | 12 | - -**Body parameter** - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| title | 기록 제목 | String | | O | 수정된 제목 | -| content | 기록 내용 | String | | X | 수정된 내용 | -| date | 기록 날짜 | String | yyyy-MM-dd | X | 2026-01-12 | -| isDraft | 임시저장 여부 | Boolean | | X | false | -| questionId | 데일리 질문 ID | Long | DAILY_QUESTION일 때만 | O | 3 | -| category | 깊은 생각 카테고리 | String | DEEP_THOUGHT일 때만 | O | 새로운 관점 | -- 기록 유형(type)은 수정할 수 없다. -- type = DIARY → questionId, category는 null -- type = DAILY_QUESTION → questionId만 포함 -- type = DEEP_THOUGHT → category만 포함 -- 임시저장(isDraft = true)인 경우에도 content는 빈 문자열("")로 전달될 수 있음 - -### Response - -**Body** - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| recordId | 수정된 기록 ID | Long | | X | 12 | - -**Example** - -```jsx -{ - "title": "수정된 제목", - "content": "내용을 수정했습니다.", - "date": "2026-01-12", - "isDraft": false -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 기록 수정 성공 | -| 400 | 잘못된 요청 또는 인증 오류 | -| 404 | 존재하지 않는 기록 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\236\221\354\204\261 2e509829378780289238d0bd10b4772a.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\236\221\354\204\261 2e509829378780289238d0bd10b4772a.md" deleted file mode 100644 index 9fc03a3c..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\247\210\354\235\214\354\235\230\352\270\260\353\241\235 \354\236\221\354\204\261 2e509829378780289238d0bd10b4772a.md" +++ /dev/null @@ -1,65 +0,0 @@ -# 마음의기록 작성 - -카테고리: Mind-Record -설명: 기록을 작성합니다. type 값으로 기록 유형 구분합니다. -Method: POST -URL: /mind-records -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -### Request - -**Body parameter** - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| type | 기록 유형 | String | DAILY_QUESTION / DIARY / DEEP_THOUGHT | X | DIARY | -| title | 기록 제목 | String | | O | 오늘의 기록 | -| content | 기록 내용 | String | | X | 오늘은 날씨가 정말 좋았다. | -| date | 기록 날짜 | String | yyyy-MM-dd | X | 2026-01-11 | -| isDraft | 임시저장 여부 | Boolean | | X | false | -| questionId | 데일리 질문 ID | Long | DAILY_QUESTION일 때만 | O | 3 | -| category | 깊은 생각 카테고리 | String | DEEP_THOUGHT일 때만 | O | 나의 가치관 | -- type = DIARY → questionId, category는 null -- type = DAILY_QUESTION → questionId만 포함 -- type = DEEP_THOUGHT → category만 포함 -- 임시저장(isDraft = true)인 경우에도 content는 빈 문자열("")로 전달될 수 있음 - -### Response - -**Body** - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| recordId | 생성된 기록 ID | Long | | X | 15 | - -**Example** - -```jsx -{ - "type": "DIARY", - "title": "오늘의 기록", - "content": "오늘은 날씨가 정말 좋았다.", - "date": "2026-01-11", - "isDraft": false -} -``` - -```java -{ - "type": "DAILY_QUESTION", - "title": "오늘 가장 기억에 남는 순간은?", - "content": "저녁 노을을 본 순간이 인상 깊었다.", - "date": "2026-01-10", - "isDraft": false, - "questionId": 3 -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 기록 작성 성공 | -| 400 | 잘못된 요청 또는 인증 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\252\250\353\223\240 afternote \353\252\251\353\241\235 2d6098293787804e947ff4ebcbeb3c6a.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\252\250\353\223\240 afternote \353\252\251\353\241\235 2d6098293787804e947ff4ebcbeb3c6a.md" deleted file mode 100644 index a99e6335..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\252\250\353\223\240 afternote \353\252\251\353\241\235 2d6098293787804e947ff4ebcbeb3c6a.md" +++ /dev/null @@ -1,60 +0,0 @@ -# 모든 afternote 목록 - -카테고리: Afternote -설명: category 없으면 전체 -Method: GET -URL: /afternotes?category=SOCIAL?page=0&size=10 -header 포함 여부: 포함 O -백엔드 관리자: 황규운 -상태: 개발 진행 중 - -**Query parameter** - -| **파라미터명** | **타입** | **필수여부** | **설명** | **예시** | -| --- | --- | --- | --- | --- | -| **`category`** | String | 선택 | 필터링할 카테고리 **(없으면 전체 조회)** | `SOCIAL`, `GALLERY`, -`MUSIC` | -| **`page`** | Integer | 선택 | 페이지 번호 (0부터 시작) | `0` | -| **`size`** | Integer | 선택 | 한 번에 가져올 개수 (기본값 10) | `10` | - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "목록 조회에 성공하였습니다.", - "data": { - "content": [ - { - "afternoteId": 10, - "title": "인스타그램", - "category": "SOCIAL", - "createdAt": "2025-11-26T14:30:00" - }, - { - "afternoteId": 9, - "title": "갤러리", - "category": "GALLERY", - "createdAt": "2025-11-26T10:15:00" - }, - { - "afternoteId": 8, - "title": "업무용 노트", - "category": "MUSIC", - "createdAt": "2025-11-25T18:00:00" - } - ], - "page": 0, // 현재 페이지 번호 - "size": 10, // 요청한 사이즈 - "hasNext": true // 다음 페이지가 있는지 여부 (무한 스크롤 구현 시 필요) - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\271\204\353\260\200\353\262\210\355\230\270 \353\263\200\352\262\275 2e60982937878084af8ee0f9d1559f99.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\271\204\353\260\200\353\262\210\355\230\270 \353\263\200\352\262\275 2e60982937878084af8ee0f9d1559f99.md" deleted file mode 100644 index 2b26caaa..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\353\271\204\353\260\200\353\262\210\355\230\270 \353\263\200\352\262\275 2e60982937878084af8ee0f9d1559f99.md" +++ /dev/null @@ -1,38 +0,0 @@ -# 비밀번호 변경 - -카테고리: Auth -설명: 비밀번호를 변경합니다. -Method: POST -URL: /auth/password/change -header 포함 여부: 포함 O -백엔드 관리자: 황규운 -상태: 개발 완료 -프론트 관리자: 정일혁 - -### Request - -```java -{ - "currentPassword" : "qwer1234!" - "newPassword" : "qwer1234!!" - -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "비밀번호 변경에 성공하였습니다.", - "data": null, -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270 \353\252\251\353\241\235 \354\241\260\355\232\214 2f109829378780cb840dfcc1f67c7a73.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270 \353\252\251\353\241\235 \354\241\260\355\232\214 2f109829378780cb840dfcc1f67c7a73.md" deleted file mode 100644 index 1f0f1d50..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270 \353\252\251\353\241\235 \354\241\260\355\232\214 2f109829378780cb840dfcc1f67c7a73.md" +++ /dev/null @@ -1,57 +0,0 @@ -# 수신인 목록 조회 - -카테고리: User -설명: 수신인 리스트 목록을 조회합니다. -Method: GET -URL: /users/receivers -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -### Request - -**Header** - -Authorization: Bearer {accessToken} - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| receivers | 수신인 목록 | Array | | N | | -| receivers[].receiverId | 수신인 ID | Long | | N | 1 | -| receivers[].name | 수신인 이름 | String | | N | 김소희 | -| receivers[].relation | 수신인과의 관계 | String | ENUM | N - | | - -**Example** - -```jsx -{ - "receivers": [ - { - "receiverId": 1, - "name": "김소희", - "relation": "딸" - }, - { - "receiverId": 2, - "name": "김소희", - "relation": "조카" - }, - { - "receiverId": 3, - "name": "김소희", - "relation": "언니" - } - ] -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 수신인 목록 조회 성공 | -| 401 | 인증 실패 (토큰 없음 또는 만료) | -| 500 | 서버 내부 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270 \354\203\201\354\204\270 \354\241\260\355\232\214 2f2098293787802bbc19f953efa50d11.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270 \354\203\201\354\204\270 \354\241\260\355\232\214 2f2098293787802bbc19f953efa50d11.md" deleted file mode 100644 index 159062f6..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270 \354\203\201\354\204\270 \354\241\260\355\232\214 2f2098293787802bbc19f953efa50d11.md" +++ /dev/null @@ -1,70 +0,0 @@ -# 수신인 상세 조회 - -카테고리: User -설명: 특정 수신인의 상세 정보를 조회합니다 -Method: GET -URL: /users/receivers/{receiverId} -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -### Request - -**Header** - -Authorization: Bearer {accessToken} - -**Path Variable** - -| **key** | **설명** | **타입** | -| --- | --- | --- | -| receiverId | 수신인 ID | Long | - -### Response - -| -key - | -설명 - | -value 타입 - | -옵션 - | -Nullable - | -예시 - | -| --- | --- | --- | --- | --- | --- | -| receiverId | 수신인 ID | Long | | N | 1 | -| name | 수신인 이름 | String | | N | 김소희 | -| relation | 수신인과의 관계 | String | ENUM | N | 딸 | -| phone | 전화번호 | String | | Y | 010-1234-1234 | -| email | 이메일 | String | Y | afternote123@naver.com | | -| dailyQuestionCount | 데일리 질문 답변 개수 | Integer | | N | 8 | -| timeLetterCount | 타임레터 개수 | Integer | | N | 12 | -| afterNoteCount | 애프터노트 개수 | Integer | | N | 4 | - -**Example** - -```jsx -{ - "receiverId": 1, - "name": "김소희", - "relation": "딸", - "phone": "010-1234-1234", - "email": "afternote123@naver.com", - "dailyQuestionCount": 8, - "timeLetterCount": 12, - "afterNoteCount": 4 -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 수신인 상세 조회 성공 | -| 401 | 인증 실패 | -| 404 | 수신인을 찾을 수 없음 | -| 500 | 서버 내부 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \353\215\260\354\235\274\353\246\254 \354\247\210\353\254\270 \353\213\265\353\263\200 \353\252\251\353\241\235 \354\241\260\355\232\214 2f209829378780cb8de4ed074704f36d.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \353\215\260\354\235\274\353\246\254 \354\247\210\353\254\270 \353\213\265\353\263\200 \353\252\251\353\241\235 \354\241\260\355\232\214 2f209829378780cb8de4ed074704f36d.md" deleted file mode 100644 index 590f340b..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \353\215\260\354\235\274\353\246\254 \354\247\210\353\254\270 \353\213\265\353\263\200 \353\252\251\353\241\235 \354\241\260\355\232\214 2f209829378780cb8de4ed074704f36d.md" +++ /dev/null @@ -1,66 +0,0 @@ -# 수신인별 데일리 질문 답변 목록 조회 - -카테고리: User -설명: 특정 수신인에게 전달될 데일리 질문 답변 목록을 최신순으로 조회합니다. -Method: GET -URL: /users/receivers/{receiverId}/daily-questions -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 시작 전 - -### Request - -Authorization: Bearer {accessToken} - -**Query parameter** - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| receiverId | 수신인 ID | Long | N | 1 | | - -### Response - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| items | 데일리 질문 답변 목록 | Array | | N | | -| items[].dailyQuestionAnswerId | 답변 ID | Long | | N | 130 | -| items[].question | 질문 내용 | String | | N | 오늘 하루, 누구에게 가장 고마웠나요? | -| items[].answer | 답변 내용 | String | | N | 아무 말 없이… | -| items[].createdAt | 작성 날짜 | LocalDate | | N | 2025-10-09 | - -**Example** - -```jsx -{ - "items": [ - { - "dailyQuestionAnswerId": 130, - "question": "오늘 하루, 누구에게 가장 고마웠나요?", - "answer": "아무 말 없이 그저 나의 곁을 지켜주는 아내가 너무 고맙다.", - "createdAt": "2025-10-09" - }, - { - "dailyQuestionAnswerId": 129, - "question": "오늘 하루 나를 미소 짓게 한 순간은?", - "answer": "퇴근길에 본 석양이 너무 예뻤다.", - "createdAt": "2025-10-08" - }, - { - "dailyQuestionAnswerId": 128, - "question": "오늘 가장 힘들었던 점은?", - "answer": "업무가 계획대로 되지 않아서 조금 지쳤다.", - "createdAt": "2025-10-07" - } - ] -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 데일리 질문 답변 목록 조회 성공 | -| 401 | 인증 실패 | -| 403 | 내 수신인이 아님 | -| 404 | 수신인을 찾을 수 없음 | -| 500 | 서버 내부 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \354\225\240\355\224\204\355\204\260\353\205\270\355\212\270 \353\252\251\353\241\235 \354\241\260\355\232\214 2f209829378780e4a2b6fcf082cec2f4.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \354\225\240\355\224\204\355\204\260\353\205\270\355\212\270 \353\252\251\353\241\235 \354\241\260\355\232\214 2f209829378780e4a2b6fcf082cec2f4.md" deleted file mode 100644 index d08c8415..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \354\225\240\355\224\204\355\204\260\353\205\270\355\212\270 \353\252\251\353\241\235 \354\241\260\355\232\214 2f209829378780e4a2b6fcf082cec2f4.md" +++ /dev/null @@ -1,64 +0,0 @@ -# 수신인별 애프터노트 목록 조회 - -카테고리: User -설명: 특정 수신인에게 전달될 애프터노트 출처별 목록을 조회합니다. -Method: GET -URL: /users/receivers/{receiverId}/after-notes -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 시작 전 - -### Request - -**Header** - -Authorization: Bearer {accessToken} - -**Path Variable** - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| receiverId | 수신인 ID | Long | | N | 1 | - -### Response - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| items | 애프터노트 출처 목록 | Array | | N | | -| items[].sourceType | 출처 타입 | String ENUM | INSTAGRAM / GALLERY / GUIDE / NAVER_MAIL | N | INSTAGRAM | -| items[].lastUpdatedAt | 해당 출처의 최근 작성일 | LocalDate | | | | - -**Example** - -```jsx -{ - "items": [ - { - "sourceType": "INSTAGRAM", - "lastUpdatedAt": "2025-11-26" - }, - { - "sourceType": "GALLERY", - "lastUpdatedAt": "2025-11-26" - }, - { - "sourceType": "GUIDE", - "lastUpdatedAt": "2025-11-26" - }, - { - "sourceType": "NAVER_MAIL", - "lastUpdatedAt": "2025-11-26" - } - ] -} -``` - -### Status - -| **status** | **response content** | -| --- | --- | -| 200 | 애프터노트 목록 조회 성공 | -| 401 | 인증 실패 | -| 403 | 내 수신인이 아님 | -| 404 | 수신인을 찾을 수 없음 | -| 500 | 서버 내부 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \355\203\200\354\236\204\353\240\210\355\204\260 \353\252\251\353\241\235 \354\241\260\355\232\214 2f20982937878025aed0f09056a9985b.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \355\203\200\354\236\204\353\240\210\355\204\260 \353\252\251\353\241\235 \354\241\260\355\232\214 2f20982937878025aed0f09056a9985b.md" deleted file mode 100644 index 190f911e..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\235\270\353\263\204 \355\203\200\354\236\204\353\240\210\355\204\260 \353\252\251\353\241\235 \354\241\260\355\232\214 2f20982937878025aed0f09056a9985b.md" +++ /dev/null @@ -1,73 +0,0 @@ -# 수신인별 타임레터 목록 조회 - -카테고리: User -설명: 특정 수신인에게 전달될 타임레터 목록을 조회합니다. -Method: GET -URL: /users/receivers/{receiverId}/time-letters -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 시작 전 - -### Request - -**Header** - -Authorization: Bearer {accessToken} - -**Path Variable** - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| receiverId | 수신인 ID | Long | | N | 1 | - -### Response - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| items | 타임레터 목록 | Array | | N | | -| items[].timeLetterId | 타임레터 ID | Long | | N | 12 | -| items[].receiverName | 타임레터 수신인 이름 | String | | N | 박채연 | -| items[].sendAt | 발송 예정일 | LocalDate | | N | 2027-11-24 | -| items[].title | 타임레터 제목 | String | | N | 채연아 20번째 생일을 축하해 | -| items[].content | 타임레터 내용 전체 | String | | N | 네가 태어난 게 엊그제 같은데… | - -**Example** - -```jsx -{ - "items": [ - { - "timeLetterId": 12, - "receiverName": "박채연", - "sendAt": "2027-11-24", - "title": "채연아 20번째 생일을 축하해", - "content": "네가 태어난 게 엊그제 같은데 벌써 스무살이라니...", - }, - { - "timeLetterId": 11, - "receiverName": "박채연", - "sendAt": "2027-11-24", - "title": "항상 고맙고 사랑한다", - "content": "늘 밝게 웃어줘서 고마워. 너의 앞날이 행복하길...", - }, - { - "timeLetterId": 10, - "receiverName": "박채연", - "sendAt": "2027-11-24", - "title": "너의 스무 번째 봄을 축복해", - "content": "너의 모든 봄이 따뜻하길 바란다...", - } - ] -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 타임레터 목록 조회 성공 | -| 400 | 잘못된 요청 | -| 401 | 인증 실패 | -| 403 | 접근 권한 없음 | -| 404 | 수신인을 찾을 수 없음 | -| 500 | 서버 내부 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\236\220 \353\223\261\353\241\235 2f209829378780d986a8d594fac658f9.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\236\220 \353\223\261\353\241\235 2f209829378780d986a8d594fac658f9.md" deleted file mode 100644 index 5be22caa..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\213\240\354\236\220 \353\223\261\353\241\235 2f209829378780d986a8d594fac658f9.md" +++ /dev/null @@ -1,54 +0,0 @@ -# 수신자 등록 - -카테고리: User -설명: 수신인을 등록합니다/ -Method: POST -URL: /users/receivers -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -### Request - -**Header** - -Authorization: Bearer {accessToken} - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| name | 수신자 이름 | String | | N | 김지은 | -| phone | 전화번호 | String | | Y | 010-1234-1234 | -| relation | 사용자와의 관계 | String ENUM | | N | DAUGHTER | -| email | 이메일 | String | | Y | afternote123@naver.com | - -```jsx -{ - "name": "김지은", - "phone": "010-1234-1234", - "relation": "DAUGHTER", - "email": "jieun@naver.com" -} -``` - -### Response - -| **key** | **설명** | **value 타입** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | -| receiverId | 생성된 수신자 ID | Long | N | 1 | - -**Example** - -```jsx -{ - "receiverId": 1 -} -``` - -### Status - -| **status** | **response content** | -| --- | --- | -| 201 | 수신자 등록 성공 | -| 400 | 요청 값 검증 실패 | -| 401 | 인증 실패 | -| 500 | 서버 내부 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\240\225 2db09829378780938d0fc5846f42ed83.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\240\225 2db09829378780938d0fc5846f42ed83.md" deleted file mode 100644 index 1f634ab0..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\210\230\354\240\225 2db09829378780938d0fc5846f42ed83.md" +++ /dev/null @@ -1,42 +0,0 @@ -# 수정 - -카테고리: Time-Letters -Method: PATCH -URL: /time-letters/{timeLetterId} -header 포함 여부: 포함 O -백엔드 관리자: 영탁 조 -상태: 개발 완료 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235\353\262\210\355\230\270 \353\263\264\353\202\264\352\270\260 2d609829378780ddab3ec50f197deb33.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235\353\262\210\355\230\270 \353\263\264\353\202\264\352\270\260 2d609829378780ddab3ec50f197deb33.md" deleted file mode 100644 index 03143f01..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235\353\262\210\355\230\270 \353\263\264\353\202\264\352\270\260 2d609829378780ddab3ec50f197deb33.md" +++ /dev/null @@ -1,36 +0,0 @@ -# 이메일 인증번호 보내기 - -카테고리: Auth -설명: 이메일로 인증 번호를 보냅니다. -Method: POST -URL: /auth/email/send -header 포함 여부: 포함 X -백엔드 관리자: 황규운 -상태: 개발 완료 -프론트 관리자: 정일혁 - -### Request - -```java -{ - "email": "gka365@naver.com" -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "6자리 인증코드가 발송되었습니다.", - "data": null -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | "6자리 인증코드가 발송되었습니다." | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235\353\262\210\355\230\270 \355\231\225\354\235\270 2d609829378780fca359daef680d4d05.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235\353\262\210\355\230\270 \355\231\225\354\235\270 2d609829378780fca359daef680d4d05.md" deleted file mode 100644 index 6638c0a7..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235\353\262\210\355\230\270 \355\231\225\354\235\270 2d609829378780fca359daef680d4d05.md" +++ /dev/null @@ -1,37 +0,0 @@ -# 이메일 인증번호 확인 - -카테고리: Auth -설명: 받은 인증 번호로 인증을 합니다. -Method: POST -URL: /auth/email/verify -header 포함 여부: 포함 X -백엔드 관리자: 황규운 -상태: 개발 완료 -프론트 관리자: 정일혁 - -### Request - -```java -{ - "email": "gka365@naver.com", - "certificateCode": "123456" -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "휴대폰 번호 인증에 성공하였습니다.", - "data": null -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 휴대폰 번호 인증에 성공하였습니다. | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\236\204\354\213\234\354\240\200\354\236\245 \354\240\204\354\262\264 \354\202\255\354\240\234 2db0982937878060a687e3a255ff873a.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\236\204\354\213\234\354\240\200\354\236\245 \354\240\204\354\262\264 \354\202\255\354\240\234 2db0982937878060a687e3a255ff873a.md" deleted file mode 100644 index 2ac89104..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\236\204\354\213\234\354\240\200\354\236\245 \354\240\204\354\262\264 \354\202\255\354\240\234 2db0982937878060a687e3a255ff873a.md" +++ /dev/null @@ -1,42 +0,0 @@ -# 임시저장 전체 삭제 - -카테고리: Time-Letters -Method: DELETE -URL: /time-letters/temporary -header 포함 여부: 포함 O -백엔드 관리자: 영탁 조 -상태: 개발 완료 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\236\204\354\213\234\354\240\200\354\236\245 \354\240\204\354\262\264 \354\241\260\355\232\214 2db098293787803dafc8d4540399c50d.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\236\204\354\213\234\354\240\200\354\236\245 \354\240\204\354\262\264 \354\241\260\355\232\214 2db098293787803dafc8d4540399c50d.md" deleted file mode 100644 index 6ab89c4e..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\236\204\354\213\234\354\240\200\354\236\245 \354\240\204\354\262\264 \354\241\260\355\232\214 2db098293787803dafc8d4540399c50d.md" +++ /dev/null @@ -1,43 +0,0 @@ -# 임시저장 전체 조회 - -카테고리: Time-Letters -설명: List -Method: GET -URL: /time-letters/temporary -header 포함 여부: 포함 O -백엔드 관리자: 영탁 조 -상태: 개발 완료 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\240\204\354\262\264 \354\241\260\355\232\214 2db098293787807aaad6e938da5cc5b6.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\240\204\354\262\264 \354\241\260\355\232\214 2db098293787807aaad6e938da5cc5b6.md" deleted file mode 100644 index 6d038f33..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\240\204\354\262\264 \354\241\260\355\232\214 2db098293787807aaad6e938da5cc5b6.md" +++ /dev/null @@ -1,43 +0,0 @@ -# 전체 조회 - -카테고리: Time-Letters -설명: List -Method: GET -URL: /time-letters -header 포함 여부: 포함 O -백엔드 관리자: 영탁 조 -상태: 개발 완료 -프론트 관리자: 박경민 - -### Request - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | - -**Query parameter** - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| | | | | | | -| | | | | | | -| | | | | | | -| | | | | | | - -**Example** - -```jsx -{ - -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\243\274\352\260\204 \353\246\254\355\217\254\355\212\270 \354\241\260\355\232\214 2e509829378780d0bf10f8410dbd81cd.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\243\274\352\260\204 \353\246\254\355\217\254\355\212\270 \354\241\260\355\232\214 2e509829378780d0bf10f8410dbd81cd.md" deleted file mode 100644 index b2aad5aa..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\243\274\352\260\204 \353\246\254\355\217\254\355\212\270 \354\241\260\355\232\214 2e509829378780d0bf10f8410dbd81cd.md" +++ /dev/null @@ -1,65 +0,0 @@ -# 주간 리포트 조회 - -카테고리: Mind-Record -Method: GET -URL: /mind-records/weekly -header 포함 여부: 포함 O -상태: 시작 전 - -![스크린샷 2026-01-11 17.28.06.png](%EC%A3%BC%EA%B0%84%20%EB%A6%AC%ED%8F%AC%ED%8A%B8%20%EC%A1%B0%ED%9A%8C/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-01-11_17.28.06.png) - -선택한 주차에 해당하는 사용자의 기록 목록을 조회하는 API입니다. - -해당 주차에 작성된 기록들을 반환합니다. - -### Request - -**Query parameter** - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| **`year`** | 조회 연도 | Integer | | X | 2025 | -| **`week`** | 연중 주차 | Integer | ISO week 기준 | X | 46 | - -### Response - -records (기록 다시 읽기) - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| records | 기록 다시 읽기 목록 | Array | | X | | -| records.recordId | 기록 ID | Long | | X | 12 | -| records.title | 기록 제목 | String | | X | 오늘 하루, 누구에게 가장 고마웠나요? | -| records.content | 기록 내용 | String | | X | 아무 말 없이 그저 나의 곁을 지켜주는 아내가 너무 고맙다. | -| records.date | 기록 날짜 | String | yyyy-MM-dd | X | 2025-10-09 | -| records.type | 기록 유형 | String | DAILY_QUESTION / AFTERNOTE / DEEP_THOUGHT | | DAILY_QUESTION | - -**Example** - -```jsx -{ - "records": [ - { - "recordId": 12, - "type": "DAILY_QUESTION", - "title": "오늘 하루, 누구에게 가장 고마웠나요?", - "contentPreview": "아무 말 없이 그저 나의 곁을 지켜주는 아내가 너무 고맙다.", - "date": "2025-10-09" - }, - { - "recordId": 13, - "type": "AFTERNOTE", - "title": "오늘의 기록", - "contentPreview": "날씨가 좋아서 기분이 한결 나아졌다.", - "date": "2025-10-11" - } - ] -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 주간 리포트 조회 성공 | -| 400 | 잘못된 요청 또는 인증 오류 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\243\274\352\260\204 \353\246\254\355\217\254\355\212\270 \354\241\260\355\232\214/\354\212\244\355\201\254\353\246\260\354\203\267_2026-01-11_17.28.06.png" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\243\274\352\260\204 \353\246\254\355\217\254\355\212\270 \354\241\260\355\232\214/\354\212\244\355\201\254\353\246\260\354\203\267_2026-01-11_17.28.06.png" deleted file mode 100644 index d65eb52f..00000000 Binary files "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\243\274\352\260\204 \353\246\254\355\217\254\355\212\270 \354\241\260\355\232\214/\354\212\244\355\201\254\353\246\260\354\203\267_2026-01-11_17.28.06.png" and /dev/null differ diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\271\264\354\271\264\354\230\244 \353\241\234\352\267\270\354\235\270 2e6098293787803f9d6acc4abb82e1ac.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\271\264\354\271\264\354\230\244 \353\241\234\352\267\270\354\235\270 2e6098293787803f9d6acc4abb82e1ac.md" deleted file mode 100644 index d679d18f..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\271\264\354\271\264\354\230\244 \353\241\234\352\267\270\354\235\270 2e6098293787803f9d6acc4abb82e1ac.md" +++ /dev/null @@ -1,43 +0,0 @@ -# 카카오 로그인..? - -카테고리: Auth -Method: POST -URL: /auth/social/login -header 포함 여부: 포함 X -백엔드 관리자: 황규운 -상태: 개발 진행 중 -프론트 관리자: 정일혁 - -### Request - -body - -```jsx -{ - "accessToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "로그인에 성공하였습니다.", - "data": { - "refreshToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", - "accessToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", - - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | - -[하는법..?](%EC%B9%B4%EC%B9%B4%EC%98%A4%20%EB%A1%9C%EA%B7%B8%EC%9D%B8/%ED%95%98%EB%8A%94%EB%B2%95%202f80982937878056b67de456a4a44fc6.md) \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\271\264\354\271\264\354\230\244 \353\241\234\352\267\270\354\235\270/\355\225\230\353\212\224\353\262\225 2f80982937878056b67de456a4a44fc6.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\271\264\354\271\264\354\230\244 \353\241\234\352\267\270\354\235\270/\355\225\230\353\212\224\353\262\225 2f80982937878056b67de456a4a44fc6.md" deleted file mode 100644 index f52ff155..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\354\271\264\354\271\264\354\230\244 \353\241\234\352\267\270\354\235\270/\355\225\230\353\212\224\353\262\225 2f80982937878056b67de456a4a44fc6.md" +++ /dev/null @@ -1,27 +0,0 @@ -# 하는법..? - -카카오 OAuth 2.0을 안드로이드와 서버에 연결하는 구조는 크게 두 가지 방식으로 나뉩니다. 보안과 데이터 관리 측면에서 **서버 연동 방식**이 주로 권장됩니다. - -**1. 주요 연동 아키텍처 (권장 방식)** - -안드로이드 앱에서 직접 카카오와 통신하기보다, 앱이 받은 **Access Token**을 서버로 전달하여 처리하는 구조가 일반적입니다. - -- **Step 1 (안드로이드):** [**Kakao SDK for Android**](https://developers.kakao.com/docs/latest/en/android/getting-started)를 사용하여 로그인을 수행하고, 카카오로부터 `Access Token`을 발급받습니다. -- **Step 2 (안드로이드 → 서버):** 발급받은 `Access Token`을 자체 API 서버로 전송합니다. -- **Step 3 (서버):** 서버는 전달받은 토큰으로 카카오 인증 서버에 사용자 정보를 요청하여 검증합니다. -- **Step 4 (서버 → 안드로이드):** 검증이 완료되면 서버에서 자체 JWT 등을 생성하여 앱에 반환하며 로그인을 마무리합니다. - -**2. 준비 사항** - -- [**Kakao Developers](https://developers.kakao.com/) 설정:** - - 애플리케이션 등록 후 **네이티브 앱 키**(안드로이드용)와 **REST API 키**(서버용)를 확보하세요. - - 안드로이드 플랫폼 등록 시 **키 해시(Key Hash)** 값을 반드시 등록해야 SDK가 작동합니다. -- **안드로이드 설정:** `build.gradle`에 SDK 의존성을 추가하고 `KakaoSdk.init`을 통해 초기화합니다. -- **서버 설정:** Spring Boot 등을 사용한다면 `OAuth2 Client` 라이브러리를 활용해 카카오 API 엔드포인트를 구성할 수 있습니다. - -**3. 핵심 API 활용 (Android SDK)** - -- **`UserApiClient.instance.loginWithKakaoTalk`**: 카카오톡 앱을 통한 간편 로그인. -- **`UserApiClient.instance.loginWithKakaoAccount`**: 카카오 계정 직접 입력 로그인 (카톡 미설치 시). - -더 구체적으로 어떤 **서버 프레임워크**(Spring Boot, Node.js 등)를 사용하시나요? 언어를 알려주시면 맞춤 코드를 가이드해 드릴 수 있습니다. \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\206\240\355\201\260 \354\236\254\353\260\234\352\270\211 2e6098293787804aaac7cc92d644b26e.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\206\240\355\201\260 \354\236\254\353\260\234\352\270\211 2e6098293787804aaac7cc92d644b26e.md" deleted file mode 100644 index b926d6a6..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\206\240\355\201\260 \354\236\254\353\260\234\352\270\211 2e6098293787804aaac7cc92d644b26e.md" +++ /dev/null @@ -1,39 +0,0 @@ -# 토큰 재발급 - -카테고리: Auth -설명: 토큰을 다시 받습니다. -Method: POST -URL: /auth/reissue -header 포함 여부: 포함 X -백엔드 관리자: 황규운 -상태: 개발 완료 -프론트 관리자: 정일혁 - -### Request - -```java -{ - "refreshToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "토큰 재발급을 성공하였습니다.", - "data": { - "refreshToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", - "accessToken": "safasfskldfjasdkgwr34tklgfdldfsdckl...", - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\221\270\354\213\234 \354\225\214\353\246\274 \354\203\201\355\203\234 \354\241\260\355\232\214 2ea09829378780f4b662d9b4f88b2bc7.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\221\270\354\213\234 \354\225\214\353\246\274 \354\203\201\355\203\234 \354\241\260\355\232\214 2ea09829378780f4b662d9b4f88b2bc7.md" deleted file mode 100644 index 945816aa..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\221\270\354\213\234 \354\225\214\353\246\274 \354\203\201\355\203\234 \354\241\260\355\232\214 2ea09829378780f4b662d9b4f88b2bc7.md" +++ /dev/null @@ -1,62 +0,0 @@ -# 푸시 알림 상태 조회 - -카테고리: User -Method: GET -URL: /users/push-settings -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -로그인한 사용자의 푸시 알림 수신 설정의 현재 상태를 조회합니다. - -각 알림 종류별로 수신 여부(true/false)를 반환합니다. - -### Request - -**Header** -Authorization: Bearer {accessToken} - -### Response - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| timeLetter | 타임레터 푸시 알림 수신 여부 | boolean | | X | true | -| mindRecord | 마음의 기록 푸시 알림 수신 여부 | boolean | | X | false | -| afterNote | 애프터노트 푸시 알림 수신 여부 | boolean | | X | true | - -**Example** - -```jsx -{ - "timeLetter": true, - "mindRecord": false, - "afterNote": true -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | -| 401 | 인증되지 않은 사용자 | - -**200 OK** - -```java -{ - "timeLetter": true, - "mindRecord": false, - "afterNote": true -} -``` - -**401 Unauthorized** - -```java -{ - "code": 999, - "message": "인증이 필요합니다. 로그인해주세요." -} -``` \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\221\270\354\213\234 \354\225\214\353\246\274 \354\204\244\354\240\225 \353\263\200\352\262\275 2eb098293787804bb219dfbd1ae4de9d.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\221\270\354\213\234 \354\225\214\353\246\274 \354\204\244\354\240\225 \353\263\200\352\262\275 2eb098293787804bb219dfbd1ae4de9d.md" deleted file mode 100644 index 37f767e0..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\221\270\354\213\234 \354\225\214\353\246\274 \354\204\244\354\240\225 \353\263\200\352\262\275 2eb098293787804bb219dfbd1ae4de9d.md" +++ /dev/null @@ -1,76 +0,0 @@ -# 푸시 알림 설정 변경 - -카테고리: User -Method: PATCH -URL: /users/push-settings -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -로그인한 사용자의 **푸시 알림 수신 설정을 변경합니다.** - -클라이언트는 변경된 토글 상태를 포함하여 **각 알림 종류의 최종 수신 여부(true/false)를 서버로 전달**하며, 서버는 전달받은 값을 그대로 저장합니다. - -### Request - -Header - -| **key** | **value** | -| --- | --- | -| Authorization | Bearer {accessToken} | - -Body - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| timeLetter | 타임레터 푸시 알림 수신 여부 | boolean | | X | false | -| mindRecord | 마음의 기록 푸시 알림 수신 여부 | boolean | | X | true | -| afterNote | 애프터노트 푸시 알림 수신 여부 | boolean | | X | false | - -### Response - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| timeLetter | 타임레터 푸시 알림 수신 여부 - | boolean | X | false | | -| -mindRecord - | -마음의 기록 푸시 알림 수신 여부 - | -boolean - | -X - | -true - | | -| -afterNote - | -애프터노트 푸시 알림 수신 여부 - | -boolean - | -X - | -false - | | -| | | | | | | - -**Example** - -```jsx -{ - "timeLetter": false, - "mindRecord": true, - "afterNote": false -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 설정 변경 성공 | -| 400 | 잘못된 요청 | -| 401 | 인증되지 않은 사용자 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\224\204\353\241\234\355\225\204 \354\210\230\354\240\225 2d90982937878035b76ff66eb064ccc4.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\224\204\353\241\234\355\225\204 \354\210\230\354\240\225 2d90982937878035b76ff66eb064ccc4.md" deleted file mode 100644 index f15c6e47..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\224\204\353\241\234\355\225\204 \354\210\230\354\240\225 2d90982937878035b76ff66eb064ccc4.md" +++ /dev/null @@ -1,42 +0,0 @@ -# 프로필 수정 - -카테고리: User -Method: PATCH -URL: /users/me -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -### Request - -Header - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| Authorization | 액세스 토큰 | String | Bearer | X | Bearer eyJhbGciOi… | - -### Response - -| **key** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| name | 사용자 이름 | String | | O | 김소희 | -| phone | 연락처 | String | | O | 01012345678 | -| profileImageUrl | 프로필 이미지 URL | String | | O | https://cdn.example.com/profile/1.png | -- 모든 필드는 선택 값입니다. - -**Example** - -```jsx -{ - "name": "김소희", - "profileImageUrl": "https://cdn.example.com/profile/1.png" -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 프로필 수정 성공 | -| 400 | 잘못된 요청 | -| 401 | 인증 필요 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\224\204\353\241\234\355\225\204 \354\241\260\355\232\214 2d9098293787803f83d5c14f7b3144c1.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\224\204\353\241\234\355\225\204 \354\241\260\355\232\214 2d9098293787803f83d5c14f7b3144c1.md" deleted file mode 100644 index 929d65ed..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\224\204\353\241\234\355\225\204 \354\241\260\355\232\214 2d9098293787803f83d5c14f7b3144c1.md" +++ /dev/null @@ -1,48 +0,0 @@ -# 프로필 조회 - -카테고리: User -설명: 프로필 수정 진입 시 기존 정보를 표시합니다. -Method: GET -URL: /users/me -header 포함 여부: 포함 O -백엔드 관리자: 김소희 -상태: 개발 완료 - -### Request - -Header - -| key | 설명 | value 타입 | 옵션 | Nullable | 예시 | -| --- | --- | --- | --- | --- | --- | -| Authorization | 액세스 토큰 | String | Bearer | X | Bearer eyJhbGciOi… | - -### Response - -Body - -| **ey** | **설명** | **value 타입** | **옵션** | **Nullable** | **예시** | -| --- | --- | --- | --- | --- | --- | -| name | 사용자 이름 | String | | X | 김소희 | -| email | 이메일 | String | | X | afternote@example.com | -| phone | 연락처 | String | | O | 01012345678 | -| profileImageUrl | 프로필 이미지 URL | String | | O | https://cdn.example.com/profile/1.png | -- phone, profileImageUrl은 미설정 시 null일 수 있습니다. - -**Example** - -```jsx -{ - "name": "김소희", - "email": "afternote@example.com", - "phone": "01012345678", - "profileImageUrl": "https://cdn.example.com/profile/1.png" -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | 프로필 조회 성공 | -| 400 | 잘못된 요청 또는 인증 오류 | -| 401 | 인증 필요 | \ No newline at end of file diff --git "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\232\214\354\233\220 \352\260\200\354\236\205 2d609829378780eeb84bf5af35364f88.md" "b/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\232\214\354\233\220 \352\260\200\354\236\205 2d609829378780eeb84bf5af35364f88.md" deleted file mode 100644 index abf40d29..00000000 --- "a/docs/API specification from notion/\354\227\224\353\223\234\355\217\254\354\235\270\355\212\270/\355\232\214\354\233\220 \352\260\200\354\236\205 2d609829378780eeb84bf5af35364f88.md" +++ /dev/null @@ -1,44 +0,0 @@ -# 회원 가입 - -카테고리: Auth -설명: 이메일,비밀 번호, 휴대폰 번호로 회원가입을 합니다. -Method: POST -URL: /auth/sign-up -header 포함 여부: 포함 X -백엔드 관리자: 황규운 -상태: 개발 완료 -프론트 관리자: 정일혁 - -비밀번호 확인은 프론트 측에서 처리 가능한 걸로 아는데 그걸로 부탁드립니다. - -### Request - -```java -{ - "email": "user@example.com", - "password": "strongPassword123!", - "nickname": "멋진개발자", - "profileUrl": "https://s3.ap-northeast-2.amazonaws.com/bucket/profile/image.jpg" -} -``` - -### Response - -```jsx -{ - "status": 200, - "code": 0, - "message": "회원가입이 완료되었습니다.", - "data": { - "userId": 1, - "email": "user@example.com", - } -} -``` - -### Status - -| status | response content | -| --- | --- | -| 200 | | -| 400 | | \ No newline at end of file diff --git a/docs/API_WORK_ALLOCATION.md b/docs/API_WORK_ALLOCATION.md deleted file mode 100644 index e3aafea0..00000000 --- a/docs/API_WORK_ALLOCATION.md +++ /dev/null @@ -1,216 +0,0 @@ -# API 작업 배분 (구조도 기준) - -> **원칙**: 아래 작업은 전부 **사진 구조도(Clean Architecture)** 에 맞춰 진행한다. -> - **Data**: API → DataSource → RepositoryImpl, Entity, Mapper(매핑: DTO/Entity → Model) -> - **Domain**: Repository(인터페이스), UseCase, Model -> - **UI**: ViewModel, View, UIModel -> - **의존성 방향**: Presentation → Domain ← Data (의존성 주입으로 연결) - ---- - -## 1. 정일혁 - -### 1-1. 모든 API의 DTO → RepositoryImpl (29개 전체) - -**담당 계층**: **Data Layer** -**포함 API**: Auth, User, Afternote, Time-Letters, Received, Mind-Record, 기타 전부 (29개) - -**할 일**: -| 구성요소 | 설명 | -|----------|------| -| **API** | Retrofit 등 서비스/엔드포인트 정의 (또는 기존 연동) | -| **DataSource** | API 호출, 응답 수신 | -| **Entity** | Data 계층 내부 데이터 구조 (필요 시) | -| **Mapper** | DTO/Entity → **Domain Model** 매핑 (nonnull·기본값 등 정제) | -| **RepositoryImpl** | Domain의 `Repository` **인터페이스 구현**, DataSource·Mapper 사용 | - -> **중요**: `Repository` **인터페이스**는 Domain 계층이며, 각 Feature의 Domain 담당자(UseCase 작성자)가 정의합니다. 정일혁은 **RepositoryImpl만 구현**합니다. -> 예외: Auth와 User는 정일혁이 Domain까지 담당하므로, 해당 Feature의 Repository 인터페이스도 정일혁이 정의합니다. - -> **정일혁이 담당하지 않는 것** (Auth·User 제외): -> Time-Letters, Received, Mind-Record, Afternote 등 **그 외 모든 Feature**에 대해서는 **Repository 인터페이스, UseCase, ViewModel, UIModel을 작성하지 않습니다.** 해당 계층은 각 Feature 담당자(박경민, 안현지)가 담당합니다. 정일혁의 역할은 Data 계층(API·DataSource·DTO·Mapper·RepositoryImpl)만입니다. - -> 요약: DTO를 nonnull 등으로 정제하는 **반복적인 Data 계층**을 한 명이 담당. - ---- - -### 1-2. Auth API (8개): UseCase → ViewModel + UIModel - -**담당 계층**: **Domain + UI (Auth만)** -**대상 API** (참고: `docs/API specification from notion/엔드포인트` 및 동일 폴더 CSV): - -1. 이메일 인증번호 보내기 `POST /auth/email/send` -2. 이메일 인증번호 확인 `POST /auth/email/verify` -3. 회원 가입 `POST /auth/sign-up` -4. 로그인 `POST /auth/login` -5. 토큰 재발급 `POST /auth/reissue` -6. 카카오 로그인 `POST /auth/social/login` -7. 비밀번호 변경 `POST /auth/password/change` -8. 로그아웃 `POST /auth/logout` - -**할 일**: -| 구성요소 | 설명 | -|----------|------| -| **Repository** | Auth `Repository` 인터페이스 정의 (Domain 계층) | -| **UseCase** | Auth `Repository` 인터페이스 사용, 비즈니스 로직 수행 | -| **ViewModel** | UseCase 호출, UI 상태·이벤트 관리 | -| **UIModel** | 화면에 필요한 데이터만 가공, View/ViewModel에서 사용 | - -> Auth는 중요도가 높아, **DTO→RepoImpl(1-1) + Repository 인터페이스 정의 + UseCase→ViewModel+UIModel** 까지 한 명이 담당. - ---- - -### 1-3. User API (10개): UseCase → ViewModel + UIModel - -**담당 계층**: **Domain + UI (User)** -**대상 API** (참고: `docs/API specification from notion/엔드포인트` 및 동일 폴더 CSV, User 카테고리): - -1. 프로필 조회 `GET /users/me` -2. 프로필 수정 `PATCH /users/me` -3. 푸시 알림 상태 조회 `GET /users/push-settings` -4. 푸시 알림 설정 변경 `PATCH /users/push-settings` -5. 수신인 목록 조회 `GET /users/receivers` -6. 수신자 등록 `POST /users/receivers` -7. 수신인 상세 조회 `GET /users/receivers/{receiverId}` -8. 수신인별 데일리 질문 답변 목록 조회 `GET /users/receivers/{receiverId}/daily-questions` -9. 수신인별 타임레터 목록 조회 `GET /users/receivers/{receiverId}/time-letters` -10. 수신인별 애프터노트 목록 조회 `GET /users/receivers/{receiverId}/after-notes` - -**할 일**: -| 구성요소 | 설명 | -|----------|------| -| **Repository** | User `Repository` 인터페이스 정의 (Domain 계층) | -| **UseCase** | User `Repository` 인터페이스 사용, 프로필·푸시·연결계정·수신인 비즈니스 로직 | -| **ViewModel** | UseCase 호출, UI 상태·이벤트 관리 | -| **UIModel** | 화면에 필요한 데이터만 가공, View/ViewModel에서 사용 | - -> User API 전체(10개) **DTO→RepoImpl(1-1) + Repository 인터페이스 정의 + UseCase→ViewModel+UIModel** 까지 정일혁이 담당. - ---- - -## 2. 박경민 - -### 2-1. Time-Letters API (7개): Repository → UseCase → ViewModel + UIModel - -**담당 계층**: **Domain + UI** -**전제**: Time-Letters `Repository`(인터페이스)는 박경민이 정의, `RepositoryImpl`은 정일혁이 DTO→RepoImpl 작업에서 이미 구현. - -**대상 API** (Notion 엔드포인트 명세 기준): -1. 전체 조회 `GET /time-letters` -2. 단일 조회, 임시저장 불러오기 `GET /time-letters/{timeLetterId}` -3. 등록 `POST /time-letters` -4. 임시저장 전체 조회 `GET /time-letters/temporary` -5. 단일, 다건 (종류 무관) 삭제 `POST /time-letters/delete` -6. 임시저장 전체 삭제 `DELETE /time-letters/temporary` -7. 수정 `PATCH /time-letters/{timeLetterId}` - -**할 일**: -| 구성요소 | 설명 | -|----------|------| -| **Repository** | Time-Letters `Repository` 인터페이스 정의 (Domain 계층) | -| **UseCase** | Time-Letters `Repository` 사용, 비즈니스 로직 | -| **ViewModel** | UseCase 호출, 상태·이벤트 처리 | -| **UIModel** | Time-Letters 화면용 모델 정의·가공 | - ---- - -### 2-2. Received API (3개): Repository → UseCase → ViewModel + UIModel - -**담당 계층**: **Domain + UI** -**전제**: Received `Repository`(인터페이스)는 박경민이 정의, `RepositoryImpl`은 정일혁이 DTO→RepoImpl 작업에서 이미 구현. - -**대상 API** (Notion 엔드포인트 명세 기준): -1. Mind-Record 조회 `GET /receiver/mind-records/{등록자Id}` -2. Time-Letter 조회 `GET /receiver/time-letters/{등록자Id}` -3. After-Note 조회 `GET /receiver/afternotes/{등록자Id}` - -**할 일**: -| 구성요소 | 설명 | -|----------|------| -| **Repository** | Received `Repository` 인터페이스 정의 (Domain 계층) | -| **UseCase** | Received `Repository` 사용, 수신자·연결 데이터 비즈니스 로직 | -| **ViewModel** | UseCase 호출, 수신자 화면 상태·이벤트 | -| **UIModel** | Received 화면용 모델 | - ---- - -## 3. 안현지 - -### 3-1. Mind-Record API (7개): Repository → UseCase → ViewModel + UIModel - -**담당 계층**: **Domain + UI** -**전제**: Mind-Record `Repository`(인터페이스)는 안현지가 정의, `RepositoryImpl`은 정일혁이 DTO→RepoImpl 작업에서 이미 구현. - -**대상 API** (Notion 엔드포인트 명세 기준): -1. 마음의기록 목록 조회 `GET /mind-records` -2. 마음의기록 단건 수정화면 조회 `GET /mind-records/{recordId}` -3. 마음의기록 작성 `POST /mind-records` -4. 마음의기록 수정 `PATCH /mind-records/{recordId}` -5. 마음의기록 삭제 `DELETE /mind-records/{recordId}` -6. 주간 리포트 조회 `GET /mind-records/weekly` -7. 데일리 질문 조회 `GET /daily-question` - -**할 일**: -| 구성요소 | 설명 | -|----------|------| -| **Repository** | Mind-Record `Repository` 인터페이스 정의 (Domain 계층) | -| **UseCase** | Mind-Record `Repository` 사용, 기록·캘린더·일기·리포트 로직 | -| **ViewModel** | UseCase 호출, Mind-Record 화면 상태·이벤트 | -| **UIModel** | Mind-Record 화면용 모델 | - ---- - -### 3-2. Afternote API (5개): Repository → UseCase → ViewModel + UIModel - -**담당 계층**: **Domain + UI** -**전제**: Afternote `Repository`(인터페이스)는 안현지가 정의, `RepositoryImpl`은 정일혁이 DTO→RepoImpl 작업에서 이미 구현. - -**대상 API** (Notion 엔드포인트 명세 기준): -1. 모든 afternote 목록 `GET /afternotes?category=&page=&size=` -2. afternote 상세 목록 `GET /afternotes/{afternote_id}` -3. afternote 생성 `POST /afternotes` -4. afternote 수정 `PATCH /afternotes/{afternote_id}` -5. afternote 삭제 `DELETE /afternotes/{afternote_id}` - -**할 일**: -| 구성요소 | 설명 | -|----------|------| -| **Repository** | Afternote `Repository` 인터페이스 정의 (Domain 계층) | -| **UseCase** | Afternote `Repository` 사용, afternote CRUD·목록 로직 | -| **ViewModel** | UseCase 호출, Afternote 화면 상태·이벤트 | -| **UIModel** | Afternote 화면용 모델 | - ---- - -## 4. 구조도 대조표 - -| 구분 | Data (API·DataSource·RepoImpl·Entity·Mapper) | Domain (Repository·UseCase·Model) | UI (ViewModel·View·UIModel) | -|------|-----------------------------------------------|-----------------------------------|------------------------------| -| **정일혁** | 29개 API 전체 (RepositoryImpl 포함) | Auth 8개, User 10개 (Repository·UseCase·Model) | Auth 8개, User 10개 (ViewModel·UIModel) | -| **박경민** | — | Time-Letters 7, Received 3 (Repository·UseCase·Model) | Time-Letters 7, Received 3 (ViewModel·UIModel) | -| **안현지** | — | Mind-Record 7, Afternote 5 (Repository·UseCase·Model) | Mind-Record 7, Afternote 5 (ViewModel·UIModel) | - ---- - -## 5. 참고 - -- **API 상세**: `docs/API specification from notion/엔드포인트` 및 동일 폴더의 `엔드포인트 2d50982937878141a5cce5c9b5d1e61f_all.csv` (Notion 명세만 사용) -- **아키텍처·의존성**: `.cursor/rules/tech-stack/architecture.mdc` -- **백엔드 명세가 잘 되어, 여러 Repository 조합이 필요 없으면** UseCase를 건너뛰고 ViewModel+UIModel만 분배하는 경우도 팀 합의 하에 가능. - ---- - -## 6. API 구현 기준 및 배포 관련 - -### API 구현 기준 -- **참고 자료**: `docs/API specification from notion/엔드포인트` 및 Swagger(OpenAPI)를 참고하여 구현할 수 있습니다. -- **불일치 시**: 명세서·Swagger와 요청이 다를 경우 팀 합의 또는 백엔드 담당자 확인을 권장합니다. -- **테스트**: Swagger UI에서 API 테스트가 가능하므로 구현 전 테스트를 권장합니다. - -### 프론트엔드 배포 URL 제공 -- **배포 시 백엔드에 프론트엔드 배포 URL 제공 필요** -- **사용 목적**: - - **CORS 설정**: 백엔드에서 프론트엔드 도메인을 허용 목록에 추가 - - **OAuth 리다이렉트**: 카카오 로그인 등 OAuth 인증 후 프론트엔드로 리다이렉트 - - **이메일 인증 링크**: 이메일 인증 완료 후 프론트엔드로 리다이렉트 -- **제공 시기**: 프론트엔드 배포 완료 후 백엔드 담당자에게 전달 diff --git a/docs/ARCHITECTURE_CONVENTIONS.md b/docs/ARCHITECTURE_CONVENTIONS.md new file mode 100644 index 00000000..af3210db --- /dev/null +++ b/docs/ARCHITECTURE_CONVENTIONS.md @@ -0,0 +1,119 @@ +# 아키텍처 컨벤션 + +이 프로젝트의 레이어 구조, 네이밍, 의존 방향 규칙을 정리한다. +Android/Kotlin 공식 컨벤션을 최우선으로 하며, 그 안에서 클린 아키텍처를 적용한다. + +## 레이어 구조 + +``` +presentation → domain ← data +``` + +- **domain**은 독립적이다. data, presentation을 import하지 않는다. +- **data**는 domain을 알고, domain이 정한 인터페이스(Repository)를 구현한다. +- **presentation**은 domain을 알고, UseCase를 통해 비즈니스 로직을 호출한다. + +## 패키지 구조 + +``` +com.kuit.afternote.feature.{기능명} +├── data +│ ├── dto # 서버 요청/응답 DTO (@Serializable) +│ ├── mapper # DTO ↔ Domain 변환 (확장 함수) +│ ├── repositoryimpl # Repository 구현체 +│ └── service # Retrofit API interface +├── domain +│ ├── model # 순수 도메인 모델 (data class) +│ ├── repository # Repository 인터페이스 +│ └── usecase # UseCase +└── presentation + ├── uimodel # UI 전용 모델 + └── viewmodel # ViewModel +``` + +## UseCase + +- **모든 API에 UseCase를 둔다.** 단순 CRUD라도 예외 없이 UseCase를 거친다. +- 네이밍: `동사(현재형) + 명사 + UseCase` + - 예: `CreateSocialAfternoteUseCase`, `GetAfternoteDetailUseCase` +- `operator fun invoke`로 구현하여 함수처럼 호출한다. +- **무상태(Stateless)**: 내부에 가변 변수를 두지 않는다. +- ViewModel은 Repository를 직접 주입받지 않고, UseCase만 주입받는다. + +```kotlin +class GetUserUseCase @Inject constructor( + private val repository: UserRepository, +) { + suspend operator fun invoke(userId: String): Result = + repository.getUser(userId) +} +``` + +## 도메인 모델 (Domain Model) + +- 순수 Kotlin `data class`로 작성한다. +- **금지 사항:** + - `@Serializable`, `@SerialName` 등 직렬화 어노테이션 + - `@Entity` 등 DB 어노테이션 + - `Parcelable` 구현 + - `android.*`, `retrofit2.*` 등 프레임워크 import +- DTO가 변해도 도메인 모델은 안정적으로 유지되어야 한다. + +```kotlin +// ✅ domain/model +data class AfternoteDetail( + val id: Long, + val title: String, + val timestamps: AfternoteDetailTimestamps, +) + +// ❌ domain/model에 직렬화 어노테이션 사용 +data class AfternoteDetail( + @SerialName("id") val id: Long, +) +``` + +## 매퍼 (Mapper) + +- **위치**: data 레이어(`data/mapper/`) 또는 presentation 레이어에 둔다. domain에는 두지 않는다. +- **방식**: 확장 함수를 사용한다. +- **네이밍**: + - domain → DTO: `toDto()` + - DTO → domain: `toDomain()` + +```kotlin +// data/mapper/AfternoteInputMapper.kt +fun AfternotePlaylistInput.toDto(): AfternotePlaylist = + AfternotePlaylist( + profilePhoto = profilePhoto, + atmosphere = atmosphere, + ) +``` + +## DTO (Data Transfer Object) + +- data 레이어에만 위치한다(`data/dto/`). +- `@Serializable`, `@SerialName`은 DTO에서만 사용한다. +- 생활체조 원칙 중 3번(원시값 포장), 7번(인스턴스 변수 제한), 8번(일급 컬렉션), 9번(getter/setter 금지)은 DTO에 한해 완화한다. + +## Repository + +- **인터페이스**: `domain/repository/`에 위치. data 레이어를 import하지 않는다. +- **구현체**: `data/repositoryimpl/`에 위치. DTO → domain 변환을 여기서 한다. +- 파라미터가 3개 이상이면 Input 객체(`domain/model/`)로 묶는다. + +```kotlin +// ✅ Input 객체 사용 +suspend fun createSocial(input: CreateSocialInput): Result + +// ❌ 파라미터 나열 +suspend fun createSocial( + title: String, + processMethod: String, + actions: List, + leaveMessage: String?, + credentialsId: String?, + credentialsPassword: String?, + receiverIds: List, +): Result +``` diff --git a/docs/AUTH_API_TEST_CHECKLIST.md b/docs/AUTH_API_TEST_CHECKLIST.md deleted file mode 100644 index 848f5236..00000000 --- a/docs/AUTH_API_TEST_CHECKLIST.md +++ /dev/null @@ -1,114 +0,0 @@ -# Auth API 테스트 체크리스트 (정일혁) - -> **담당 범위**: Auth API 4개 (이메일 인증번호 발송, 이메일 인증번호 확인, 회원가입, 로그인) -> **테스트 환경**: 실제 서버 (`https://afternote.kro.kr/`) - ---- - -## 📋 테스트 전 준비사항 - -### 1. Logcat 필터 설정 -- [ ] Logcat 필터: `tag:OkHttp` 또는 `package:com.kuit.afternote` - -### 2. 테스트 범위 -- 실제 서버로 API 호출 후 UI 동작 및 Logcat 확인 -- 단위 테스트는 별도로 실행 (`./gradlew test`) - ---- - -## ✅ 1. 이메일 인증번호 발송 (`POST /auth/email/send`) - -### 테스트 (UI + Logcat 확인) -- [ ] 회원가입 화면에서 이메일 주소 입력 (예: `test@example.com`) -- [ ] "인증번호 받기" 버튼 클릭 -- [ ] **Logcat 확인**: - - [ ] `POST /auth/email/send` 요청 로그 - - [ ] 요청 본문: `{"email":"test@example.com"}` - - [ ] 응답: `200 OK` - - [ ] 응답 메시지: `"이메일 인증번호가 발송되었습니다"` -- [ ] **UI 확인**: - - [ ] 성공 메시지가 화면에 표시되는지 확인 - - [ ] 인증번호 입력 필드에 실제로 입력 가능한지 확인 (키보드로 숫자 입력 테스트) - - [ ] 에러 발생 시 에러 메시지가 화면에 표시되는지 확인 -- [ ] 실제 이메일로 인증번호 수신 확인 (서버가 이메일 발송 기능이 있는 경우) - ---- - -## ✅ 2. 이메일 인증번호 확인 (`POST /auth/email/verify`) - -### 테스트 -- [ ] 이메일 인증번호 발송 성공 후, 인증번호 입력 (실제 서버에서 받은 번호 또는 테스트용) -- [ ] "다음" 버튼 클릭 -- [ ] **Logcat 확인**: - - [ ] `POST /auth/email/verify` 요청 로그 - - [ ] 응답: `200 OK` -- [ ] **UI 확인**: - - [ ] 다음 단계(주민등록번호 입력 화면)로 이동했는지 확인 - - [ ] 인증 실패 시 에러 메시지 표시 확인 - - [ ] 인증 성공 시 에러 메시지가 표시되지 않는지 확인 - ---- - -## ✅ 3. 회원가입 (`POST /auth/sign-up`) - -### 테스트 -- [ ] 회원가입 플로우 진행 (이메일 인증 → 주민등록번호 → 비밀번호) -- [ ] 회원가입 완료 버튼 클릭 -- [ ] **Logcat 확인**: `POST /auth/sign-up` 요청/응답 확인 -- [ ] **UI 확인**: 프로필 설정 화면으로 이동, 실패 시 에러 메시지 표시 확인 - ---- - -## ✅ 4. 로그인 (`POST /auth/login`) - -### 테스트 -- [ ] SplashScreen → "로그인하기" 클릭 후 로그인 화면 진입 확인 -- [ ] 이메일/비밀번호 입력 후 "로그인" 버튼 클릭 -- [ ] **Logcat 확인**: `POST /auth/login` 요청/응답, AccessToken/RefreshToken 확인 -- [ ] **UI 확인**: 메인 화면으로 이동, 실패 시 에러 메시지 표시 확인 - ---- - -## 🔍 추가 확인 사항 - -### 에러 처리 테스트 -- [ ] 이메일 미입력 시 "인증번호 받기" 클릭 → 에러 메시지 표시 확인 -- [ ] 잘못된 인증번호 입력 시 "다음" 클릭 → 에러 메시지 표시 확인 -- [ ] 로그인 실패 시 에러 메시지 표시 확인 - -### UI 상태 확인 -- [ ] 인증번호 발송 성공 후 인증번호 입력 필드에 **실제로 입력 가능한지** 확인 (키보드로 숫자 입력 테스트) -- [ ] 로딩 상태 표시 확인 (로딩 인디케이터가 보이는지, 필요 시) -- [ ] 성공/실패 메시지가 **화면에 실제로 표시**되는지 확인 (Logcat만으로는 부족) - -### 네비게이션 확인 -- [ ] SplashScreen → 회원가입 화면 이동 확인 -- [ ] 회원가입 → 프로필 설정 화면 이동 확인 -- [ ] 로그인 → 메인 화면 이동 확인 - ---- - -## 📝 테스트 결과 기록 - -### 테스트 일자: ___________ - -| 항목 | 결과 | 비고 | -|------|------|------| -| 이메일 인증번호 발송 | ⬜ 성공 / ⬜ 실패 | | -| 이메일 인증번호 확인 | ⬜ 성공 / ⬜ 실패 | | -| 회원가입 | ⬜ 성공 / ⬜ 실패 | | -| 로그인 | ⬜ 성공 / ⬜ 실패 | | - -### 발견된 문제 -- - -### 해결 방법 -- - ---- - -## 💡 참고사항 - -- **서버**: `https://afternote.kro.kr/` (실제 API 사용) -- **Logcat 필터**: `tag:OkHttp` 또는 `package:com.kuit.afternote` -- **테스트 가이드**: `docs/API_TEST_GUIDE.md` 참고 diff --git a/docs/DEV_MODE_PASSWORD_RESET_IMPROVEMENT_PLAN.md b/docs/DEV_MODE_PASSWORD_RESET_IMPROVEMENT_PLAN.md deleted file mode 100644 index 7afe8fca..00000000 --- a/docs/DEV_MODE_PASSWORD_RESET_IMPROVEMENT_PLAN.md +++ /dev/null @@ -1,76 +0,0 @@ -# Dev Mode Password Reset 개선 계획 - -> **Obsolete**: Dev mode was removed from the app. This document is kept for reference only. - -## 피드백 요약 - -Perplexity 피드백에 따라 다음 개선이 필요합니다: - -1. **Source Set 분리**: `src/debug`와 `src/release`로 분리하여 릴리즈 빌드에 개발 코드가 포함되지 않도록 -2. **DI 모듈 분리**: Hilt 모듈을 debug/release로 분리 -3. **아키텍처 분리**: 개발 전용 기능을 명확히 분리 - -## 구현 계획 - -### 1. Source Set 구조 변경 - -``` -app/src/ -├── main/ # 공통 코드 -│ └── java/.../ -│ ├── AuthRepository.kt (devPasswordReset 제거) -│ └── ... -├── debug/ # Debug 전용 코드 -│ └── java/.../ -│ ├── DevAuthRepository.kt (devPasswordReset 포함) -│ ├── DevPasswordResetUseCase.kt -│ └── di/DevModule.kt -└── release/ # Release 전용 코드 (선택적) - └── java/.../ - └── di/ReleaseModule.kt (No-op 구현) -``` - -### 2. 변경 사항 - -#### A. Repository 인터페이스 분리 -- `AuthRepository`에서 `devPasswordReset` 제거 -- `src/debug`에 `DevAuthRepository` 인터페이스 생성 (확장 인터페이스) -- `AuthRepositoryImpl`에서 `devPasswordReset` 구현을 debug source set으로 이동 - -#### B. UseCase 분리 -- `DevPasswordResetUseCase`를 `src/debug`로 이동 - -#### C. API Service 분리 -- `AuthApiService`에서 `devPasswordReset` 메서드를 debug source set으로 이동 -- 또는 debug 전용 인터페이스로 분리 - -#### D. ViewModel 및 UI -- `DevModeViewModel.resetPassword()`는 이미 dev feature이므로 유지 -- `DevModeScreen`의 password reset 버튼도 유지 (이미 dev mode 전용) - -#### E. Hilt 모듈 분리 -- `src/debug/di/DevModule.kt`: DevPasswordResetUseCase 주입 -- `src/release/di/ReleaseModule.kt`: No-op 또는 빈 모듈 - -### 3. 구현 단계 - -1. ✅ Source Set 디렉토리 생성 -2. ✅ Repository 인터페이스 분리 -3. ✅ UseCase 이동 -4. ✅ API Service 분리 -5. ✅ Repository 구현 분리 -6. ✅ Hilt 모듈 분리 -7. ✅ 빌드 확인 - -### 4. 보안 강화 - -- **Proguard/R8**: Release 빌드에서 dev 관련 클래스 제거 확인 -- **BuildConfig**: `BuildConfig.DEBUG` 체크 추가 (이중 안전장치) -- **코드 리뷰**: Release 빌드 체크리스트에 dev 코드 포함 여부 확인 추가 - -## 예상 효과 - -1. **보안**: Release APK에 dev 코드가 물리적으로 포함되지 않음 -2. **사이즈**: Release 빌드 크기 감소 -3. **명확성**: 개발 전용 기능이 명확히 구분됨 -4. **유지보수**: 개발 기능과 프로덕션 기능의 혼동 방지 diff --git a/docs/GRADLE_DEPRECATIONS.md b/docs/GRADLE_DEPRECATIONS.md deleted file mode 100644 index 33d02875..00000000 --- a/docs/GRADLE_DEPRECATIONS.md +++ /dev/null @@ -1,34 +0,0 @@ -# Gradle 10 Deprecation Warnings - -This project shows deprecation warnings when building with Gradle 9.1. These come from **plugins**, not from this repo’s build scripts. The build is successful; the messages are about future Gradle 10 compatibility. - -## Warnings (from `--warning-mode all`) - -| Warning | Source | Action | -|--------|-------------------------------|--------| -| Multi-string dependency notation for `com.android.tools.lint:lint-gradle:31.13.0` | Android Gradle Plugin (AGP) | Upgrade AGP when a Gradle-10–compatible version is available | -| Multi-string dependency notation for `com.android.tools.build:aapt2:8.13.0-13719691:osx` | Android Gradle Plugin (AGP) | Same as above | -| `ReportingExtension.file(String)` deprecated; use `getBaseDirectory().file(String)` | A plugin (e.g. detekt) | Upgrade the plugin when it uses the new API | - -## What we’re *not* doing - -- We are **not** using multi-string dependency notation in our own `build.gradle.kts` or `libs.versions.toml`; those usages are inside AGP. -- We are **not** calling `reporting.file()` in our scripts; that usage is inside a third‑party plugin. - -## What to do - -1. **Short term** - Builds remain valid. You can ignore these warnings or run with `--warning-mode all` only when you need the full list. - -2. **Before Gradle 10** - Upgrade when new versions are available: - - **AGP** – to a version that uses single-string dependency notation for its internal dependencies. - - **detekt** – to versions that use `reporting.baseDirectory.file(...)` (or equivalent) instead of `ReportingExtension.file(String)`. - -## How to reproduce - -```bash -./gradlew :app:assembleDebug --warning-mode all -``` - -Individual deprecation messages will appear in the output. diff --git a/docs/OOP_OBJECT_CALISTHENICS_NOTES.md b/docs/OOP_OBJECT_CALISTHENICS_NOTES.md new file mode 100644 index 00000000..d651b26c --- /dev/null +++ b/docs/OOP_OBJECT_CALISTHENICS_NOTES.md @@ -0,0 +1,305 @@ +# 객체지향 생활체조 메모 + +아래 내용은 리팩토링/코드리뷰 시 참고하기 위한 메모입니다. + +## 1. 한 메서드에 오직 한 단계의 들여쓰기만 한다 + +- 한 메서드에 들여쓰기가 여러 개 존재한다면, 해당 메서드는 여러 가지 일을 하고 있다고 봐도 무관하다. +- 메서드는 맡은 일이 적을수록(잘게 쪼갤수록), 재사용성이 높고 디버깅도 용이하다. +- 메서드명을 통해 직관적으로 분리하고 재사용 가능한 코드로 만든다. + +```java +public class JamieObject { + + String JamieAndNewLine() { + StringBuilder stringBuilder = new StringBuilder(); + int raw = 10; + int repeat = 5; + for (int i = 0; i < raw; i++) { + for (int j = 0; j < repeat; j++) { + stringBuilder.append("Jamie"); + stringBuilder.append(" "); + } + stringBuilder.append("\n"); + } + return stringBuilder.toString(); + } +} +``` + +```java +public class JamieObject { + + String JamieAndNewLine() { + StringBuilder stringBuilder = new StringBuilder(); + int raw = 10; + int repeat = 5; + RefeatJamieNewLine(stringBuilder, raw, repeat); + return stringBuilder.toString(); + } + + private void RefeatJamieNewLine(StringBuilder stringBuilder, int raw, int repeat) { + for (int i = 0; i < raw; i++) { + RepeatJamie(stringBuilder, repeat); + stringBuilder.append("\n"); + } + } + + private void RepeatJamie(StringBuilder stringBuilder, int repeat) { + for (int j = 0; j < repeat; j++) { + stringBuilder.append("Jamie"); + stringBuilder.append(" "); + } + } +} +``` + +## 2. else 키워드를 쓰지 않는다 + +- 조건문은 복제의 원인이 되기도 하고, 가독성도 좋지 않을 수 있다. +- 상태 분기가 여러 곳에 중복될 때 Strategy 패턴(다형성)이 특히 유용하다. +- 간단한 경우에는 guard clause(보호 구문), early return을 사용한다. + +```java +public class JamieObject { + + String JamieStatus(int hour, boolean isStudy) { + String status = ""; + if (hour > 4 && hour <= 12) { + status = "취침"; + } else { + if (isStudy) { + status = "공부"; + } else { + status = "여가"; + } + } + return status; + } +} +``` + +```java +public class JamieObject { + + String JamieStatus(int hour, boolean isStudy) { + if (hour > 4 && hour <= 12) { + return "취침"; + } + return isStudy ? "공부" : "여가"; + } +} +``` + +## 3. 모든 원시값과 문자열을 포장(wrap)한다 + +- 원시형 변수는 의미를 잃기 쉽다. +- 포장 객체를 쓰면 값의 의도와 제약을 코드에 담을 수 있다. +- 시간/돈 같은 단위 값은 행위를 둘 자리도 생긴다. + +```java +public class JamieMoney { + + private final int money; + + public JamieMoney(int money) { + validMoney(money <= 0, "현금은 0원 이상이여야 합니다."); + validMoney(money % 10 != 0, "현금은 10원 단위 이상만 허용 합니다."); + this.money = money; + } + + private void validMoney(boolean expression, String exceptionMessage) { + if (expression) { + throw new IllegalArgumentException(exceptionMessage); + } + } + + public int getMoney() { + return money; + } +} +``` + +## 4. 한 줄에 점을 하나만 찍는다 + +- (스트림 체이닝 등 일부 예외 제외) +- 한 줄에 점이 둘 이상이면 객체 내부를 너무 깊게 건드리고 있을 가능성이 있다. +- 디미터 법칙(친구하고만 대화하라): 자신의 객체/생성한 객체/파라미터 객체와만 메시지를 주고받는다. + +```java +public class JamieObject { + + void getMoney() { + jamieWallet.getTotalMoney().getMoney(); + } +} + +class JamieWallet { + private final JamieMoney totalMoney; + + JamieMoney getTotalMoney() { + return totalMoney; + } +} + +class JamieMoney { + + private final int money; + + int getMoney() { + return getMoney(); + } +} +``` + +```java +public class JamieObject { + + void getMoney() { + jamieWallet.getTotalMoney(); + } +} + +class JamieWallet { + + private final JamieMoney totalMoney; + + int getTotalMoney() { + return totalMoney.getMoney(); + } +} + +class JamieMoney { + + private final int money; + + int getMoney() { + return getMoney(); + } +} +``` + +## 5. 줄여쓰지 않는다 + +- 과도한 축약은 가독성을 해친다. +- 이름이 길다면 책임 분리가 필요한 신호일 수 있다. +- 문맥상 중복되는 단어는 줄이고, 의미는 정확히 드러낸다. + +```java +public class Jamie { + + void printJamieName() { + String EName = "Jamie"; + String KName = "제이미"; + } +} +``` + +```java +public class Jamie { + + void printName() { + String englishName = "Jamie"; + String koreanName = "제이미"; + } +} +``` + +## 6. 모든 entity를 작게 유지한다 + +- 50줄 이상 되는 클래스 또는 10개 파일 이상의 패키지는 지양한다. +- 클래스가 커지면 보통 한 가지 일만 하지 않는다. +- 패키지가 작을수록 목적과 정체성이 명확해진다. + +## 7. 2개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다 + +- 인스턴스 변수가 많아질수록 응집도가 떨어진다. +- 관련 인스턴스 변수는 wrapper/일급 컬렉션 등 협력 객체로 분해한다. + +```java +public class Jamie { + + private final String name; + private final String job; + private final int age; + + public Jamie(String name, String job, int age) { + this.name = name; + this.job = job; + this.age = age; + } +} +``` + +```java +public class Jamie { + + private final Name name; + private final Job job; + private final Age age; + + public Jamie(Name name, Job job, Age age) { + this.name = name; + this.job = job; + this.age = age; + } +} +``` + +## 8. 일급 컬렉션을 쓴다 + +- 컬렉션을 포함한 클래스는 반드시 다른 멤버 변수가 없어야 한다. +- 참고: 일급 컬렉션 개념을 별도 학습한다. + +## 9. getter/setter/property를 쓰지 않는다 + +- (메모) 도메인 객체 중심 원칙으로 이해한다. +- 객체끼리는 값을 꺼내 조작하기보다 메시지를 보내 동작하게 한다. +- 캡슐화 경계를 지키면 중복/오류를 줄이고 변경 지점을 지역화할 수 있다. + +```java +public class Jamie { + + private final Name name; + private final Money money; + + public Jamie(Name name, Money money) { + this.name = name; + this.money = money; + } + + boolean canBuySomething(int somthing) { + return somthing <= money.getMoney(); + } +} +``` + +```java +public class Jamie { + + private final Name name; + private final Money money; + + public Jamie(Name name, Money money) { + this.name = name; + this.money = money; + } + + boolean canBuySomething(int somthing) { + return money.moreThanOrEqualsPrice(somthing); + } +} +``` + +## 결론 + +- 9가지 규칙(축약 관련 제외)은 데이터 캡슐화를 가시화/실현하기 위한 방안이다. +- `else`를 쓰지 않는 규칙은 다형성의 적절한 사용을 유도한다. +- 명명 전략은 코드/아이디어의 중복을 줄이고 의도를 드러내는 방향으로 간다. + +## 이 프로젝트 적용 원칙 +- **최우선 원칙**: Android/Kotlin 공식 컨벤션(Compose 권장 패턴 포함)을 우선 준수한다. +- **적용 기준**: 객체지향 생활체조 규칙은 위 컨벤션을 해치지 않는 범위에서 적용한다. +- **충돌 시 판단**: 생활체조 규칙보다 플랫폼 컨벤션/팀 코딩 컨벤션을 우선한다. + + diff --git a/docs/PROMPT_API_DELAY_ISSUES.md b/docs/PROMPT_API_DELAY_ISSUES.md deleted file mode 100644 index 4bf2426b..00000000 --- a/docs/PROMPT_API_DELAY_ISSUES.md +++ /dev/null @@ -1,215 +0,0 @@ -# API Delay Issues and Optimistic Updates - Cursor Rules Update Prompt - -## Context - -When implementing API calls in ViewModels, there's often a noticeable delay between user action and UI feedback because the code waits for the API response before updating the UI state. This creates a poor user experience, especially for operations that should feel instant. - -## Problem - -Current pattern in the codebase: -```kotlin -fun updateSomething() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - - useCase.update() - .onSuccess { - _uiState.update { - it.copy(isLoading = false, success = true) - } - } - .onFailure { e -> - _uiState.update { - it.copy(isLoading = false, errorMessage = e.message) - } - } - } -} -``` - -**Issue**: User must wait for the entire API round-trip (network latency + server processing) before seeing any feedback, even for operations that are likely to succeed. - -## Solution: Optimistic Updates - -Optimistic updates immediately show success in the UI, then make the API call in the background. If the API fails, rollback the optimistic state and show an error. - -### Pattern - -```kotlin -fun updateSomething() { - viewModelScope.launch { - // Optimistic update: 즉시 성공 상태로 설정 - _uiState.update { - it.copy( - isLoading = false, - success = true, - needsRollback = false - ) - } - - // 백그라운드에서 실제 API 호출 - useCase.update() - .onSuccess { - // 이미 성공 상태이므로 추가 업데이트 불필요 - } - .onFailure { e -> - // Rollback: 성공 상태를 취소하고 에러 표시 - _uiState.update { - it.copy( - success = false, - needsRollback = true, - errorMessage = mapErrorToUserMessage(e) - ) - } - } - } -} -``` - -### UiState Requirements - -Add a `needsRollback: Boolean` flag to track when optimistic updates need to be rolled back: - -```kotlin -data class MyUiState( - val isLoading: Boolean = false, - val errorMessage: String? = null, - val success: Boolean = false, - val needsRollback: Boolean = false // Optimistic update 실패 시 rollback 필요 -) -``` - -### Screen Handling - -In the Screen, handle rollback before navigation: - -```kotlin -LaunchedEffect(uiState.success, uiState.needsRollback) { - // Rollback이 필요하면 navigation 취소 - if (uiState.needsRollback) { - viewModel.clearSuccess() - viewModel.clearRollback() - return@LaunchedEffect - } - - // 성공 시에만 navigation - if (uiState.success && !uiState.needsRollback) { - snackbarHostState.showSnackbar("작업이 완료되었습니다.") - viewModel.clearSuccess() - onNavigateAway() - } -} -``` - -## When to Use Optimistic Updates - -### ✅ Good Candidates -- **Non-critical updates**: Profile updates, settings changes, preferences -- **High success rate operations**: Operations that rarely fail (e.g., toggle switches) -- **User-initiated actions**: Actions where the user expects immediate feedback -- **Operations with rollback capability**: Can easily revert if API fails - -### ❌ Avoid For -- **Security-critical operations**: Password changes, authentication (though can be used with careful rollback) -- **Financial transactions**: Payments, transfers -- **Irreversible operations**: Delete operations (unless with undo) -- **Operations requiring server validation**: Where client-side validation isn't sufficient - -## Cursor Rules Update - -Add the following section to `.cursor/rules/tech-stack/compose.mdc` or create a new file `.cursor/rules/tech-stack/optimistic-updates.mdc`: - -```markdown -## Optimistic Updates Pattern (낙관적 업데이트 패턴) - Critical - -### When to Use -- **Non-critical updates**: Profile, settings, preferences -- **High success rate operations**: Toggle switches, simple updates -- **User-initiated actions**: Where immediate feedback is expected - -### Implementation Pattern - -1. **UiState에 needsRollback 플래그 추가**: -```kotlin -data class MyUiState( - val success: Boolean = false, - val needsRollback: Boolean = false // Optimistic update 실패 시 rollback 필요 -) -``` - -2. **ViewModel에서 Optimistic Update 구현**: -```kotlin -fun updateSomething() { - viewModelScope.launch { - // Optimistic: 즉시 성공 상태로 설정 - _uiState.update { - it.copy(success = true, needsRollback = false) - } - - // 백그라운드에서 실제 API 호출 - useCase.update() - .onFailure { e -> - // Rollback: 성공 상태 취소 및 에러 표시 - _uiState.update { - it.copy( - success = false, - needsRollback = true, - errorMessage = mapErrorToUserMessage(e) - ) - } - } - } -} -``` - -3. **Screen에서 Rollback 처리**: -```kotlin -LaunchedEffect(uiState.success, uiState.needsRollback) { - if (uiState.needsRollback) { - viewModel.clearSuccess() - viewModel.clearRollback() - return@LaunchedEffect - } - - if (uiState.success && !uiState.needsRollback) { - // Navigation or success feedback - navController.navigate("success") - } -} -``` - -### Rollback Handling -- **Navigation 취소**: `needsRollback`이 true면 navigation을 취소 -- **에러 표시**: Rollback 시 에러 메시지를 사용자에게 표시 -- **상태 초기화**: Rollback 후 `clearRollback()` 호출하여 상태 초기화 - -### Avoid For -- Security-critical operations (password changes - use with caution) -- Financial transactions -- Irreversible operations -- Operations requiring server-side validation -``` - -## Examples in Codebase - -Current implementations that could benefit from optimistic updates: -- `ProfileViewModel.updateProfile()` - Profile updates -- `PushSettingsViewModel.updatePushSettings()` - Settings toggles -- `PasswordChangeViewModel.changePassword()` - Already implemented with optimistic updates - -## Testing Considerations - -When testing optimistic updates: -1. **Success path**: Verify immediate UI update, then API success -2. **Failure path**: Verify rollback occurs, error is shown, navigation is cancelled -3. **Race conditions**: Test rapid successive calls -4. **Network delays**: Test with slow network to ensure rollback works - -## Migration Strategy - -1. Identify ViewModels with API delays that affect UX -2. Add `needsRollback` flag to UiState -3. Implement optimistic update pattern -4. Update Screen to handle rollback -5. Test thoroughly, especially failure scenarios -6. Update tests to cover optimistic update paths diff --git a/docs/RUNTIME_TESTING_GUIDE.md b/docs/RUNTIME_TESTING_GUIDE.md deleted file mode 100644 index 34b510d3..00000000 --- a/docs/RUNTIME_TESTING_GUIDE.md +++ /dev/null @@ -1,188 +0,0 @@ -# Runtime Testing Guide (런타임 테스트 가이드) - -이 문서는 User API와 TimeLetter API 구현을 런타임에서 테스트하는 방법을 설명합니다. - ---- - -## 📋 목차 - -1. [실제 서버로 테스트](#1-실제-서버로-테스트) -2. [Logcat으로 API 호출 확인](#2-logcat으로-api-호출-확인) -3. [UI 화면에서 테스트](#3-ui-화면에서-테스트) - ---- - -## 1. 실제 서버로 테스트 - -### 설정 방법 - -1. **Clean Build 및 앱 재설치**: - ```bash - ./gradlew clean - ./gradlew :app:assembleDebug - ``` - -2. **백엔드 서버 확인**: - - 서버가 `https://afternote.kro.kr/`에서 실행 중이어야 합니다 - - 테스트 계정이 필요합니다 (예: `dnfjddk2@gmail.com` / `Ab@12345`) - ---- - -## 2. Logcat으로 API 호출 확인 - -### Logcat 필터 설정 - -Android Studio의 Logcat에서 다음 필터를 사용하세요: - -``` -tag:OkHttp -또는 -package:com.kuit.afternote -또는 -UserRepositoryImpl|TimeLetterRepositoryImpl -``` - -### 확인할 로그 - -**User API 호출 시:** -``` -D/UserRepositoryImpl: getMyProfile: userId=1 -D/UserRepositoryImpl: getMyProfile: response=ApiResponse(...) -D/UserRepositoryImpl: getMyProfile: SUCCESS -``` - -**TimeLetter API 호출 시:** -``` -D/TimeLetterRepositoryImpl: getTimeLetters: calling API -D/TimeLetterRepositoryImpl: getTimeLetters: response=ApiResponse(...) -``` - -**HTTP 요청/응답 (OkHttp 로깅):** -``` -D/OkHttp: --> POST https://afternote.kro.kr/users/me?userId=1 -D/OkHttp: Content-Type: application/json -D/OkHttp: {"name":"테스트 사용자",...} -D/OkHttp: <-- 200 OK (123ms) -D/OkHttp: {"status":200,"code":200,"message":"프로필 조회 성공","data":{...}} -``` - ---- - -## 3. UI 화면에서 테스트 - -### User API 테스트 - -#### 프로필 조회/수정 (`ProfileViewModel`) - -**화면 위치:** -- `ProfileEditScreen` (설정 → 프로필 수정) - -**테스트 방법:** -1. 앱 실행 후 설정 화면으로 이동 -2. "프로필 수정" 메뉴 클릭 -3. **Logcat 확인**: - ``` - D/UserRepositoryImpl: getMyProfile: userId=... - D/OkHttp: --> GET /users/me?userId=... - ``` -4. 프로필 정보가 화면에 표시되는지 확인 -5. 이름/연락처 수정 후 "등록" 버튼 클릭 -6. **Logcat 확인**: - ``` - D/UserRepositoryImpl: updateMyProfile: userId=... - D/OkHttp: --> PATCH /users/me?userId=... - ``` - -#### 푸시 알림 설정 (`PushSettingsViewModel`) - -**화면 위치:** -- `PushToastSettingScreen` (설정 → 푸시 알림 설정) - -**테스트 방법:** -1. 설정 화면에서 "푸시 알림 설정" 메뉴 클릭 -2. **Logcat 확인**: - ``` - D/UserRepositoryImpl: getMyPushSettings: userId=... - D/OkHttp: --> GET /users/push-settings?userId=... - ``` -3. 푸시 알림 설정이 화면에 표시되는지 확인 -4. 설정 변경 후 저장 -5. **Logcat 확인**: - ``` - D/UserRepositoryImpl: updateMyPushSettings: userId=... - D/OkHttp: --> PATCH /users/push-settings?userId=... - ``` - -### TimeLetter API 테스트 - -**화면 위치:** -- `TimeLetterScreen` (타임레터 목록) -- `TimeLetterWriterScreen` (타임레터 작성) - -**테스트 방법:** -1. 타임레터 화면으로 이동 -2. **Logcat 확인**: - ``` - D/TimeLetterRepositoryImpl: getTimeLetters: calling API - D/OkHttp: --> GET /time-letters - ``` -3. 타임레터 목록이 표시되는지 확인 -4. 새 타임레터 작성 시: - ``` - D/TimeLetterRepositoryImpl: createTimeLetter: title=..., status=DRAFT - D/OkHttp: --> POST /time-letters - ``` - ---- - -## ✅ 검증 체크리스트 - -### User API 검증 - -- [ ] Mock 모드에서 프로필 조회 성공 (Logcat 확인) -- [ ] Mock 모드에서 프로필 수정 성공 (Logcat 확인) -- [ ] Mock 모드에서 푸시 설정 조회 성공 (Logcat 확인) -- [ ] Mock 모드에서 푸시 설정 수정 성공 (Logcat 확인) -- [ ] 실제 서버 모드에서 프로필 조회 성공 -- [ ] 실제 서버 모드에서 프로필 수정 성공 -- [ ] 실제 서버 모드에서 푸시 설정 조회 성공 -- [ ] 실제 서버 모드에서 푸시 설정 수정 성공 -- [ ] 에러 처리 확인 (401, 404 등) - -### TimeLetter API 검증 - -- [ ] Mock 모드에서 타임레터 목록 조회 성공 -- [ ] 타임레터 생성 성공 -- [ ] 타임레터 단일 조회 성공 -- [ ] 타임레터 수정 성공 -- [ ] 타임레터 삭제 성공 -- [ ] 임시저장 목록 조회 성공 -- [ ] 임시저장 전체 삭제 성공 - ---- - -## 🔧 문제 해결 - -### 실제 서버 연결 실패 - -1. **네트워크 연결 확인** -2. **서버 상태 확인**: - ```bash - curl https://afternote.kro.kr/v3/api-docs - ``` -3. **인증 토큰 확인** (Logcat에서): - ``` - D/OkHttp: Authorization: Bearer ... - ``` - -### Logcat에 로그가 보이지 않는 경우 - -1. **필터 확인**: `tag:OkHttp` 또는 `package:com.kuit.afternote` -2. **로그 레벨 확인**: Debug 레벨 로그가 활성화되어 있는지 확인 -3. **앱 재시작**: 앱을 완전히 종료 후 재시작 - ---- - -## 📝 참고 - -- **Network Module**: `app/src/main/java/com/kuit/afternote/data/remote/NetworkModule.kt` diff --git a/docs/SETUP_MEMO.md b/docs/SETUP_MEMO.md new file mode 100644 index 00000000..ae9910e8 --- /dev/null +++ b/docs/SETUP_MEMO.md @@ -0,0 +1,426 @@ +### 1. 프로젝트 기초 세팅 패키징 + +![image.png](attachment:0c4d5988-9653-4964-a8ee-7354ada25a76:image.png) + +```kotlin +┣ 📂 core +┃ ┣ 📂 component +┃ ┣ 📂 util +┣ 📂 data +┃ ┣ 📂 dto +┃ ┃ ┣ 📂 request +┃ ┃ ┣ 📂 response +┃ ┣ 📂 mapper +┃ ┣ 📂 repositoryimpl +┃ ┣ 📂 service +┣ 📂 di +┣ 📂 domain +┃ ┣ 📂 entity +┃ ┣ 📂 repository +┣ 📂 presentation +┃ ┣ 📂 diary +┃ ┣ 📂 home +┃ ┣ 📂 my +┃ ┣ 📂 navigation +``` + +### 2. 의존성 추가 (build.gradle, libs.versions.toml) + +(Hilt 기초 세팅 관련도 함께 있음) + +**project단 build.gradle** + +- project단 build.gradle 파일 + + ```kotlin + // Top-level build file where you can add configuration options common to all sub-projects/modules. + plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + **alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false** + } + ``` + + +**app단 build.gradle** + +- app단 build.gradle 파일 + + ```kotlin + import java.util.Properties + + plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + **alias(libs.plugins.kotlin.serialization)** + **alias(libs.plugins.hilt) + alias(libs.plugins.ksp)** + } + + **val properties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) + }** + + android { + namespace = "com.konkuk.kuit6_project_setting_solution" + compileSdk = 35 + + defaultConfig { + applicationId = "com.example.kuit6_project_setting_solution" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + **buildConfigField("String", "BASE_URL", properties["base.url"].toString())** + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + **buildConfig = true** + } + } + + dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + **// navigation + implementation(libs.androidx.navigation.compose) + + // serialization + implementation(libs.kotlinx.serialization.json) + + // coil + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + // Network + implementation(platform(libs.okhttp.bom)) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.retrofit) + implementation(libs.retrofit.kotlin.serialization.converter) + implementation(libs.kotlinx.serialization.json) + + // Hilt + implementation(libs.hilt.android) + implementation(libs.hilt.core) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.android.compiler) + ksp(libs.hilt.compiler) + ksp(libs.hilt.manager)** + } + ``` + + +**libs.versions.toml** + +- toml 파일 + + ```kotlin + [versions] + navigationCompose = "2.8.9" + okhttp = "4.11.0" + retrofit = "2.9.0" + retrofitKotlinSerializationConverter = "1.0.0" + kotlinxSerializationJson = "1.8.0" + coilCompose = "3.2.0" + coilNetworkOkhttp = "3.2.0" + hilt = "2.51" + hiltNavigationCompose = "1.2.0" + hiltManager = "1.0.0" + ksp = "2.0.21-1.0.28" + **** + [libraries] + androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp" } + okhttp = { group = "com.squareup.okhttp3", name = "okhttp" } + okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor" } + retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } + retrofit-kotlin-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinSerializationConverter" } + kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } + coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coilCompose" } + coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coilNetworkOkhttp" } + hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } + hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } + hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } + hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } + hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + hilt-manager = {group = "androidx.hilt" , name = "hilt-compiler", version.ref = "hiltManager"} + + [plugins] + kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } + hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } + ksp = {id = "com.google.devtools.ksp", version.ref = "ksp"} + ``` + + +### 3. AndroidManifest.xml 구현 + +- 인터넷 권한 추가 + + ```xml + + ``` + +- Cleartext HTTP 활성화를 통해 Http 접근 허용 + + ```xml + android:usesCleartextTraffic="true" + ``` + + +### 4. core 패키지 구현 + +- component : 공통 컴포넌트 구현 +- util : 확장함수 구현 + - **ContextExt.kt** + + ```kotlin + fun Context.**toast**(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + ``` + + - **ModifierExt.kt** + + ```kotlin + inline fun Modifier.**noRippleClickable**( + crossinline onClick: () -> Unit + ): Modifier = composed { + clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + onClick() + } + } + + fun Modifier.**addFocusCleaner**( + focusManager: FocusManager, + doOnClear: () -> Unit = {}): Modifier { + return this.pointerInput(Unit) { + detectTapGestures(onTap = { + doOnClear() + focusManager.clearFocus() + }) + } + } + ``` + + +### 5. presentation 패키지 구현 + +- **Bottom Navigation** 구현 + - sealed class로 **Route** 구현 + + ```kotlin + sealed class Route( + val route: String + ) { + data object Home: Route(route = "home") + + data object Diary: Route(route = "diary") + + data object My: Route(route = "my") + } + ``` + + - **NavGraph** 구현 (HomeScreen, DiaryScreen, MyScreen까지 생성) + + ```kotlin + @Composable + fun KuitNavGraph( + navController: NavHostController, + modifier: Modifier = Modifier + ) { + NavHost( + navController = navController, + startDestination = Route.Home.route, + ){ + composable(route = Route.Home.route) { + HomeScreen(modifier = modifier) + } + composable(route = Route.Diary.route) { + DiaryScreen(modifier = modifier) + } + composable(route = Route.My.route) { + MyScreen(modifier = modifier) + } + } + } + ``` + + - **BottomNavItem data class** 구현 + + ```kotlin + data class BottomNavItem( + val label: String, + val route: String, + val icon: Int + ) + ``` + + - **Scaffold**를 활용하여 **MainActivity**에서 **Bottom Navigation** 구조 구현 + + ```kotlin + val navController = rememberNavController() + var selectedRoute by remember { mutableStateOf(Route.Home.route) } + + val bottomNavItems = listOf( + BottomNavItem("홈", Route.Home.route, R.drawable.ic_home), + BottomNavItem("일기", Route.Diary.route, R.drawable.ic_diary), + BottomNavItem("마이페이지", Route.My.route, R.drawable.ic_my) + ) + + Scaffold( + bottomBar = { + NavigationBar { + bottomNavItems.forEach { item -> + NavigationBarItem( + selected = selectedRoute == item.route, + onClick = { + selectedRoute = item.route + navController.navigate(item.route) { + launchSingleTop = true + restoreState = true + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + } + }, + icon = { + Icon( + painter = painterResource(id = item.icon), + contentDescription = item.label + ) + }, + label = { + Text( + text = item.label + ) + } + ) + } + } + } + ) { innerPadding -> + KuitNavGraph( + navController = navController, + modifier = Modifier.padding(innerPadding) + ) + } + ``` + + +### 6. data 패키지 구현 + +- dto + - request + - response + - cf) BaseResponse +- service +- mapper +- repositoryimpl + +### 7. domain 패키지 구현 + +- entity +- repository + +### 8. DI 적용 (Hilt) + +- ApiModule, RepositoryModule, NetworkModule 구현 + + ```kotlin + @Module + @InstallIn(SingletonComponent::class) + object ApiModule { + @Provides + @Singleton + fun providesHomeService(retrofit: Retrofit): HomeService = + retrofit.create(HomeService::class.java) + } + ``` + + ```kotlin + @Module + @InstallIn(SingletonComponent::class) + **abstract class** RepositoryModule { + **@Binds** + @Singleton + **abstract fun** bindsHomeRepository(homeRepositoryImpl: HomeRepositoryImpl): HomeRepository + } + ``` + + ```kotlin + @Module + @InstallIn(SingletonComponent::class) + object NetworkModule { + @Provides + @Singleton + fun providesOkHttpClient(): OkHttpClient { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + } + + @Provides + @Singleton + fun providesConverterFactory(): Converter.Factory = + Json.asConverterFactory("application/json".toMediaType()) + + @Provides + @Singleton + fun providesRetrofit( + client: OkHttpClient, + converterFactory: Converter.Factory + ): Retrofit = Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .client(client) + .addConverterFactory(converterFactory) + .build() + } + ``` + +- KuitApplication 생성, MainActivity에 @AndroidEntryPoint 어노테이션 추가 +- AndroidManifest.xml에서 KuitApplication 적용 \ No newline at end of file diff --git a/docs/VISUAL_TESTING_GUIDE.md b/docs/VISUAL_TESTING_GUIDE.md deleted file mode 100644 index 8cea1644..00000000 --- a/docs/VISUAL_TESTING_GUIDE.md +++ /dev/null @@ -1,266 +0,0 @@ -# Visual Testing Guide (시각적 테스트 가이드) - -이 문서는 User API를 앱에서 시각적으로 테스트하는 방법을 설명합니다. - ---- - -## ⚠️ 현재 상태 - -**문제**: `ProfileEditScreen`과 `PushToastSettingScreen`이 아직 ViewModel과 연결되지 않았습니다. - -**해결 방법**: 아래 가이드를 따라 화면을 ViewModel에 연결하세요. - ---- - -## 1. ProfileEditScreen 연결하기 - -### 현재 문제 -- `ProfileEditScreen`이 `ProfileViewModel`을 사용하지 않음 -- 프로필 데이터를 로드하지 않음 -- 프로필 수정 기능이 동작하지 않음 - -### 해결 방법 - -**Step 1: ProfileEditScreen 수정** - -`ReceiverRecordDetailScreen.kt`를 다음과 같이 수정: - -```kotlin -@Composable -fun ProfileEditScreen( - modifier: Modifier = Modifier, - viewModel: ProfileViewModel = hiltViewModel(), - callbacks: ProfileEditCallbacks = ProfileEditCallbacks() -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val scrollState = rememberScrollState() - - // 화면 진입 시 프로필 로드 (userId는 JWT 토큰에서 자동 추출) - LaunchedEffect(Unit) { - viewModel.loadProfile() - } - - // TextFieldState를 uiState와 동기화 - val nameState = rememberTextFieldState(initialText = uiState.name) - val contactState = rememberTextFieldState(initialText = uiState.phone ?: "") - val emailState = rememberTextFieldState(initialText = uiState.email) - - // ... 기존 Scaffold 코드 ... - - // 등록 버튼 클릭 시 프로필 업데이트 (userId는 JWT 토큰에서 자동 추출) - val onRegisterClick = { - viewModel.updateProfile( - name = nameState.text.takeIf { it.isNotBlank() }, - phone = contactState.text.takeIf { it.isNotBlank() }, - profileImageUrl = null // TODO: 이미지 업로드 구현 후 - ) - } - - Scaffold( - topBar = { - TopBar( - title = "프로필 수정", - onBackClick = callbacks.onBackClick, - onActionClick = onRegisterClick, // 수정된 콜백 사용 - actionText = "등록" - ) - } - ) { paddingValues -> - // ... 기존 UI 코드 ... - - // 에러 메시지 표시 (Snackbar 또는 Toast) - uiState.errorMessage?.let { error -> - // Snackbar 표시 로직 - } - - // 로딩 상태 표시 - if (uiState.isLoading) { - // 로딩 인디케이터 - } - } -} -``` - -**Step 2: 필요한 Import 추가** - -```kotlin -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.runtime.LaunchedEffect -import com.kuit.afternote.feature.user.presentation.viewmodel.ProfileViewModel -``` - ---- - -## 2. PushToastSettingScreen 연결하기 - -### 현재 문제 -- `PushToastSettingScreen`이 `PushSettingsViewModel`을 사용하지 않음 -- 로컬 상태만 사용하여 실제 API와 동기화되지 않음 - -### 해결 방법 - -**Step 1: PushToastSettingScreen 수정** - -`PushToastSettingScreen.kt`를 다음과 같이 수정: - -```kotlin -@Composable -fun PushToastSettingScreen( - viewModel: PushSettingsViewModel = hiltViewModel(), - onBackClick: () -> Unit = {} -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - // 화면 진입 시 푸시 설정 로드 (userId는 JWT 토큰에서 자동 추출) - LaunchedEffect(Unit) { - viewModel.loadPushSettings() - } - - Scaffold( - topBar = { - TopBar("푸시 알림 설정", onBackClick = onBackClick) - } - ) { paddingValues -> - Column( - modifier = Modifier - .padding(paddingValues) - .padding(horizontal = 20.dp) - .fillMaxSize() - ) { - // 타임레터 - PushSettingRow( - name = "타임레터", - isChecked = uiState.timeLetter, - onCheckedChange = { newValue -> - viewModel.updatePushSettings( - timeLetter = newValue, - mindRecord = null, - afterNote = null - ) - } - ) - Spacer(modifier = Modifier.height(16.dp)) - - // 마음의 기록 - PushSettingRow( - name = "마음의 기록", - isChecked = uiState.mindRecord, - onCheckedChange = { newValue -> - viewModel.updatePushSettings( - timeLetter = null, - mindRecord = newValue, - afterNote = null - ) - } - ) - Spacer(modifier = Modifier.height(16.dp)) - - // 애프터노트 - PushSettingRow( - name = "애프터노트", - isChecked = uiState.afterNote, - onCheckedChange = { newValue -> - viewModel.updatePushSettings( - timeLetter = null, - mindRecord = null, - afterNote = newValue - ) - } - ) - - // 에러 메시지 표시 - uiState.errorMessage?.let { error -> - Text( - text = error, - color = Color.Red, - modifier = Modifier.padding(16.dp) - ) - } - } - } -} -``` - -**Step 2: 필요한 Import 추가** - -```kotlin -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.runtime.LaunchedEffect -import com.kuit.afternote.feature.user.presentation.viewmodel.PushSettingsViewModel -``` - ---- - -## 3. 테스트 방법 - -### Step 1: 앱 실행 및 테스트 - -1. **앱 실행** - ```bash - ./gradlew :app:installDebug - ``` - -2. **로그인** - - 스플래시 후 로그인 화면에서 로그인 - -3. **프로필 수정 화면 테스트** - - 설정 → 프로필 수정 - - 프로필 정보가 로드되는지 확인 - - 이름/연락처 수정 후 "등록" 버튼 클릭 - - 수정 성공 여부 확인 - -4. **푸시 알림 설정 화면 테스트** - - 설정 → 푸시 알림 설정 - - 푸시 설정이 로드되는지 확인 - - 스위치를 토글하여 설정 변경 - - 변경 사항이 저장되는지 확인 - ---- - -## 4. 확인 사항 - -### ✅ 성공 시나리오 -- [ ] 프로필 수정 화면에서 프로필 정보가 표시됨 -- [ ] 프로필 수정 후 "등록" 버튼 클릭 시 성공 메시지 표시 -- [ ] 푸시 알림 설정 화면에서 현재 설정이 표시됨 -- [ ] 스위치 토글 시 설정이 즉시 저장됨 - -### ❌ 실패 시나리오 (에러 처리 확인) -- [ ] 네트워크 오류 시 에러 메시지 표시 -- [ ] 401 Unauthorized 시 에러 메시지 표시 -- [ ] 404 Not Found 시 에러 메시지 표시 - ---- - -## 5. ✅ 해결된 사항 - -### userId 자동 추출 - -**이제 `userId`는 JWT 토큰에서 자동으로 추출됩니다!** - -- `JwtDecoder` 유틸리티가 JWT 토큰을 디코딩하여 `userId`를 추출합니다 -- `TokenManager.getUserId()` 메서드를 통해 `userId`를 가져올 수 있습니다 -- ViewModel은 자동으로 `TokenManager`에서 `userId`를 가져와 사용합니다 -- 화면에서는 `userId`를 전달할 필요가 없습니다 - -**구현된 기능:** -- ✅ `JwtDecoder.getUserId(token)` - JWT 토큰에서 userId 추출 -- ✅ `TokenManager.getUserId()` - 저장된 토큰에서 userId 가져오기 -- ✅ `ProfileViewModel.loadProfile()` - userId 파라미터 불필요 -- ✅ `PushSettingsViewModel.loadPushSettings()` - userId 파라미터 불필요 - ---- - -## 6. TimeLetter API 테스트 - -**참고**: TimeLetter API의 Domain/UI 레이어는 박경민님의 담당입니다. Data 레이어만 구현되어 있으므로, UI 테스트는 Domain/UI 레이어 구현 후 가능합니다. - ---- - -## 요약 - -1. **화면 연결**: `ProfileEditScreen`과 `PushToastSettingScreen`을 각각의 ViewModel에 연결 -2. **userId 자동 추출**: ✅ JWT 토큰에서 자동으로 `userId`를 추출하므로 하드코딩 불필요 -3. **시각적 확인**: 앱 실행 후 설정 화면에서 프로필 수정 및 푸시 알림 설정 기능 테스트 (실제 서버 사용) diff --git a/docs/troubleshooting-gradle-java-issues.md b/docs/troubleshooting-gradle-java-issues.md deleted file mode 100644 index 5882e5bf..00000000 --- a/docs/troubleshooting-gradle-java-issues.md +++ /dev/null @@ -1,23 +0,0 @@ -# Gradle Java 문제 해결 기록 - -## 문제 -Windows에서 pre-commit hook 실행 시 `java.lang.InternalError: Error loading java.security file` 에러 발생 - -## 시도한 해결책들 - -### 실패한 방법들 -1. gradle.properties에 JBR 경로 설정 -2. pre-commit hook에서 Windows 감지 및 gradlew.bat 사용 -3. JAVA_HOME 환경 변수 설정 -4. JAVA_TOOL_OPTIONS로 보안 정책 우회 -5. org.gradle.daemon=false 설정 -6. org.gradle.jvmargs에 보안 옵션 추가 - -### 성공한 해결책 -**Eclipse Adoptium JDK 사용** -- 경로: `C:/Users/rlfjr/AppData/Local/Programs/Eclipse Adoptium/jdk-21.0.9.10-hotspot` -- gradle.properties: `org.gradle.java.home=C:/Users/rlfjr/AppData/Local/Programs/Eclipse Adoptium/jdk-21.0.9.10-hotspot` - -## 원인 -Android Studio의 JBR은 명령줄에서 Java 보안 파일 로드 문제가 있음. Eclipse Adoptium JDK 사용으로 해결. -