diff --git a/.github/actions/cleanup-release-config/action.yml b/.github/actions/cleanup-release-config/action.yml new file mode 100644 index 0000000000..bd72d69ef2 --- /dev/null +++ b/.github/actions/cleanup-release-config/action.yml @@ -0,0 +1,35 @@ +name: Remove release build configuration +description: Delete the credential files setup-release-config created + +# 공개 저장소라 job 이 어떻게 끝나든 러너에 credential 을 남기지 않는다. if: always() 로 부를 것. + +inputs: + keystore-path: + description: setup-release-config 의 keystore-path output + required: false + default: "" + firebase-credentials-path: + description: setup-release-config 의 firebase-credentials-path output + required: false + default: "" + +runs: + using: composite + steps: + - name: Remove release build files + shell: bash + env: + KEYSTORE_PATH: ${{ inputs.keystore-path }} + FIREBASE_CREDENTIALS_PATH: ${{ inputs.firebase-credentials-path }} + run: | + set -euo pipefail + + rm -f app/google-services.json local.properties + + if [ -n "$KEYSTORE_PATH" ]; then + rm -f "$KEYSTORE_PATH" + fi + + if [ -n "$FIREBASE_CREDENTIALS_PATH" ]; then + rm -f "$FIREBASE_CREDENTIALS_PATH" + fi diff --git a/.github/actions/setup-release-config/action.yml b/.github/actions/setup-release-config/action.yml new file mode 100644 index 0000000000..ac6bd42c93 --- /dev/null +++ b/.github/actions/setup-release-config/action.yml @@ -0,0 +1,162 @@ +name: Set up release build configuration +description: Verify the release secrets a job declares and materialise the build files they back + +# setup-ci-config 와 별개 action 이다. 그쪽은 실서비스 값이 없는 결정적 stub 을 만들어 PR 검증에 +# 쓰고(#842), 이쪽은 승인 environment 뒤에서 실서비스 secret 을 다룬다. 두 경로가 한 action 으로 +# 섞이면 PR 검증이 실서비스 secret 을 참조할 여지가 생기므로 합치지 않는다. + +inputs: + require: + description: "검증할 secret 이름 목록 (공백 구분). 나열된 이름의 값이 비면 이름만 보고하고 실패한다." + required: true + kakao-native-app-key: + description: KAKAO_NATIVE_APP_KEY + required: false + default: "" + google-web-client-id: + description: GOOGLE_WEB_CLIENT_ID + required: false + default: "" + google-services-json-b64: + description: GOOGLE_SERVICES_JSON_B64 — 주어지면 app/google-services.json 을 만든다 + required: false + default: "" + release-store-file-b64: + description: RELEASE_STORE_FILE_B64 — 주어지면 release keystore 를 만든다 + required: false + default: "" + release-store-password: + description: RELEASE_STORE_PASSWORD + required: false + default: "" + release-key-alias: + description: RELEASE_KEY_ALIAS + required: false + default: "" + release-key-password: + description: RELEASE_KEY_PASSWORD + required: false + default: "" + firebase-service-account-json: + description: FIREBASE_SERVICE_ACCOUNT_JSON — 주어지면 ADC credential 파일을 만든다 + required: false + default: "" + compose-local-properties: + description: "true 면 app/build.gradle.kts 의 signingConfigs.release 가 읽는 local.properties 를 만든다" + required: false + default: "false" + +outputs: + keystore-path: + description: decode 된 release keystore 경로 (만들지 않았으면 빈 값) + value: ${{ steps.materialise.outputs.keystore-path }} + firebase-credentials-path: + description: Firebase service account credential 경로 (만들지 않았으면 빈 값) + value: ${{ steps.materialise.outputs.firebase-credentials-path }} + +runs: + using: composite + steps: + # fail-fast — secret 누락 시 Gradle 까지 가지 않고 즉시 실패. + # secret 값은 echo 하지 않는다 (env 로만 받아 길이 검사). 누락 키 이름은 안전. + - name: Verify required secrets are set + shell: bash + env: + REQUIRE: ${{ inputs.require }} + KAKAO_NATIVE_APP_KEY: ${{ inputs.kakao-native-app-key }} + GOOGLE_WEB_CLIENT_ID: ${{ inputs.google-web-client-id }} + GOOGLE_SERVICES_JSON_B64: ${{ inputs.google-services-json-b64 }} + RELEASE_STORE_FILE_B64: ${{ inputs.release-store-file-b64 }} + RELEASE_STORE_PASSWORD: ${{ inputs.release-store-password }} + RELEASE_KEY_ALIAS: ${{ inputs.release-key-alias }} + RELEASE_KEY_PASSWORD: ${{ inputs.release-key-password }} + FIREBASE_SERVICE_ACCOUNT_JSON: ${{ inputs.firebase-service-account-json }} + run: | + set -euo pipefail + + read -ra required_names <<< "$REQUIRE" + if [ "${#required_names[@]}" -eq 0 ]; then + echo "::error::require 가 비어 있다 — 검증할 secret 이름을 지정할 것" + exit 1 + fi + + missing="" + for name in "${required_names[@]}"; do + case "$name" in + KAKAO_NATIVE_APP_KEY) value="$KAKAO_NATIVE_APP_KEY" ;; + GOOGLE_WEB_CLIENT_ID) value="$GOOGLE_WEB_CLIENT_ID" ;; + GOOGLE_SERVICES_JSON_B64) value="$GOOGLE_SERVICES_JSON_B64" ;; + RELEASE_STORE_FILE_B64) value="$RELEASE_STORE_FILE_B64" ;; + RELEASE_STORE_PASSWORD) value="$RELEASE_STORE_PASSWORD" ;; + RELEASE_KEY_ALIAS) value="$RELEASE_KEY_ALIAS" ;; + RELEASE_KEY_PASSWORD) value="$RELEASE_KEY_PASSWORD" ;; + FIREBASE_SERVICE_ACCOUNT_JSON) value="$FIREBASE_SERVICE_ACCOUNT_JSON" ;; + *) + echo "::error::이 action 이 모르는 secret 이름: $name" + exit 1 + ;; + esac + if [ -z "$value" ]; then + missing="$missing $name" + fi + done + + if [ -n "$missing" ]; then + echo "::error::필수 secret 누락:$missing — Settings → Secrets and variables → Actions 에 등록" + exit 1 + fi + + # keystore·credential 은 workspace 밖 RUNNER_TEMP 에 둔다 — job 종료 시 러너와 함께 폐기되고 + # actions/upload-artifact 의 workspace glob 에 딸려 올라갈 수 없다. + - name: Materialise release build files + id: materialise + shell: bash + env: + KAKAO_NATIVE_APP_KEY: ${{ inputs.kakao-native-app-key }} + GOOGLE_WEB_CLIENT_ID: ${{ inputs.google-web-client-id }} + GOOGLE_SERVICES_JSON_B64: ${{ inputs.google-services-json-b64 }} + RELEASE_STORE_FILE_B64: ${{ inputs.release-store-file-b64 }} + RELEASE_STORE_PASSWORD: ${{ inputs.release-store-password }} + RELEASE_KEY_ALIAS: ${{ inputs.release-key-alias }} + RELEASE_KEY_PASSWORD: ${{ inputs.release-key-password }} + FIREBASE_SERVICE_ACCOUNT_JSON: ${{ inputs.firebase-service-account-json }} + COMPOSE_LOCAL_PROPERTIES: ${{ inputs.compose-local-properties }} + run: | + set -euo pipefail + + keystore_path="" + firebase_credentials_path="" + + # google-services plugin 이 app/google-services.json 을 요구한다. 파일은 .gitignore 라 + # CI 환경엔 없으므로 base64 secret 에서 복원한다. + if [ -n "$GOOGLE_SERVICES_JSON_B64" ]; then + printf '%s' "$GOOGLE_SERVICES_JSON_B64" | base64 -d > app/google-services.json + fi + + if [ -n "$RELEASE_STORE_FILE_B64" ]; then + keystore_path="$RUNNER_TEMP/afternote-release.jks" + printf '%s' "$RELEASE_STORE_FILE_B64" | base64 -d > "$keystore_path" + fi + + # Firebase App Distribution Gradle plugin 은 GOOGLE_APPLICATION_CREDENTIALS 환경변수로 + # service account JSON 경로를 인식한다. 경로만 output 으로 넘기고 export 는 호출자가 한다. + if [ -n "$FIREBASE_SERVICE_ACCOUNT_JSON" ]; then + firebase_credentials_path="$RUNNER_TEMP/firebase-service-account.json" + printf '%s' "$FIREBASE_SERVICE_ACCOUNT_JSON" > "$firebase_credentials_path" + fi + + if [ "$COMPOSE_LOCAL_PROPERTIES" = "true" ]; then + { + echo "KAKAO_NATIVE_APP_KEY=$KAKAO_NATIVE_APP_KEY" + echo "GOOGLE_WEB_CLIENT_ID=$GOOGLE_WEB_CLIENT_ID" + echo "RELEASE_STORE_FILE=$keystore_path" + echo "RELEASE_STORE_PASSWORD=$RELEASE_STORE_PASSWORD" + echo "RELEASE_KEY_ALIAS=$RELEASE_KEY_ALIAS" + echo "RELEASE_KEY_PASSWORD=$RELEASE_KEY_PASSWORD" + } > local.properties + fi + + { + echo "keystore-path=$keystore_path" + echo "firebase-credentials-path=$firebase_credentials_path" + } >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/build-cache-policy.test.mjs b/.github/scripts/build-cache-policy.test.mjs new file mode 100644 index 0000000000..3f98cd5a51 --- /dev/null +++ b/.github/scripts/build-cache-policy.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import test from "node:test"; + +const workflowDirectory = new URL("../workflows/", import.meta.url); + +async function workflows() { + const names = (await readdir(workflowDirectory)).filter((name) => name.endsWith(".yml")); + return Promise.all(names.map(async (name) => [name, await readFile(new URL(name, workflowDirectory), "utf8")])); +} + +// 주석은 왜 그렇게 했는지를 적어 두는 자리라 액션 이름·플래그가 그대로 등장한다. +// 정책은 실제로 실행되는 줄에만 걸어야 한다. +function withoutComments(source) { + return source + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); +} + +// Actions 캐시 엔트리는 (키, ref) 로 격리되고, 읽기는 «현재 ref → base → default branch» +// 순으로 매칭된다. 그래서 경쟁이 일어나는 범위는 develop 스코프 하나다 — release·canary +// 워크플로는 main 스코프에 쓰므로 develop PR 이 받는 엔트리를 가리지 않는다. +function runsOnDevelopPush(source) { + return /^\s{2}push:\n\s{4}branches:\s*\[[^\]]*\bdevelop\b/m.test(source); +} + +function runsOnPullRequests(source) { + return /^on:\n(?:[^\n]*\n)*?\s{2}pull_request:/m.test(source); +} + +test("exactly one workflow may write the shared Gradle User Home cache", async () => { + // Actions 캐시는 같은 키를 덮어쓰지 못한다. writer 가 둘이면 먼저 끝난 쪽이 키를 + // 차지하고, build cache 를 채운 쪽의 저장이 조용히 버려진다 (#996). + const writers = (await workflows()) + .filter(([, source]) => runsOnDevelopPush(source)) + .filter(([, source]) => /cache-read-only:\s*false/.test(withoutComments(source))) + .map(([name]) => name); + + assert.deepEqual(writers, ["build-cache-warm.yml"]); +}); + +test("every other Gradle workflow reads the cache without writing to it", async () => { + for (const [name, source] of await workflows()) { + if (name === "build-cache-warm.yml" || !/uses:\s*gradle\/actions\/setup-gradle@/.test(source)) { + continue; + } + if (!runsOnPullRequests(source) && !runsOnDevelopPush(source)) { + continue; + } + assert.match(source, /cache-read-only:\s*true/, `${name} must not write to the shared cache`); + // 조건식으로 쓰면 pull_request 가 아닌 trigger 에서 writer 로 돌변한다. + assert.doesNotMatch( + source, + /cache-read-only:\s*\$\{\{/, + `${name} must not decide cache writability from the event`, + ); + } +}); + +test("the warming workflow runs on develop pushes and is never cancelled mid-run", async () => { + const source = await readFile(new URL("build-cache-warm.yml", workflowDirectory), "utf8"); + + assert.match(source, /push:\n\s+branches:\s*\[develop\]/); + // 캐시는 job 의 post 스텝에서 저장된다. 중도 취소되면 그 스텝에 닿지 못한다. + assert.match(source, /cancel-in-progress:\s*false/); + assert.match(source, /--build-cache/); +}); + +test("the warming workflow covers the tasks pull request jobs actually run", async () => { + const warm = await readFile(new URL("build-cache-warm.yml", workflowDirectory), "utf8"); + const lint = await readFile(new URL("lint.yml", workflowDirectory), "utf8"); + const unitTest = await readFile(new URL("unit-test.yml", workflowDirectory), "utf8"); + + for (const task of ["ktlintCheck", "lintDebug"]) { + assert.match(lint, new RegExp(`\\./gradlew[^\\n]*${task}`), `lint.yml no longer runs ${task}`); + assert.match(warm, new RegExp(`\\b${task}\\b`), `warming misses ${task}`); + } + for (const task of [":koverXmlReportCi", ":konsist:test", ":app:compileDebugAndroidTestKotlin"]) { + assert.ok(unitTest.includes(task), `unit-test.yml no longer runs ${task}`); + assert.ok(warm.includes(task), `warming misses ${task}`); + } +}); + +test("CodeQL stays out of the build cache in both directions", async () => { + const source = await readFile(new URL("codeql.yml", workflowDirectory), "utf8"); + + // 추출기가 컴파일러 호출을 관찰해야 하므로 FROM-CACHE 로 넘어가면 안 된다 (#1014). + assert.doesNotMatch(withoutComments(source), /--build-cache/); + assert.match(source, /cache-read-only:\s*true/); +}); + +test("the local Gradle default stays uncached", async () => { + // #996 은 CI 한정이다. 로컬은 손상된 cache 엔트리 사례 때문에 여전히 꺼 둔다. + const properties = await readFile(new URL("../../gradle.properties", import.meta.url), "utf8"); + + assert.match(properties, /^org\.gradle\.caching=false$/m); +}); diff --git a/.github/scripts/label-conflicting-prs.mjs b/.github/scripts/label-conflicting-prs.mjs new file mode 100644 index 0000000000..127a241a88 --- /dev/null +++ b/.github/scripts/label-conflicting-prs.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node + +// base 와 충돌한 PR 에 라벨을 붙여 «CI 가 돌지 않는 상태» 를 드러낸다 (#1028). +// +// `pull_request` 워크플로는 base 와 head 를 합친 가상 머지 커밋(`refs/pull//merge`)에서 +// 실행되는데, 충돌이면 GitHub 이 그 ref 를 만들지 못해 워크플로가 **트리거조차 되지 않는다.** +// 체크가 실패하는 게 아니라 아예 생성되지 않으므로, 저자도 리뷰어도 「아직 안 끝났나」로 읽고 +// 최신 커밋이 미검증인 채 남는다. 머지 자체는 mergeable_state=dirty 와 required check 의 +// pending 이 막으므로, 이 스크립트가 고치는 것은 게이트가 아니라 가시성이다. + +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_LABEL = "conflict"; +export const COMMENT_MARKER = ""; + +const LABEL_COLOR = "e99695"; +const LABEL_DESCRIPTION = "base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다"; +const PULL_REQUEST_PAGE_SIZE = 50; +const UNKNOWN_RESOLVE_ATTEMPTS = 3; +const UNKNOWN_RESOLVE_DELAY_MS = 2000; + +const OPEN_PULL_REQUESTS_QUERY = ` +query($owner: String!, $name: String!, $cursor: String, $pageSize: Int!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: $pageSize, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + number + mergeable + baseRefName + headRefName + isDraft + labels(first: 30) { nodes { name } } + } + } + } +}`; + +/** + * 조회 결과를 라벨 조작 계획으로 바꾼다. + * + * `UNKNOWN` 은 GitHub 이 mergeable 을 아직 계산하지 못한 상태다. 충돌로 단정하면 멀쩡한 PR 에 + * 라벨이 붙고, 그 오탐은 라벨이 없는 것보다 나쁘다 — 건드리지 않고 다음 실행에 맡긴다. + */ +export function planLabelChanges({ pullRequests, label = DEFAULT_LABEL }) { + const toLabel = []; + const toUnlabel = []; + const skipped = []; + + for (const pullRequest of pullRequests) { + const labels = pullRequest.labels ?? []; + const labeled = labels.includes(label); + + if (pullRequest.mergeable === "CONFLICTING") { + // 이미 붙어 있으면 아무것도 하지 않는다 — 코멘트가 실행마다 쌓이면 소음이 된다. + if (!labeled) { + toLabel.push(pullRequest); + } + continue; + } + + if (pullRequest.mergeable === "MERGEABLE") { + if (labeled) { + toUnlabel.push(pullRequest); + } + continue; + } + + skipped.push(pullRequest); + } + + return { toLabel, toUnlabel, skipped }; +} + +export function renderConflictComment({ baseRefName }) { + return [ + COMMENT_MARKER, + `이 PR 은 \`${baseRefName}\` 와 충돌 상태이고, **그래서 CI 가 돌지 않는다.**`, + "", + "`pull_request` 워크플로는 base 와 head 를 합친 가상 머지 커밋(`refs/pull//merge`)에서", + "실행되는데, 충돌이면 GitHub 이 그 ref 를 만들지 못해 워크플로가 트리거조차 되지 않는다.", + "체크가 실패하는 것이 아니라 아예 생성되지 않으므로, 새 커밋을 올려도 `no checks reported`", + "인 채 **최신 커밋이 한 번도 검증되지 않은 상태**로 남는다 (#1028).", + "", + `\`${baseRefName}\` 를 병합해 충돌을 풀면 검증이 다시 돈다. 해소되면 이 라벨은 자동으로 떨어진다.`, + ].join("\n"); +} + +export function renderSummary({ plan, label, dryRun }) { + const lines = [ + `## 충돌 PR 라벨 (\`${label}\`)${dryRun ? " — dry run" : ""}`, + "", + `- 라벨 부착: ${plan.toLabel.length}건${formatNumbers(plan.toLabel)}`, + `- 라벨 제거: ${plan.toUnlabel.length}건${formatNumbers(plan.toUnlabel)}`, + `- 판정 보류(UNKNOWN): ${plan.skipped.length}건${formatNumbers(plan.skipped)}`, + ]; + return lines.join("\n"); +} + +function formatNumbers(pullRequests) { + if (pullRequests.length === 0) { + return ""; + } + return ` — ${pullRequests.map((pullRequest) => `#${pullRequest.number}`).join(", ")}`; +} + +function normalizePullRequest(node) { + return { + number: node.number, + mergeable: node.mergeable, + baseRefName: node.baseRefName, + headRefName: node.headRefName, + isDraft: node.isDraft, + labels: (node.labels?.nodes ?? []).map((item) => item.name), + }; +} + +function createApi(token) { + return async function api(apiPath, { method = "GET", body, allowNotFound = false } = {}) { + const response = await fetch(`https://api.github.com${apiPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (allowNotFound && response.status === 404) { + return null; + } + if (!response.ok) { + const detail = await response.text(); + throw new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); + } + if (response.status === 204) { + return null; + } + return response.json(); + }; +} + +async function graphql(api, query, variables) { + const payload = await api("/graphql", { method: "POST", body: { query, variables } }); + if (payload?.errors?.length) { + throw new Error(`GraphQL 실패: ${JSON.stringify(payload.errors)}`); + } + return payload.data; +} + +async function fetchOpenPullRequests(api, repository) { + const [owner, name] = repository.split("/"); + const pullRequests = []; + let cursor = null; + + for (;;) { + const data = await graphql(api, OPEN_PULL_REQUESTS_QUERY, { + owner, + name, + cursor, + pageSize: PULL_REQUEST_PAGE_SIZE, + }); + const page = data.repository.pullRequests; + pullRequests.push(...page.nodes.map(normalizePullRequest)); + + if (!page.pageInfo.hasNextPage) { + return pullRequests; + } + cursor = page.pageInfo.endCursor; + } +} + +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** + * `UNKNOWN` 이 남아 있으면 다시 조회한다. + * + * GitHub 은 mergeable 을 비동기로 계산하고, 조회 자체가 계산을 촉발한다. 첫 응답에서 UNKNOWN 이 + * 흔하므로 몇 번 더 물어본 뒤에 판정한다. + */ +export async function resolveMergeStates( + api, + repository, + { attempts = UNKNOWN_RESOLVE_ATTEMPTS, delayMs = UNKNOWN_RESOLVE_DELAY_MS, wait = sleep, fetchPage = fetchOpenPullRequests } = {}, +) { + let pullRequests = await fetchPage(api, repository); + + for (let attempt = 1; attempt < attempts; attempt += 1) { + const unknown = pullRequests.filter((pullRequest) => pullRequest.mergeable === "UNKNOWN"); + if (unknown.length === 0) { + break; + } + await wait(delayMs * attempt); + pullRequests = await fetchPage(api, repository); + } + + return pullRequests; +} + +export async function ensureLabelExists(api, repository, label) { + const existing = await api(`/repos/${repository}/labels/${encodeURIComponent(label)}`, { + allowNotFound: true, + }); + if (existing) { + return; + } + await api(`/repos/${repository}/labels`, { + method: "POST", + body: { name: label, color: LABEL_COLOR, description: LABEL_DESCRIPTION }, + }); +} + +async function hasConflictComment(api, repository, number) { + const comments = await api(`/repos/${repository}/issues/${number}/comments?per_page=100`); + return (comments ?? []).some((comment) => (comment.body ?? "").includes(COMMENT_MARKER)); +} + +export async function applyPlan(api, repository, plan, { label, dryRun }) { + const failures = []; + + for (const pullRequest of plan.toLabel) { + try { + if (dryRun) { + console.log(`[dry-run] #${pullRequest.number} 라벨 부착 + 코멘트`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels`, { + method: "POST", + body: { labels: [label] }, + }); + // 사람이 라벨을 손으로 뗐다 붙였다 하는 경우까지 감안해 마커로 한 번 더 확인한다. + if (!(await hasConflictComment(api, repository, pullRequest.number))) { + await api(`/repos/${repository}/issues/${pullRequest.number}/comments`, { + method: "POST", + body: { body: renderConflictComment(pullRequest) }, + }); + } + console.log(`#${pullRequest.number} 라벨 부착 (base ${pullRequest.baseRefName})`); + } catch (error) { + failures.push(`#${pullRequest.number} 라벨 부착 실패: ${error.message}`); + } + } + + for (const pullRequest of plan.toUnlabel) { + try { + if (dryRun) { + console.log(`[dry-run] #${pullRequest.number} 라벨 제거`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels/${encodeURIComponent(label)}`, { + method: "DELETE", + allowNotFound: true, + }); + console.log(`#${pullRequest.number} 라벨 제거`); + } catch (error) { + failures.push(`#${pullRequest.number} 라벨 제거 실패: ${error.message}`); + } + } + + return failures; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + if (!token || !repository) { + throw new Error("GITHUB_TOKEN 과 GITHUB_REPOSITORY 가 필요합니다."); + } + + const label = process.env.CONFLICT_LABEL ?? DEFAULT_LABEL; + const dryRun = process.env.DRY_RUN === "true"; + const api = createApi(token); + + const pullRequests = await resolveMergeStates(api, repository); + const plan = planLabelChanges({ pullRequests, label }); + + if (!dryRun && plan.toLabel.length > 0) { + await ensureLabelExists(api, repository, label); + } + + const failures = await applyPlan(api, repository, plan, { label, dryRun }); + const summary = renderSummary({ plan, label, dryRun }); + console.log(summary); + + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } + + if (failures.length > 0) { + // 개별 실패가 나머지 PR 처리를 막지 않게 모아 두었다가 여기서 한 번에 드러낸다. + throw new Error(failures.join("\n")); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/label-conflicting-prs.test.mjs b/.github/scripts/label-conflicting-prs.test.mjs new file mode 100644 index 0000000000..9537fc263b --- /dev/null +++ b/.github/scripts/label-conflicting-prs.test.mjs @@ -0,0 +1,307 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COMMENT_MARKER, + DEFAULT_LABEL, + applyPlan, + ensureLabelExists, + planLabelChanges, + renderConflictComment, + renderSummary, + resolveMergeStates, +} from "./label-conflicting-prs.mjs"; + +/** + * 호출을 기록하는 가짜 API. `responses` 로 특정 경로의 응답을 지정한다. + */ +function fakeApi({ responses = {}, failOn = null } = {}) { + const calls = []; + const api = async (apiPath, options = {}) => { + calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); + if (failOn && failOn(apiPath, options)) { + throw new Error("boom"); + } + for (const [pattern, value] of Object.entries(responses)) { + if (apiPath.startsWith(pattern)) { + return typeof value === "function" ? value(apiPath, options) : value; + } + } + return options.allowNotFound ? null : {}; + }; + api.calls = calls; + return api; +} + +function pullRequest(overrides = {}) { + return { + number: 1, + mergeable: "MERGEABLE", + baseRefName: "develop", + headRefName: "feature/x", + isDraft: false, + labels: [], + ...overrides, + }; +} + +test("충돌 PR 에 라벨을 붙이고 해소된 PR 에서 뗀다", () => { + const plan = planLabelChanges({ + pullRequests: [ + pullRequest({ number: 10, mergeable: "CONFLICTING" }), + pullRequest({ number: 11, mergeable: "MERGEABLE", labels: [DEFAULT_LABEL] }), + ], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [10]); + assert.deepEqual(plan.toUnlabel.map((item) => item.number), [11]); + assert.deepEqual(plan.skipped, []); +}); + +test("이미 라벨이 붙은 충돌 PR 은 다시 건드리지 않는다", () => { + // 실행마다 코멘트가 쌓이면 알림이 소음이 되고, 저자가 라벨 자체를 무시하게 된다. + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 12, mergeable: "CONFLICTING", labels: [DEFAULT_LABEL, "bug"] })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel, []); + assert.deepEqual(plan.toUnlabel, []); +}); + +test("라벨이 없는 정상 PR 은 아무 대상도 아니다", () => { + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 13, mergeable: "MERGEABLE" })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel, []); + assert.deepEqual(plan.toUnlabel, []); + assert.deepEqual(plan.skipped, []); +}); + +test("UNKNOWN 은 충돌로 단정하지 않고 보류한다", () => { + // GitHub 이 mergeable 을 계산 중인 상태다. 멀쩡한 PR 에 라벨이 붙는 오탐은 + // 라벨이 없는 것보다 나쁘다. + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 14, mergeable: "UNKNOWN" })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel, []); + assert.deepEqual(plan.skipped.map((item) => item.number), [14]); +}); + +test("UNKNOWN 이면서 라벨이 붙어 있어도 성급히 떼지 않는다", () => { + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 15, mergeable: "UNKNOWN", labels: [DEFAULT_LABEL] })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toUnlabel, []); + assert.deepEqual(plan.skipped.map((item) => item.number), [15]); +}); + +test("스택 PR 도 base 가 develop 이 아닐 뿐 같은 규칙으로 본다", () => { + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 16, mergeable: "CONFLICTING", baseRefName: "feat/601" })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.baseRefName), ["feat/601"]); +}); + +test("사용자 지정 라벨 이름을 따른다", () => { + const plan = planLabelChanges({ + pullRequests: [ + pullRequest({ number: 17, mergeable: "CONFLICTING", labels: ["conflict"] }), + pullRequest({ number: 18, mergeable: "MERGEABLE", labels: ["needs-rebase"] }), + ], + label: "needs-rebase", + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [17]); + assert.deepEqual(plan.toUnlabel.map((item) => item.number), [18]); +}); + +test("코멘트에 마커와 base 이름이 들어간다", () => { + const body = renderConflictComment({ baseRefName: "develop" }); + + assert.ok(body.startsWith(COMMENT_MARKER)); + assert.ok(body.includes("`develop` 를 병합해")); + assert.ok(body.includes("#1028")); +}); + +test("UNKNOWN 이 남아 있으면 다시 조회한다", async () => { + const pages = [ + [pullRequest({ number: 20, mergeable: "UNKNOWN" })], + [pullRequest({ number: 20, mergeable: "CONFLICTING" })], + ]; + let calls = 0; + const waits = []; + + const resolved = await resolveMergeStates(null, "o/r", { + attempts: 3, + delayMs: 5, + wait: async (ms) => { + waits.push(ms); + }, + fetchPage: async () => pages[Math.min(calls++, pages.length - 1)], + }); + + assert.equal(calls, 2); + assert.deepEqual(waits, [5]); + assert.equal(resolved[0].mergeable, "CONFLICTING"); +}); + +test("UNKNOWN 이 없으면 한 번만 조회한다", async () => { + let calls = 0; + + await resolveMergeStates(null, "o/r", { + attempts: 3, + delayMs: 5, + wait: async () => {}, + fetchPage: async () => { + calls += 1; + return [pullRequest({ number: 21, mergeable: "MERGEABLE" })]; + }, + }); + + assert.equal(calls, 1); +}); + +test("끝내 UNKNOWN 이면 시도 횟수만큼만 조회하고 보류로 남긴다", async () => { + let calls = 0; + + const resolved = await resolveMergeStates(null, "o/r", { + attempts: 3, + delayMs: 1, + wait: async () => {}, + fetchPage: async () => { + calls += 1; + return [pullRequest({ number: 22, mergeable: "UNKNOWN" })]; + }, + }); + + assert.equal(calls, 3); + assert.deepEqual(planLabelChanges({ pullRequests: resolved, label: DEFAULT_LABEL }).skipped.length, 1); +}); + +test("라벨을 붙이고 안내 코멘트를 한 번 남긴다", async () => { + const api = fakeApi({ responses: { "/repos/o/r/issues/40/comments": [] } }); + + const failures = await applyPlan( + api, + "o/r", + { toLabel: [{ number: 40, baseRefName: "develop" }], toUnlabel: [] }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + assert.deepEqual(failures, []); + const writes = api.calls.filter((call) => call.method === "POST"); + assert.deepEqual(writes.map((call) => call.apiPath), [ + "/repos/o/r/issues/40/labels", + "/repos/o/r/issues/40/comments", + ]); + assert.ok(writes[1].body.body.includes(COMMENT_MARKER)); +}); + +test("마커 코멘트가 이미 있으면 다시 달지 않는다", async () => { + // 사람이 라벨을 손으로 떼었다가 다시 붙는 경우에도 안내가 중복되지 않아야 한다. + const api = fakeApi({ + responses: { "/repos/o/r/issues/41/comments": [{ body: `${COMMENT_MARKER}\n이전 안내` }] }, + }); + + await applyPlan( + api, + "o/r", + { toLabel: [{ number: 41, baseRefName: "develop" }], toUnlabel: [] }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + const posts = api.calls.filter((call) => call.method === "POST").map((call) => call.apiPath); + assert.deepEqual(posts, ["/repos/o/r/issues/41/labels"]); +}); + +test("해소된 PR 에서 라벨만 지운다", async () => { + const api = fakeApi(); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [{ number: 42 }] }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + assert.deepEqual(api.calls, [ + { apiPath: "/repos/o/r/issues/42/labels/conflict", method: "DELETE", body: undefined }, + ]); +}); + +test("dry run 은 아무것도 쓰지 않는다", async () => { + const api = fakeApi(); + + await applyPlan( + api, + "o/r", + { toLabel: [{ number: 43, baseRefName: "develop" }], toUnlabel: [{ number: 44 }] }, + { label: DEFAULT_LABEL, dryRun: true }, + ); + + assert.deepEqual(api.calls, []); +}); + +test("한 PR 이 실패해도 나머지를 처리하고 실패를 모아 돌려준다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues": [] }, + failOn: (apiPath) => apiPath === "/repos/o/r/issues/50/labels", + }); + + const failures = await applyPlan( + api, + "o/r", + { + toLabel: [ + { number: 50, baseRefName: "develop" }, + { number: 51, baseRefName: "develop" }, + ], + toUnlabel: [], + }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + assert.equal(failures.length, 1); + assert.match(failures[0], /#50 라벨 부착 실패/); + assert.ok(api.calls.some((call) => call.apiPath === "/repos/o/r/issues/51/labels")); +}); + +test("라벨이 없으면 만들고, 있으면 만들지 않는다", async () => { + const missing = fakeApi(); + await ensureLabelExists(missing, "o/r", DEFAULT_LABEL); + const created = missing.calls.filter((call) => call.method === "POST"); + assert.equal(created.length, 1); + assert.equal(created[0].body.name, DEFAULT_LABEL); + + const existing = fakeApi({ responses: { "/repos/o/r/labels/conflict": { name: "conflict" } } }); + await ensureLabelExists(existing, "o/r", DEFAULT_LABEL); + assert.equal(existing.calls.filter((call) => call.method === "POST").length, 0); +}); + +test("요약에 건수와 PR 번호가 남는다", () => { + const plan = planLabelChanges({ + pullRequests: [ + pullRequest({ number: 30, mergeable: "CONFLICTING" }), + pullRequest({ number: 31, mergeable: "MERGEABLE", labels: [DEFAULT_LABEL] }), + pullRequest({ number: 32, mergeable: "UNKNOWN" }), + ], + label: DEFAULT_LABEL, + }); + + const summary = renderSummary({ plan, label: DEFAULT_LABEL, dryRun: true }); + + assert.ok(summary.includes("dry run")); + assert.ok(summary.includes("라벨 부착: 1건 — #30")); + assert.ok(summary.includes("라벨 제거: 1건 — #31")); + assert.ok(summary.includes("판정 보류(UNKNOWN): 1건 — #32")); +}); diff --git a/.github/scripts/lint-workflow-policy.test.mjs b/.github/scripts/lint-workflow-policy.test.mjs new file mode 100644 index 0000000000..23b28d50d0 --- /dev/null +++ b/.github/scripts/lint-workflow-policy.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const lintWorkflow = await readFile(new URL("../workflows/lint.yml", import.meta.url), "utf8"); + +test("ktlint runs exactly once per pull request", () => { + // ktlint 를 실제로 돌리는 자리는 Gradle 태스크 하나뿐이다. 검사를 한 번 더 하는 액션이 + // 끼면(과거 ScaCap/action-ktlint) 같은 규칙을 두 번 돌리며 job 의 30% 를 쓴다 (#1012). + const gradleInvocations = lintWorkflow.match(/^\s*run:.*ktlintCheck/gm) ?? []; + assert.equal(gradleInvocations.length, 1); + // 주석은 이력이라 남기고, 실제로 액션을 부르는 uses 줄만 금지한다. + assert.doesNotMatch(lintWorkflow, /^\s*uses:\s*ScaCap\/action-ktlint/m); +}); + +test("the ktlint binary version has a single source of truth", () => { + // 워크플로가 버전을 손으로 적으면 libs.versions.toml 과 표류해 두 검사 결과가 갈린다. + assert.doesNotMatch(lintWorkflow, /ktlint_version/); +}); + +test("ktlint still checks the whole repository, not the pull request diff", () => { + // #875: 파일을 지우면서 남은 파일에 새로 발생하는 파일 단위 위반은 diff 필터로는 + // 보이지 않는다. 저장소 전체를 검사하는 성질이 사라지면 그 회귀가 되돌아온다. + assert.match(lintWorkflow, /\.\/gradlew ktlintCheck[^\n]*--continue/); + assert.doesNotMatch(lintWorkflow, /filter_mode/); +}); + +test("ktlint violations are rendered into the job summary even when the check fails", () => { + assert.match(lintWorkflow, /render-ktlint-summary\.mjs/); + const summaryStep = /- name: Summarize ktlint violations\n\s+if: always\(\)/; + assert.match(lintWorkflow, summaryStep); +}); + +test("Android Lint keeps its inline reporting — it reuses the XML instead of re-running", () => { + // #279 가 푼 문제(모듈별 위반이 수만 줄 로그에 묻힘)를 되돌리지 않는다. lintDebug 가 + // 만든 XML 을 재활용하므로 검사가 두 번 돌지 않는다. + assert.match(lintWorkflow, /lint_xml_file:\s*app\/build\/reports\/lint-results-merged\.xml/); + assert.match(lintWorkflow, /\.\/gradlew lintDebug/); +}); diff --git a/.github/scripts/render-ktlint-summary.mjs b/.github/scripts/render-ktlint-summary.mjs new file mode 100644 index 0000000000..37eff121cd --- /dev/null +++ b/.github/scripts/render-ktlint-summary.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +// ktlint-gradle 이 모듈마다 남기는 checkstyle 리포트 위치. +const REPORT_DIRECTORY = path.join("build", "reports", "ktlint"); +// 모듈 경로는 feature/afternote/presentation 이 가장 깊다. 그 아래로는 내려가지 않는다. +const MAX_MODULE_DEPTH = 4; +const SKIPPED_DIRECTORIES = new Set(["build", "node_modules", ".git", ".gradle", ".idea"]); +// job summary 는 1MB 상한이라 위반 전량을 붙이지 않는다. 나머지는 Gradle 콘솔 출력에 있다. +const MAX_LISTED_VIOLATIONS = 100; + +function decodeXml(value) { + return String(value) + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function attribute(attributes, name) { + const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attributes); + return match ? decodeXml(match[1]) : null; +} + +export function parseCheckstyleXml(xml) { + const violations = []; + const filePattern = /]*)>([\s\S]*?)<\/file>/g; + let fileMatch; + while ((fileMatch = filePattern.exec(xml)) !== null) { + const file = attribute(fileMatch[1], "name"); + if (!file) { + throw new Error("checkstyle file element without a name"); + } + const errorPattern = /]*)\/>/g; + let errorMatch; + while ((errorMatch = errorPattern.exec(fileMatch[2])) !== null) { + violations.push({ + file, + line: Number(attribute(errorMatch[1], "line") ?? 0), + column: Number(attribute(errorMatch[1], "column") ?? 0), + severity: attribute(errorMatch[1], "severity") ?? "error", + message: attribute(errorMatch[1], "message") ?? "", + rule: attribute(errorMatch[1], "source") ?? "unknown", + }); + } + } + return violations; +} + +export async function collectReportFiles(root) { + const reports = []; + + async function collectFromBuildDirectory(buildDirectory) { + const reportRoot = path.join(buildDirectory, "reports", "ktlint"); + let entries; + try { + entries = await fs.readdir(reportRoot, { withFileTypes: true, recursive: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".xml")) { + reports.push(path.join(entry.parentPath ?? reportRoot, entry.name)); + } + } + } + + async function walk(directory, depth) { + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name === "build") { + await collectFromBuildDirectory(path.join(directory, entry.name)); + continue; + } + if (SKIPPED_DIRECTORIES.has(entry.name) || entry.name.startsWith(".")) { + continue; + } + if (depth > 0) { + await walk(path.join(directory, entry.name), depth - 1); + } + } + } + + await walk(root, MAX_MODULE_DEPTH); + return reports.sort(); +} + +export async function collectViolations(root) { + const violations = []; + for (const report of await collectReportFiles(root)) { + violations.push(...parseCheckstyleXml(await fs.readFile(report, "utf8"))); + } + return violations; +} + +function relativize(root, file) { + const relative = path.relative(root, file); + return relative.startsWith("..") ? file : relative; +} + +function countByRule(violations) { + const counts = new Map(); + for (const violation of violations) { + counts.set(violation.rule, (counts.get(violation.rule) ?? 0) + 1); + } + return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); +} + +export function renderSummary(violations, { root = process.cwd(), reportCount = 0 } = {}) { + if (violations.length === 0) { + return reportCount === 0 + ? ["## ktlint", "", "리포트가 없다 — ktlintCheck 가 실행되기 전에 중단됐는지 확인할 것."].join("\n") + : ["## ktlint", "", `✅ 위반 없음 (리포트 ${reportCount}건)`].join("\n"); + } + + const sorted = [...violations].sort( + (left, right) => + left.file.localeCompare(right.file) || left.line - right.line || left.column - right.column, + ); + const files = new Set(sorted.map((violation) => violation.file)); + const lines = [ + "## ktlint", + "", + `❌ **위반 ${sorted.length}건** — 파일 ${files.size}개, 규칙 ${countByRule(sorted).length}종`, + "", + "| 규칙 | 건수 |", + "| --- | ---: |", + ]; + for (const [rule, count] of countByRule(sorted)) { + lines.push(`| \`${rule}\` | ${count} |`); + } + lines.push("", "
위반 위치", ""); + for (const violation of sorted.slice(0, MAX_LISTED_VIOLATIONS)) { + lines.push( + `- \`${relativize(root, violation.file)}:${violation.line}:${violation.column}\` — ${violation.message} (\`${violation.rule}\`)`, + ); + } + if (sorted.length > MAX_LISTED_VIOLATIONS) { + lines.push("", `외 ${sorted.length - MAX_LISTED_VIOLATIONS}건 — 전량은 위 Gradle 스텝 로그에 있다.`); + } + lines.push("", "
", "", "로컬에서 `./gradlew ktlintFormat` 으로 대부분 자동 교정된다."); + return lines.join("\n"); +} + +async function main() { + const root = process.env.GITHUB_WORKSPACE || process.cwd(); + const reports = await collectReportFiles(root); + const violations = []; + for (const report of reports) { + violations.push(...parseCheckstyleXml(await fs.readFile(report, "utf8"))); + } + + const summary = renderSummary(violations, { root, reportCount: reports.length }); + const target = process.env.GITHUB_STEP_SUMMARY; + if (target) { + await fs.appendFile(target, `${summary}\n`); + } else { + process.stdout.write(`${summary}\n`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/render-ktlint-summary.test.mjs b/.github/scripts/render-ktlint-summary.test.mjs new file mode 100644 index 0000000000..be25551d8f --- /dev/null +++ b/.github/scripts/render-ktlint-summary.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { collectReportFiles, collectViolations, parseCheckstyleXml, renderSummary } from "./render-ktlint-summary.mjs"; + +const REPORT = ` + + + + + +`; + +const EMPTY_REPORT = ` + +`; + +test("parses file, position, rule, and decoded message from a checkstyle report", () => { + const violations = parseCheckstyleXml(REPORT); + + assert.equal(violations.length, 2); + assert.deepEqual(violations[0], { + file: "/workspace/core/common/src/main/java/Probe.kt", + line: 4, + column: 15, + severity: "error", + message: "Missing newline after '{'", + rule: "standard:statement-wrapping", + }); + assert.equal(violations[1].message, 'Missing spacing around "="'); +}); + +test("reports no violations for an empty checkstyle report", () => { + assert.deepEqual(parseCheckstyleXml(EMPTY_REPORT), []); +}); + +test("collects reports from every module build directory but not from the sources", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "ktlint-summary-")); + try { + const moduleReport = path.join( + root, + "feature/afternote/presentation/build/reports/ktlint/ktlintMainSourceSetCheck", + ); + await fs.mkdir(moduleReport, { recursive: true }); + await fs.writeFile(path.join(moduleReport, "ktlintMainSourceSetCheck.xml"), REPORT); + await fs.writeFile(path.join(moduleReport, "ktlintMainSourceSetCheck.txt"), "plain output"); + + const unrelated = path.join(root, "core/ui/build/reports/lint-results-debug.xml"); + await fs.mkdir(path.dirname(unrelated), { recursive: true }); + await fs.writeFile(unrelated, ""); + + const sourceDirectory = path.join(root, "core/ui/src/main/java"); + await fs.mkdir(sourceDirectory, { recursive: true }); + await fs.writeFile(path.join(sourceDirectory, "Sample.xml"), REPORT); + + const reports = await collectReportFiles(root); + assert.equal(reports.length, 1); + assert.match(reports[0], /ktlintMainSourceSetCheck\.xml$/); + + const violations = await collectViolations(root); + assert.equal(violations.length, 2); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("renders a rule tally and repository relative positions when violations exist", () => { + const summary = renderSummary(parseCheckstyleXml(REPORT), { root: "/workspace", reportCount: 1 }); + + assert.match(summary, /❌ \*\*위반 2건\*\* — 파일 1개, 규칙 2종/); + assert.match(summary, /\| `standard:op-spacing` \| 1 \|/); + assert.match(summary, /- `core\/common\/src\/main\/java\/Probe\.kt:4:15`/); + assert.doesNotMatch(summary, /\/workspace\/core/); + assert.match(summary, /ktlintFormat/); +}); + +test("renders a pass line when reports exist without violations", () => { + const summary = renderSummary([], { root: "/workspace", reportCount: 3 }); + + assert.match(summary, /✅ 위반 없음 \(리포트 3건\)/); +}); + +test("distinguishes a clean run from a run that produced no report at all", () => { + const summary = renderSummary([], { root: "/workspace", reportCount: 0 }); + + assert.match(summary, /리포트가 없다/); +}); + +test("truncates the listing but keeps the total and the tally exact", () => { + const many = Array.from({ length: 150 }, (unused, index) => ({ + file: "/workspace/core/common/src/main/java/Probe.kt", + line: index + 1, + column: 1, + severity: "error", + message: "Missing newline", + rule: "standard:statement-wrapping", + })); + + const summary = renderSummary(many, { root: "/workspace", reportCount: 1 }); + + assert.match(summary, /❌ \*\*위반 150건\*\*/); + assert.match(summary, /\| `standard:statement-wrapping` \| 150 \|/); + assert.match(summary, /외 50건/); + assert.equal(summary.split("\n").filter((line) => line.startsWith("- `")).length, 100); +}); diff --git a/.github/workflows/build-cache-warm.yml b/.github/workflows/build-cache-warm.yml new file mode 100644 index 0000000000..ba7dd13656 --- /dev/null +++ b/.github/workflows/build-cache-warm.yml @@ -0,0 +1,72 @@ +name: Build Cache Warm + +# PR 검증 job 4개가 같은 commit 의 같은 debug variant 를 각자 처음부터 컴파일하던 문제(#996)의 +# 공급 측이다. lint·unit-test·screenshot 은 --build-cache 로 읽을 준비가 돼 있지만, PR job 은 +# cache poisoning 방지를 위해 전부 cache-read-only 라 아무도 캐시를 채우지 않는다. +# +# 이 워크플로가 develop push 에서 유일한 writer 다. CodeQL 이 그 역할을 겸하던 시절에는 +# 캐시를 채우려고 --build-cache 를 붙였다가 추출기가 컴파일러 호출을 관찰하지 못하는 +# 문제로 되돌려야 했다(#1014). 워밍과 분석을 분리해 둘 다 제 조건에서 돌게 한다. +on: + push: + branches: [develop] + workflow_dispatch: + +# 워밍은 취소하지 않는다. develop 은 PR 을 연달아 머지하는 브랜치라 cancel-in-progress 면 +# 마지막 push 를 뺀 전부가 중도에 죽고, 캐시를 저장하는 post 스텝까지 못 간다. +concurrency: + group: build-cache-warm-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + warm: + name: Warm Gradle build cache + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Clone repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'temurin' + java-version: '21' + + # 이 저장소에서 cache-read-only: false 를 쓰는 유일한 자리다. setup-gradle 의 기본값은 + # «default branch 에서만 write» 인데 이 저장소의 default branch 는 main 이라, develop 에서 + # 쓰려면 명시해야 한다. 캐시 대상에는 caches/build-cache-1 이 포함된다. + - name: Set up Gradle and validate wrapper + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + cache-read-only: false + validate-wrappers: true + + - name: Set up CI-only app configuration + uses: ./.github/actions/setup-ci-config + + # PR 이 실제로 돌리는 태스크와 같은 것을 돌려야 그 산출물이 재사용된다. 목록은 + # lint.yml(ktlintCheck·lintDebug)과 unit-test.yml 의 태스크를 합친 것이다. + # + # screenshot 은 넣지 않는다 — docker 컨테이너 안에서 돌아 캐시 키에 들어가는 실행 + # 환경이 러너와 갈릴 수 있고, 그 job 이 실제로 아끼는 컴파일 산출물은 여기서 이미 + # 채워진다. + # + # --continue 는 develop 이 깨져 있을 때도 통과한 만큼은 캐시에 남기기 위한 것이다. + # 워밍은 게이트가 아니므로 실패가 머지를 막지 않는다. + - name: Populate build cache with the tasks pull requests run + run: >- + ./gradlew + ktlintCheck + lintDebug + :koverXmlReportCi + :konsist:test + :build-logic:test + :app:compileDebugAndroidTestKotlin + --continue + --parallel + --build-cache diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5bdd521ea7..7d8d53bc55 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,12 +49,16 @@ jobs: distribution: temurin java-version: '21' + # 읽기 전용이다 — push 에서도 쓰지 않는다. 이 job 은 --build-cache 를 쓸 수 없어 + # (아래 주석) build-cache-1 이 빈 Gradle User Home 을 저장하게 되는데, Actions 캐시는 + # 같은 키를 덮어쓰지 못하므로 그것이 먼저 저장되면 build-cache-warm.yml 이 채운 + # 캐시가 올라갈 자리를 잃는다. develop push 의 캐시 writer 는 그 워크플로 하나다 (#996). - name: Set up Gradle and validate wrapper if: matrix.build_mode == 'manual' uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-provider: basic - cache-read-only: ${{ github.event_name == 'pull_request' }} + cache-read-only: true validate-wrappers: true - name: Set up CI-only app configuration @@ -68,11 +72,21 @@ jobs: build-mode: ${{ matrix.build_mode }} # Java/Kotlin extraction observes compiler invocations, so a clean manual build is the - # contract. assembleDebug covers every Android leaf; the two pure JVM leaves need classes. + # contract. compileDebugSources covers every Android leaf; the two pure JVM leaves need classes. + # + # 추출에 필요한 것은 컴파일러 호출이지 APK 가 아니다. assembleDebug 는 그 뒤로 + # dex 병합(R8)까지 끌고 갔고, 그 :app:mergeExtDexDebug 가 GitHub 러너(7GB)에서 + # 힙을 넘겨 CodeQL 을 산발적으로 실패시켰다 — 코드 결함과 구분되지 않는 빨간불이었다. + # compileDebugSources 는 같은 114개 컴파일 task 를 그대로 돌리면서 dex·packaging 만 + # 뺀다 (#1023). + # + # 이 워크플로는 #996 의 --build-cache 예외이기도 하다. compile task 가 FROM-CACHE 로 + # 넘어가면 컴파일러가 아예 실행되지 않아 추출기가 소스를 담지 못하고, 아래 verify + # 스텝이 빈 database 로 실패한다. lint·unit-test·screenshot 에는 이 제약이 없다. - name: Compile Android and pure JVM Kotlin sources if: matrix.build_mode == 'manual' run: >- - ./gradlew assembleDebug + ./gradlew compileDebugSources :feature:onboarding:domain:classes :feature:setting:domain:classes --no-configuration-cache diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml new file mode 100644 index 0000000000..710b5802e7 --- /dev/null +++ b/.github/workflows/conflict-label.yml @@ -0,0 +1,56 @@ +name: Conflict Label + +# 충돌 PR 은 `pull_request` 워크플로가 트리거되지 않는다 (#1028) — GitHub 이 base 와 head 를 +# 합친 `refs/pull//merge` 를 만들지 못하기 때문이다. 그래서 이 워크플로는 `pull_request` 를 +# 쓰지 않는다. +# +# **지금은 수동 실행 전용이다.** 도입 시점에 이미 충돌 PR 이 10건이라, 자동 트리거를 켜면 첫 +# 실행에서 라벨과 안내 코멘트가 한꺼번에 쏟아진다. 먼저 `dry_run` 으로 실제 러너 환경에서 +# 판정을 확인하고, 결과를 보고 아래 두 트리거를 여는 후속 PR 을 낸다. +# +# ```yaml +# push: +# branches: [develop] # 충돌을 만들어 내는 쪽 — develop 이 움직인 직후 +# schedule: +# - cron: '23 * * * *' # 보조. 정각 부하를 피해 매시 23분 +# ``` +on: + workflow_dispatch: + inputs: + dry_run: + description: "라벨·코멘트를 쓰지 않고 계획만 출력" + required: false + # 자동 트리거가 없는 동안의 기본값은 안전한 쪽이다. 실제로 쓰려면 끄고 실행한다. + default: true + type: boolean + +# 실행이 겹치면 같은 PR 에 코멘트가 두 번 달릴 수 있다. 취소하지 않고 줄을 세운다. +concurrency: + group: conflict-label + cancel-in-progress: false + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + name: Label conflicting pull requests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Clone repo + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Run script tests + run: node --test .github/scripts/label-conflicting-prs.test.mjs + + - name: Label conflicting pull requests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }} + run: node .github/scripts/label-conflicting-prs.mjs diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index c955317096..ec30568ed5 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -45,25 +45,13 @@ jobs: cache-provider: basic validate-wrappers: true - - name: Verify required secrets are set - env: - KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} - GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} - run: | - missing="" - [ -z "$KAKAO_NATIVE_APP_KEY" ] && missing="$missing KAKAO_NATIVE_APP_KEY" - [ -z "$GOOGLE_WEB_CLIENT_ID" ] && missing="$missing GOOGLE_WEB_CLIENT_ID" - [ -z "$GOOGLE_SERVICES_JSON_B64" ] && missing="$missing GOOGLE_SERVICES_JSON_B64" - if [ -n "$missing" ]; then - echo "::error::필수 secret 누락:$missing — Settings → Secrets and variables → Actions 에 등록" - exit 1 - fi - - - name: Decode google-services.json - env: - GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} - run: echo "$GOOGLE_SERVICES_JSON_B64" | base64 -d > app/google-services.json + - name: Set up release build configuration + uses: ./.github/actions/setup-release-config + with: + require: KAKAO_NATIVE_APP_KEY GOOGLE_WEB_CLIENT_ID GOOGLE_SERVICES_JSON_B64 + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + google-services-json-b64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} - name: Run dependency audit script tests run: node --test .github/scripts/*.test.mjs @@ -145,3 +133,7 @@ jobs: - name: Fail when current compatibility gate failed if: steps.compatibility.outcome == 'failure' run: exit 1 + + - name: Remove release build configuration + if: always() + uses: ./.github/actions/cleanup-release-config diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 982d868e87..b72fa681a8 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -21,6 +21,11 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + env: + # base(develop)·head(PR) 스냅샷이 같은 correlator 라야 compare API 가 둘을 짝짓는다. + # job 이름을 따라 갈리면 develop base PR 에서 base 가 0 건으로 보여 그 PR 의 의존성 + # 전량이 «신규 도입» 으로 계산되고, 기존 의존성의 취약점이 그 PR 탓으로 걸린다 (#1009). + GITHUB_DEPENDENCY_GRAPH_JOB_CORRELATOR: afternote-gradle-dependency-graph steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -48,6 +53,11 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + # base(develop)·head(PR) 스냅샷이 같은 correlator 라야 compare API 가 둘을 짝짓는다. + # job 이름을 따라 갈리면 develop base PR 에서 base 가 0 건으로 보여 그 PR 의 의존성 + # 전량이 «신규 도입» 으로 계산되고, 기존 의존성의 취약점이 그 PR 탓으로 걸린다 (#1009). + GITHUB_DEPENDENCY_GRAPH_JOB_CORRELATOR: afternote-gradle-dependency-graph steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/firebase-wif-canary.yml b/.github/workflows/firebase-wif-canary.yml index b94fe014f1..1768b59854 100644 --- a/.github/workflows/firebase-wif-canary.yml +++ b/.github/workflows/firebase-wif-canary.yml @@ -21,8 +21,10 @@ on: default: false type: boolean +# release-distribution.yml 과 같은 group — 업로드 대상 Firebase 앱이 하나다. 한쪽만 바꾸면 +# 두 러너가 같은 앱에 동시 업로드한다 (#995). concurrency: - group: firebase-wif-canary + group: firebase-app-distribution-upload cancel-in-progress: false permissions: @@ -91,55 +93,25 @@ jobs: cache-read-only: false validate-wrappers: true - # FIREBASE_SERVICE_ACCOUNT_JSON은 의도적으로 참조하지 않는다. 기존 production rollback - # 경로에는 남겨 두되, 이 canary의 인증 성공 여부에는 영향을 줄 수 없게 분리한다. - - name: Verify release build secrets are set - env: - GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} - GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} - KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} - RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - RELEASE_STORE_FILE_B64: ${{ secrets.RELEASE_STORE_FILE_B64 }} - RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} - run: | - missing="" - [ -z "$KAKAO_NATIVE_APP_KEY" ] && missing="$missing KAKAO_NATIVE_APP_KEY" - [ -z "$GOOGLE_WEB_CLIENT_ID" ] && missing="$missing GOOGLE_WEB_CLIENT_ID" - [ -z "$GOOGLE_SERVICES_JSON_B64" ] && missing="$missing GOOGLE_SERVICES_JSON_B64" - [ -z "$RELEASE_STORE_FILE_B64" ] && missing="$missing RELEASE_STORE_FILE_B64" - [ -z "$RELEASE_STORE_PASSWORD" ] && missing="$missing RELEASE_STORE_PASSWORD" - [ -z "$RELEASE_KEY_ALIAS" ] && missing="$missing RELEASE_KEY_ALIAS" - [ -z "$RELEASE_KEY_PASSWORD" ] && missing="$missing RELEASE_KEY_PASSWORD" - if [ -n "$missing" ]; then - echo "::error::필수 release build secret 누락:$missing" - exit 1 - fi - - - name: Decode release build files - env: - GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} - RELEASE_STORE_FILE_B64: ${{ secrets.RELEASE_STORE_FILE_B64 }} - run: | - printf '%s' "$GOOGLE_SERVICES_JSON_B64" | base64 -d > app/google-services.json - printf '%s' "$RELEASE_STORE_FILE_B64" | base64 -d > "$RUNNER_TEMP/afternote-release.jks" - - - name: Compose local.properties - env: - GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} - KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} - RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} - run: | - { - echo "KAKAO_NATIVE_APP_KEY=$KAKAO_NATIVE_APP_KEY" - echo "GOOGLE_WEB_CLIENT_ID=$GOOGLE_WEB_CLIENT_ID" - echo "RELEASE_STORE_FILE=$RUNNER_TEMP/afternote-release.jks" - echo "RELEASE_STORE_PASSWORD=$RELEASE_STORE_PASSWORD" - echo "RELEASE_KEY_ALIAS=$RELEASE_KEY_ALIAS" - echo "RELEASE_KEY_PASSWORD=$RELEASE_KEY_PASSWORD" - } > local.properties + # firebase-service-account-json 을 의도적으로 넘기지 않는다. 기존 production rollback + # 경로에는 secret 을 남겨 두되, 이 canary 의 인증 성공 여부에는 영향을 줄 수 없게 분리한다. + # require 에도 없으므로 그 secret 이 비어 있어도 canary 는 통과한다. + - name: Set up release build configuration + id: release_config + uses: ./.github/actions/setup-release-config + with: + require: >- + KAKAO_NATIVE_APP_KEY GOOGLE_WEB_CLIENT_ID GOOGLE_SERVICES_JSON_B64 + RELEASE_STORE_FILE_B64 RELEASE_STORE_PASSWORD RELEASE_KEY_ALIAS + RELEASE_KEY_PASSWORD + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + google-services-json-b64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} + release-store-file-b64: ${{ secrets.RELEASE_STORE_FILE_B64 }} + release-store-password: ${{ secrets.RELEASE_STORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + compose-local-properties: 'true' # public 저장소이므로 signed APK를 Actions artifact로 게시하거나 job 사이에 전달하지 않는다. - name: Build signed release APK without Google credentials @@ -234,7 +206,8 @@ jobs: echo "- run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" } >> "$GITHUB_STEP_SUMMARY" - - name: Remove release build files + - name: Remove release build configuration if: always() - run: | - rm -f app/google-services.json local.properties "$RUNNER_TEMP/afternote-release.jks" + uses: ./.github/actions/cleanup-release-config + with: + keystore-path: ${{ steps.release_config.outputs.keystore-path }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 272ec5f5a5..7337f1548d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,6 +5,10 @@ name: Static Analysis (Reviewdog) on: pull_request: +concurrency: + group: static-analysis-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read pull-requests: write @@ -32,20 +36,23 @@ jobs: cache-read-only: true validate-wrappers: true + # ktlint 를 돌리는 자리는 여기 하나다. 예전에는 ScaCap/action-ktlint(reviewdog)가 + # PR diff 의 추가된 줄을, 이 태스크가 저장소 전체를 각각 검사해 같은 규칙을 두 번 + # 돌렸다 — reviewdog 경로만 32초, job 의 30% 였다. 인라인 코멘트의 원래 수요자는 + # 로그를 뒤지기 싫은 사람인데 이 저장소 작업자는 에이전트라 로그를 직접 읽는다. + # 막는 것(ktlintCheck)만 남기고 알려주는 것은 아래 job summary 로 옮겼다 (#1012). + # + # 저장소 전체 검사는 유지한다 — 파일을 지우면서 남은 파일에 새로 발생하는 파일 단위 + # 위반은 diff 필터로 보이지 않는다 (#875). + # --build-cache 는 CI 한정 — gradle.properties 의 org.gradle.caching=false 로컬 기본값은 그대로다 (#996). - name: Run Ktlint - uses: ScaCap/action-ktlint@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - reporter: github-pr-review # PR 리뷰 댓글로 표시 - android: true # Android 스타일 가이드 준수 - fail_on_error: true # 에러 발견 시 빌드 실패 - level: warning # Warning 이상은 댓글로 표시 - ktlint_version: "1.8.0" # 로컬(libs.versions.toml)과 버전 통일 + run: ./gradlew ktlintCheck --continue --parallel --build-cache - # Reviewdog 의 diff 필터는 삭제로 새로 발생한 파일 단위 위반을 놓칠 수 있다. - # Gradle 검사는 변경 라인과 무관하게 저장소 전체의 ktlint 규칙을 강제한다. - - name: Run full Ktlint check - run: ./gradlew ktlintCheck --continue --parallel + # 실패한 위치를 Actions 로그 수천 줄이 아니라 job summary 상단에서 바로 보게 한다. + # ktlint-gradle 이 모듈마다 남긴 checkstyle XML 을 읽으므로 검사가 다시 돌지 않는다. + - name: Summarize ktlint violations + if: always() + run: node .github/scripts/render-ktlint-summary.mjs android-lint: name: Check Project Issues (Android Lint) @@ -54,7 +61,7 @@ jobs: - name: Clone repo uses: actions/checkout@v7 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v5 with: distribution: 'temurin' @@ -71,7 +78,7 @@ jobs: uses: ./.github/actions/setup-ci-config - name: Run Android Lint - run: ./gradlew lintDebug --continue --parallel + run: ./gradlew lintDebug --continue --parallel --build-cache # 모듈별 lint XML 을 한 파일로 합쳐 Reviewdog 가 :feature/* · :core/* 위반도 # PR 인라인 코멘트로 띄우도록 한다. 이전엔 :app XML 한 개만 전달돼 다른 모듈 위반이 diff --git a/.github/workflows/merge-order-guard.yml b/.github/workflows/merge-order-guard.yml index 0064a1de3e..caf0c8c2cf 100644 --- a/.github/workflows/merge-order-guard.yml +++ b/.github/workflows/merge-order-guard.yml @@ -1,9 +1,9 @@ # PR 머지 순서 가드 (#660) # # GitHub 는 이슈 Relationships(blocked_by)를 표시만 하고 머지 버튼을 잠그지 않는다. -# 이 워크플로는 PR 의 closing 이슈에 아직 열린 blocked_by 가 있거나, base 가 -# develop/main 이 아니면(스택 PR 미PATCH 상태) check 를 실패시킨다. -# branch protection 에서 required check 로 지정하면 머지 버튼이 실제로 잠긴다. +# 이 워크플로는 PR 의 closing 이슈에 아직 열린 blocked_by 가 있으면 check 를 +# 실패시킨다. branch protection 에서 required check 로 지정하면 머지 버튼이 +# 실제로 잠긴다. base 가 develop/main 이 아닌 스택 PR 은 경고로만 남긴다. name: merge-order-guard on: @@ -21,7 +21,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - name: closing 이슈 blocked_by · base 검사 + - name: closing 이슈 blocked_by 검사 env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -32,12 +32,14 @@ jobs: fail=0 - # 스택 PR 방호 — base 가 feat 브랜치면 그 브랜치로 머지돼 버린다. + # 스택 PR 안내 — base 가 부모 브랜치면 애초에 develop 로 머지되지 않고, + # 부모가 머지되면 GitHub 가 base 를 develop 로 이관하며 이 가드가 다시 + # 돌아 blocked_by 를 검사한다. 실패로 올리면 스택 PR 이 수명 내내 X 로 + # 남아 목록에서 진짜 CI 실패와 구분되지 않으므로 경고로만 남긴다. case "$BASE_REF" in develop|main) ;; *) - echo "::error::base 가 '$BASE_REF' — 스택 PR 은 base 를 develop 로 PATCH 한 뒤 머지할 것" - fail=1 + echo "::warning::base 가 '$BASE_REF' — 스택 PR 은 base 를 develop 로 PATCH 한 뒤 머지할 것" ;; esac diff --git a/.github/workflows/release-distribution.yml b/.github/workflows/release-distribution.yml index a984f69cd0..463f602126 100644 --- a/.github/workflows/release-distribution.yml +++ b/.github/workflows/release-distribution.yml @@ -18,10 +18,12 @@ on: required: true type: string -# 수동 배포 도중 main 릴리스 push 가 들어오면 러너 둘이 같은 App Distribution 앱에 올린다. -# cancel-in-progress 는 기본값(false) 그대로 — 릴리스 배포가 수동 배포에 밀려 취소되면 안 된다. +# 같은 Firebase App Distribution 앱에 올리는 경로를 전부 이 group 하나로 직렬화한다 — 수동 배포 +# 도중 main 릴리스 push 가 들어오거나 WIF canary(firebase-wif-canary.yml)가 겹치면 러너 둘이 같은 +# 앱에 업로드한다. group 문자열은 두 워크플로가 공유하므로 한쪽만 바꾸면 방호가 풀린다 (#995). +# cancel-in-progress 는 기본값(false) 그대로 — 진행 중 업로드가 취소되면 부분 상태가 남는다. concurrency: - group: release-distribution + group: firebase-app-distribution-upload permissions: contents: read @@ -78,7 +80,7 @@ jobs: bash .github/scripts/render-distribution-release-notes.sh "$RELEASE_NOTES_FILE" cat "$RELEASE_NOTES_FILE" >> "$GITHUB_STEP_SUMMARY" - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v5 with: distribution: 'temurin' @@ -91,82 +93,41 @@ jobs: cache-read-only: false validate-wrappers: true - # fail-fast — secret 누락 시 Gradle 까지 가지 않고 즉시 실패. - # secret 값은 echo 하지 않음 (env 로만 받아서 길이 검사). 누락 키 이름은 안전. - - name: Verify required secrets are set - env: - KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} - GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} - RELEASE_STORE_FILE_B64: ${{ secrets.RELEASE_STORE_FILE_B64 }} - RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} - RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} - RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }} - run: | - missing="" - [ -z "$KAKAO_NATIVE_APP_KEY" ] && missing="$missing KAKAO_NATIVE_APP_KEY" - [ -z "$GOOGLE_WEB_CLIENT_ID" ] && missing="$missing GOOGLE_WEB_CLIENT_ID" - [ -z "$GOOGLE_SERVICES_JSON_B64" ] && missing="$missing GOOGLE_SERVICES_JSON_B64" - [ -z "$RELEASE_STORE_FILE_B64" ] && missing="$missing RELEASE_STORE_FILE_B64" - [ -z "$RELEASE_STORE_PASSWORD" ] && missing="$missing RELEASE_STORE_PASSWORD" - [ -z "$RELEASE_KEY_ALIAS" ] && missing="$missing RELEASE_KEY_ALIAS" - [ -z "$RELEASE_KEY_PASSWORD" ] && missing="$missing RELEASE_KEY_PASSWORD" - [ -z "$FIREBASE_SERVICE_ACCOUNT_JSON" ] && missing="$missing FIREBASE_SERVICE_ACCOUNT_JSON" - if [ -n "$missing" ]; then - echo "::error::필수 secret 누락:$missing — Settings → Secrets and variables → Actions 에 등록" - exit 1 - fi - - # google-services plugin 이 app/google-services.json 을 요구. 파일은 .gitignore 라 - # CI 환경엔 없음. base64 인코딩된 secret 으로 받아서 decode. - - name: Decode google-services.json - env: - GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} - run: | - echo "$GOOGLE_SERVICES_JSON_B64" | base64 -d > app/google-services.json - - - name: Decode release keystore - env: - RELEASE_STORE_FILE_B64: ${{ secrets.RELEASE_STORE_FILE_B64 }} - run: | - echo "$RELEASE_STORE_FILE_B64" | base64 -d > "$HOME/afternote-release.jks" + - name: Set up release build configuration + id: release_config + uses: ./.github/actions/setup-release-config + with: + require: >- + KAKAO_NATIVE_APP_KEY GOOGLE_WEB_CLIENT_ID GOOGLE_SERVICES_JSON_B64 + RELEASE_STORE_FILE_B64 RELEASE_STORE_PASSWORD RELEASE_KEY_ALIAS + RELEASE_KEY_PASSWORD FIREBASE_SERVICE_ACCOUNT_JSON + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + google-services-json-b64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} + release-store-file-b64: ${{ secrets.RELEASE_STORE_FILE_B64 }} + release-store-password: ${{ secrets.RELEASE_STORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + firebase-service-account-json: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }} + compose-local-properties: 'true' # Firebase App Distribution Gradle plugin 이 GOOGLE_APPLICATION_CREDENTIALS 환경변수로 # service account JSON 경로를 인식. plugin DSL 의 serviceCredentialsFile 미지정 시 fallback. # 참고: https://firebase.google.com/docs/app-distribution/android/distribute-gradle - - name: Decode Firebase service account credentials - env: - FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }} - run: | - printf '%s' "$FIREBASE_SERVICE_ACCOUNT_JSON" > "$HOME/firebase-service-account.json" - - # app/build.gradle.kts 의 signingConfigs.release 가 local.properties 의 RELEASE_* 4개 키를 - # 읽음. CI 환경에서는 local.properties 가 없으므로 secret 으로부터 작성. - - name: Compose local.properties - env: - KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} - RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} - RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} - RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - run: | - { - echo "KAKAO_NATIVE_APP_KEY=$KAKAO_NATIVE_APP_KEY" - echo "GOOGLE_WEB_CLIENT_ID=$GOOGLE_WEB_CLIENT_ID" - echo "RELEASE_STORE_FILE=$HOME/afternote-release.jks" - echo "RELEASE_STORE_PASSWORD=$RELEASE_STORE_PASSWORD" - echo "RELEASE_KEY_ALIAS=$RELEASE_KEY_ALIAS" - echo "RELEASE_KEY_PASSWORD=$RELEASE_KEY_PASSWORD" - } > local.properties - - name: Build release APK and upload to Firebase App Distribution env: + GOOGLE_APPLICATION_CREDENTIALS: ${{ steps.release_config.outputs.firebase-credentials-path }} KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} RELEASE_NOTES_FILE: ${{ runner.temp }}/firebase-app-distribution-release-notes.txt run: | - export GOOGLE_APPLICATION_CREDENTIALS="$HOME/firebase-service-account.json" ./gradlew assembleRelease appDistributionUploadRelease \ --releaseNotesFile="$RELEASE_NOTES_FILE" \ --no-daemon + + - name: Remove release build configuration + if: always() + uses: ./.github/actions/cleanup-release-config + with: + keystore-path: ${{ steps.release_config.outputs.keystore-path }} + firebase-credentials-path: ${{ steps.release_config.outputs.firebase-credentials-path }} diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index e70ab713a9..bc5756f396 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -5,6 +5,10 @@ name: Compose Preview Screenshot Test on: pull_request: +concurrency: + group: screenshot-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read pull-requests: write @@ -55,10 +59,11 @@ jobs: -e GOOGLE_WEB_CLIENT_ID \ afternote-screenshot:latest \ ./gradlew :core:ui:validateScreenshotTest \ - :app:validateScreenshotTest \ + :feature:home:presentation:validateScreenshotTest \ + :feature:receiver:presentation:validateScreenshotTest \ :feature:onboarding:presentation:validateScreenshotTest \ :feature:afternote:presentation:validateScreenshotTest \ - --parallel + --parallel --build-cache # 컨테이너가 root 로 gradle 을 실행해 workspace · ~/.gradle 산출물이 root 소유가 된다. # job 종료 시 'Post Restore Gradle cache'(캐시 저장)의 hashFiles/tar 가 runner 권한이라 @@ -75,7 +80,8 @@ jobs: name: screenshot-test-results path: | core/ui/build/outputs/screenshotTest-results/ - app/build/outputs/screenshotTest-results/ + feature/home/presentation/build/outputs/screenshotTest-results/ + feature/receiver/presentation/build/outputs/screenshotTest-results/ feature/onboarding/presentation/build/outputs/screenshotTest-results/ feature/afternote/presentation/build/outputs/screenshotTest-results/ retention-days: 7 @@ -98,7 +104,7 @@ jobs: '🔍 **확인 위치** (artifact zip 내부):', '- diff PNG: `preview/debug/diffs/...`', '- rendered PNG: `preview/debug/rendered/...` (CI 환경에서 새로 그린 결과)', - '- baseline PNG: repo `/src/screenshotTestDebug/reference/...` (`:core:ui` · `:app` · `:feature:onboarding:presentation` · `:feature:afternote:presentation`)', + '- baseline PNG: repo `/src/screenshotTestDebug/reference/...` (`:core:ui` · `:feature:home:presentation` · `:feature:receiver:presentation` · `:feature:onboarding:presentation` · `:feature:afternote:presentation`)', '', '📝 **조치 가이드**:', '- 의도된 시각 변경 → `docker build -t afternote-screenshot:latest -f Dockerfile.screenshot .` 후 `docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest ./gradlew ::updateScreenshotTest` 로 baseline 갱신 + commit/push', diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 50baf2a844..523882580f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -5,6 +5,9 @@ name: Unit Test # - :koverXmlReportCi / :koverHtmlReportCi : Android debug + 순수 JVM 테스트와 aggregate coverage # - :konsist:test : 순수 JVM 모듈(testDebugUnitTest 미보유)이라 별도 명시. 레이어 의존 아키텍처 가드. # - :build-logic:test : included build 라 루트 태스크에 안 묶여 별도 명시. release 키 가드(TestKit) 회귀 가드. +# - :app:compileDebugAndroidTestKotlin : androidTest 소스셋 컴파일 가드. GMD 스위트는 PR 트리거 없이 +# 화·금 스케줄로만 돌아(android-managed-device.yml), main 코드 변경이 androidTest 를 깨뜨려도 +# 머지 전 어디서도 검출되지 않았다(#936 — develop 컴파일 실패 44건). 에뮬레이터 없이 컴파일만 검증. # lint.yml 과 setup 이 거의 동일하지만 분리 — lint 실패가 test 결과를 가리지 않도록 fail-fast 독립. # base 필터를 두지 않는다 — base 변경(feat/* → develop)은 pull_request 기본 activity type 에 @@ -12,6 +15,10 @@ name: Unit Test on: pull_request: +concurrency: + group: unit-test-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -35,7 +42,7 @@ jobs: - name: Run deployment script tests run: node --test .github/scripts/*.test.mjs - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v5 with: distribution: 'temurin' @@ -51,8 +58,8 @@ jobs: - name: Set up CI-only app configuration uses: ./.github/actions/setup-ci-config - - name: Run unit tests and aggregate Kover reports - run: ./gradlew :koverXmlReportCi :koverHtmlReportCi :konsist:test :build-logic:test --continue --parallel + - name: Run unit tests, androidTest compile guard, and aggregate Kover reports + run: ./gradlew :koverXmlReportCi :koverHtmlReportCi :konsist:test :build-logic:test :app:compileDebugAndroidTestKotlin --continue --parallel --build-cache - name: Upload Kover coverage reports id: kover-artifact diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1056cd77be..aa19a5f5ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.google.services) alias(libs.plugins.firebase.app.distribution) alias(libs.plugins.firebase.crashlytics) - alias(libs.plugins.compose.screenshot) } val localProperties = Properties() @@ -16,15 +15,13 @@ if (localPropertiesFile.exists()) { localPropertiesFile.inputStream().use { localProperties.load(it) } } -// 카카오 OAuth redirect(`kakao{KEY}://oauth`) 핸들러 등록용. -// SDK 런타임 초기화 키는 `core:startup`의 BuildConfig.KAKAO_NATIVE_APP_KEY 사용. +// OAuth redirect(`kakao{KEY}://oauth`) 핸들러 등록용 manifestPlaceholder 와 SDK 런타임 초기화용 +// BuildConfig 가 같은 값을 쓴다 — 주입 지점이 둘이어도 키는 여기서 한 번만 읽는다. val kakaoKey = socialLoginKey("KAKAO_NATIVE_APP_KEY") android { namespace = "com.afternote.afternote_fe" - experimentalProperties["android.experimental.enableScreenshotTest"] = true - buildFeatures { buildConfig = true } @@ -38,6 +35,7 @@ android { testInstrumentationRunnerArguments["clearPackageData"] = "true" manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = kakaoKey + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"") } testOptions { @@ -181,18 +179,16 @@ android { } } -configurations.configureEach { - if (name == "debugScreenshotTestCompileClasspath") { - // Keep the renderer from loading firebase-datatransport R classes from two different versions. - exclude(group = "com.google.firebase", module = "firebase-datatransport") - } -} - dependencies { implementation(libs.coil.compose) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.core.splashscreen) + // App Startup — 기동 초기화는 app 매니페스트에 등록한 Initializer 로 실행한다. + implementation(libs.androidx.startup.runtime) + // DailyNotificationInitializer 가 WorkManagerInitializer 를 선행 의존으로 지정한다. + implementation(libs.androidx.work.runtime.ktx) + // 카카오 OAuth redirect Activity(`com.kakao.sdk.auth.AuthCodeHandlerActivity`)를 // app 매니페스트에서 직접 참조하므로 컴파일 classpath에 노출 필요. implementation(libs.kakao.sdk.auth) @@ -211,32 +207,25 @@ dependencies { implementation(projects.core.network) implementation(projects.core.ui) implementation(projects.core.model) - implementation(projects.core.startup) - implementation(projects.core.di) + implementation(projects.core.data) implementation(projects.core.domain) // Feature — presentation implementation(projects.feature.afternote.presentation) + implementation(projects.feature.home.presentation) implementation(projects.feature.mindrecord.presentation) + implementation(projects.feature.receiver.presentation) implementation(projects.feature.timeletter.presentation) implementation(projects.feature.onboarding.presentation) implementation(projects.feature.setting.presentation) - // Feature — domain (발신자·수신자 홈이 각 기능 Repository를 app 계층에서 조합) - implementation(projects.feature.mindrecord.domain) - implementation(projects.feature.afternote.domain) - implementation(projects.feature.receiver.domain) - implementation(projects.feature.timeletter.domain) - // Feature — data (Hilt @Module / 바인딩이 루트 그래프에 포함되도록 app이 classpath에 둔다) implementation(projects.feature.afternote.data) + implementation(projects.feature.receiver.data) implementation(projects.feature.mindrecord.data) implementation(projects.feature.timeletter.data) implementation(projects.feature.onboarding.data) - // HomeTabViewModel 경합 테스트 — 가상 시간으로 viewModelScope 요청 순서를 제어한다. - testImplementation(libs.coroutines.test) - // Managed-device androidTest — 실제 서버·OAuth 대신 Hilt fake를 주입하고 Compose semantics를 검증한다. androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) @@ -248,13 +237,10 @@ dependencies { androidTestImplementation(libs.coroutines.test) androidTestImplementation(projects.core.data) androidTestImplementation(projects.feature.afternote.domain) + androidTestImplementation(projects.feature.mindrecord.domain) androidTestImplementation(projects.feature.receiver.domain) androidTestImplementation(projects.feature.timeletter.domain) kspAndroidTest(libs.hilt.compiler) debugImplementation(libs.androidx.compose.ui.test.manifest) androidTestUtil(libs.androidx.test.orchestrator) - - // Compose Preview Screenshot Testing (#330) — 1hyok 영역 (홈) 적용 - screenshotTestImplementation(libs.screenshot.validation.api) - screenshotTestImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt index 8b5d711d0e..d97050715a 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt @@ -15,10 +15,10 @@ import androidx.paging.PagingData import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter +import com.afternote.afternote_fe.test.FakeUserRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload -import com.afternote.feature.afternote.domain.model.author.AuthorReceiverEntry import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload @@ -26,13 +26,10 @@ import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.model.author.ProcessingMethod import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository -import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.author.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.author.editor.SaveAfternoteMemorialMedia @@ -41,7 +38,6 @@ import com.afternote.feature.afternote.presentation.author.editor.model.EditorCa import com.afternote.feature.afternote.presentation.author.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteValidationError import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -173,28 +169,29 @@ class AfternoteAuthorAndroidTest { ): AfternoteEditorViewModel = AfternoteEditorViewModel( savedStateHandle = savedStateHandle, - authorReceiverRepository = FakeAuthorReceiverRepository(), + userRepository = FakeUserRepository(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { Result.success("https://cdn.test/thumb.jpg") }, resolveMemorialMediaForSave = ResolveMemorialMediaForSaveUseCase( - memorialVideoUploadRepository = - MemorialVideoUploadRepository { input -> + memorialMediaUploadRepository = + MemorialMediaUploadRepository { input, kind -> Result.success( when (input) { - MediaInput.None -> VideoUploadOutcome.Empty - is MediaInput.Local -> VideoUploadOutcome.FreshlyUploaded("https://cdn.test/video.mp4") - is MediaInput.Remote -> VideoUploadOutcome.Existing(input.url) - }, - ) - }, - memorialPhotoUploadRepository = - MemorialPhotoUploadRepository { input -> - Result.success( - when (input) { - MediaInput.None -> PhotoUploadOutcome.Empty - is MediaInput.Local -> PhotoUploadOutcome.FreshlyUploaded("https://cdn.test/photo.jpg") - is MediaInput.Remote -> PhotoUploadOutcome.Existing(input.url) + MediaInput.None -> { + null + } + + is MediaInput.Local -> { + when (kind) { + MediaKind.VIDEO -> "https://cdn.test/video.mp4" + MediaKind.PHOTO -> "https://cdn.test/photo.jpg" + } + } + + is MediaInput.Remote -> { + input.url + } }, ) }, @@ -203,22 +200,6 @@ class AfternoteAuthorAndroidTest { ) } -private class FakeAuthorReceiverRepository : AuthorReceiverRepository { - private val receivers = MutableStateFlow(listOf(AuthorReceiverEntry(7L, "김수신", "가족"))) - - override fun currentAuthorUserId(): Long = 1L - - override fun observeReceivers(): Flow> = receivers - - override fun currentReceivers(): List = receivers.value - - override suspend fun refreshReceivers(): Result = Result.success(Unit) - - override suspend fun clearReceivers() { - receivers.value = emptyList() - } -} - private class FakeAfternoteRepository : AfternoteRepository { val createSocialPayloads = mutableListOf() val createSocialResults = ArrayDeque>() diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index 147f4dcbb9..a920dfac69 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -46,7 +46,6 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload -import com.afternote.feature.afternote.domain.model.author.AuthorReceiverEntry import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload @@ -58,13 +57,10 @@ import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.model.author.ProcessingMethod import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository -import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.author.detail.AfternoteDetailDeleteResult @@ -83,7 +79,6 @@ import com.afternote.feature.afternote.presentation.author.home.AfternoteHomeEnt import com.afternote.feature.afternote.presentation.author.home.AfternoteHomeViewModel import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.junit.Assert.assertEquals @@ -93,6 +88,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.time.LocalDate import java.util.concurrent.atomic.AtomicInteger +import com.afternote.feature.afternote.presentation.R as AfternoteFeatureR @RunWith(AndroidJUnit4::class) class AfternoteAuthorExtendedAndroidTest { @@ -120,9 +116,9 @@ class AfternoteAuthorExtendedAndroidTest { Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow repository.listFlows[AfternoteType.SOCIAL_NETWORK] = flowOf(PagingData.empty()) val viewModel = AfternoteHomeViewModel(repository) - val accountRoutes = mutableListOf() - val galleryRoutes = mutableListOf() - val memorialRoutes = mutableListOf() + val accountRoutes = mutableListOf() + val galleryRoutes = mutableListOf() + val memorialRoutes = mutableListOf() val addRoutes = mutableListOf() composeRule.setContent { @@ -157,9 +153,9 @@ class AfternoteAuthorExtendedAndroidTest { .onNodeWithContentDescription("추억 노트") .performScrollTo() .performClick() - assertEquals(listOf("101"), accountRoutes) - assertEquals(listOf("102"), galleryRoutes) - assertEquals(listOf("103"), memorialRoutes) + assertEquals(listOf(101L), accountRoutes) + assertEquals(listOf(102L), galleryRoutes) + assertEquals(listOf(103L), memorialRoutes) composeRule.onNodeWithText("소셜네트워크").performClick() composeRule.waitUntil(timeoutMillis = 5_000) { @@ -413,7 +409,7 @@ private fun AuthorDetailForDelete( is AfternoteDetailDeleteResult.Failed -> { val message = resources.getString( - result.messageRes ?: R.string.afternote_detail_delete_failed, + result.messageRes ?: AfternoteFeatureR.string.afternote_detail_delete_failed, ) scope.launch { snackbarHostState.showSnackbar(message) } viewModel.onDeleteResultConsumed() @@ -518,28 +514,12 @@ private class AdvancedAfternoteRepository( } } -private class ExtendedAuthorReceiverRepository : AuthorReceiverRepository { - private val receivers = MutableStateFlow(listOf(AuthorReceiverEntry(7L, "김수신", "가족"))) - - override fun currentAuthorUserId(): Long = 1L - - override fun observeReceivers(): Flow> = receivers - - override fun currentReceivers(): List = receivers.value - - override suspend fun refreshReceivers(): Result = Result.success(Unit) - - override suspend fun clearReceivers() { - receivers.value = emptyList() - } -} - private fun detailViewModel( repository: AdvancedAfternoteRepository, itemId: Long, ): AfternoteDetailViewModel = AfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId.toString())), + savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId)), afternoteRepository = repository, userRepository = FakeUserRepository(), errorReporter = FakeErrorReporter(), @@ -550,8 +530,8 @@ private fun editorViewModel( itemId: Long, ): AfternoteEditorViewModel = AfternoteEditorViewModel( - savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId.toString())), - authorReceiverRepository = ExtendedAuthorReceiverRepository(), + savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId)), + userRepository = FakeUserRepository(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { @@ -559,38 +539,23 @@ private fun editorViewModel( }, resolveMemorialMediaForSave = ResolveMemorialMediaForSaveUseCase( - memorialVideoUploadRepository = - MemorialVideoUploadRepository { input -> - Result.success( - when (input) { - MediaInput.None -> { - VideoUploadOutcome.Empty - } - - is MediaInput.Local -> { - VideoUploadOutcome.FreshlyUploaded("https://cdn.test/video.mp4") - } - - is MediaInput.Remote -> { - VideoUploadOutcome.Existing(input.url) - } - }, - ) - }, - memorialPhotoUploadRepository = - MemorialPhotoUploadRepository { input -> + memorialMediaUploadRepository = + MemorialMediaUploadRepository { input, kind -> Result.success( when (input) { MediaInput.None -> { - PhotoUploadOutcome.Empty + null } is MediaInput.Local -> { - PhotoUploadOutcome.FreshlyUploaded("https://cdn.test/photo.jpg") + when (kind) { + MediaKind.VIDEO -> "https://cdn.test/video.mp4" + MediaKind.PHOTO -> "https://cdn.test/photo.jpg" + } } is MediaInput.Remote -> { - PhotoUploadOutcome.Existing(input.url) + input.url } }, ) @@ -602,19 +567,19 @@ private fun editorViewModel( private fun authorListItems(): List = listOf( ListItem( - id = "101", + id = 101L, serviceName = "Instagram", date = "2026.08.22", type = AfternoteType.SOCIAL_NETWORK, ), ListItem( - id = "102", + id = 102L, serviceName = "Google Drive", date = "2026.08.22", type = AfternoteType.GALLERY_AND_FILES, ), ListItem( - id = "103", + id = 103L, serviceName = "추억 노트", date = "2026.08.22", type = AfternoteType.MEMORIAL, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt index 4e4e17d051..3c6b817cf2 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt @@ -12,12 +12,12 @@ import androidx.paging.PagingData import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter +import com.afternote.afternote_fe.test.FakeUserRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.domain.model.author.AfternoteAccountCredentials import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload -import com.afternote.feature.afternote.domain.model.author.AuthorReceiverEntry import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload @@ -28,13 +28,10 @@ import com.afternote.feature.afternote.domain.model.author.MemorialVideoPayload import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload import com.afternote.feature.afternote.domain.model.author.ProcessingMethod import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository -import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.author.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.author.editor.SaveAfternoteMemorialMedia @@ -43,7 +40,6 @@ import com.afternote.feature.afternote.presentation.author.editor.message.Editor import com.afternote.feature.afternote.presentation.author.editor.model.EditorCategory import com.afternote.feature.afternote.presentation.author.editor.model.RegisterAfternotePayload import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Assert.assertEquals import org.junit.Rule @@ -312,22 +308,6 @@ private class ImplementedCoverageAfternoteRepository : AfternoteRepository { override suspend fun delete(id: Long): Result = error("unexpected delete") } -private class ImplementedCoverageAuthorReceiverRepository : AuthorReceiverRepository { - private val receivers = MutableStateFlow(listOf(AuthorReceiverEntry(7L, "김수신", "가족"))) - - override fun currentAuthorUserId(): Long = 1L - - override fun observeReceivers(): Flow> = receivers - - override fun currentReceivers(): List = receivers.value - - override suspend fun refreshReceivers(): Result = Result.success(Unit) - - override suspend fun clearReceivers() { - receivers.value = emptyList() - } -} - private fun implementedCoverageViewModel( repository: ImplementedCoverageAfternoteRepository, savedStateHandle: SavedStateHandle = SavedStateHandle(), @@ -336,7 +316,7 @@ private fun implementedCoverageViewModel( ): AfternoteEditorViewModel = AfternoteEditorViewModel( savedStateHandle = savedStateHandle, - authorReceiverRepository = ImplementedCoverageAuthorReceiverRepository(), + userRepository = FakeUserRepository(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { @@ -344,40 +324,27 @@ private fun implementedCoverageViewModel( }, resolveMemorialMediaForSave = ResolveMemorialMediaForSaveUseCase( - memorialVideoUploadRepository = - MemorialVideoUploadRepository { input -> - videoInputs += input - Result.success( - when (input) { - MediaInput.None -> { - VideoUploadOutcome.Empty - } - - is MediaInput.Local -> { - VideoUploadOutcome.FreshlyUploaded("https://cdn.test/uploaded-video.mp4") - } - - is MediaInput.Remote -> { - VideoUploadOutcome.Existing(input.url) - } - }, - ) - }, - memorialPhotoUploadRepository = - MemorialPhotoUploadRepository { input -> - photoInputs += input + memorialMediaUploadRepository = + MemorialMediaUploadRepository { input, kind -> + when (kind) { + MediaKind.VIDEO -> videoInputs += input + MediaKind.PHOTO -> photoInputs += input + } Result.success( when (input) { MediaInput.None -> { - PhotoUploadOutcome.Empty + null } is MediaInput.Local -> { - PhotoUploadOutcome.FreshlyUploaded("https://cdn.test/uploaded-photo.jpg") + when (kind) { + MediaKind.VIDEO -> "https://cdn.test/uploaded-video.mp4" + MediaKind.PHOTO -> "https://cdn.test/uploaded-photo.jpg" + } } is MediaInput.Remote -> { - PhotoUploadOutcome.Existing(input.url) + input.url } }, ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 975fb75c55..3435509b55 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -33,12 +33,6 @@ import com.afternote.afternote_fe.navigation.AppState import com.afternote.afternote_fe.navigation.rememberAfternoteAppState import com.afternote.afternote_fe.navigation.rememberHomeTabActions import com.afternote.afternote_fe.navigation.rememberReceiverNavActions -import com.afternote.afternote_fe.screen.HomeTabActions -import com.afternote.afternote_fe.screen.receiver.ReceiverHomeActions -import com.afternote.afternote_fe.screen.receiver.ReceiverHomeEvent -import com.afternote.afternote_fe.screen.receiver.ReceiverHomeScreen -import com.afternote.afternote_fe.screen.receiver.ReceiverHomeViewModel -import com.afternote.afternote_fe.screen.receiver.model.ReceiverHomeUiState import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeAuthRepository import com.afternote.afternote_fe.test.FakeErrorReporter @@ -51,27 +45,19 @@ import com.afternote.core.model.Session import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult -import com.afternote.feature.afternote.domain.model.receiver.LoadCountResult -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle -import com.afternote.feature.afternote.domain.model.receiver.ReceivedPlaylistDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedPlaylistSong -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentSlot -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentUploadScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentUploadViewModel -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.IdentityVerificationEmailScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.IdentityVerificationViewModel -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceiverNavActions -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute +import com.afternote.feature.home.presentation.HomeTabActions +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle +import com.afternote.feature.receiver.domain.model.ReceivedPlaylistDetail +import com.afternote.feature.receiver.domain.model.ReceivedPlaylistSong import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult import com.afternote.feature.receiver.domain.model.ReceiverIdentity @@ -79,6 +65,24 @@ import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import com.afternote.feature.receiver.domain.repository.ReceiverDeliveryDocumentUploadRepository +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentSlot +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadScreen +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadViewModel +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationEmailScreen +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationViewModel +import com.afternote.feature.receiver.presentation.detail.ReceivedAfternoteDetailRoute +import com.afternote.feature.receiver.presentation.detail.ReceivedAfternoteDetailViewModel +import com.afternote.feature.receiver.presentation.home.ReceiverHomeActions +import com.afternote.feature.receiver.presentation.home.ReceiverHomeEvent +import com.afternote.feature.receiver.presentation.home.ReceiverHomeScreen +import com.afternote.feature.receiver.presentation.home.ReceiverHomeViewModel +import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState +import com.afternote.feature.receiver.presentation.navigation.ReceiverNavActions +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList +import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.CompletableDeferred @@ -96,7 +100,9 @@ import org.junit.runner.RunWith import javax.inject.Inject import com.afternote.core.ui.R as CoreUiR import com.afternote.feature.afternote.presentation.R as AfternoteFeatureR +import com.afternote.feature.home.presentation.R as HomeR import com.afternote.feature.onboarding.presentation.R as OnboardingR +import com.afternote.feature.receiver.presentation.R as ReceiverR @HiltAndroidTest @RunWith(AndroidJUnit4::class) @@ -164,7 +170,7 @@ class AppAndReceiverCompletionAndroidTest { .performTextReplacement("correct-password") composeRule.onNode(loginButton).performClick() - val greeting = context.getString(R.string.home_tab_greeting, "테스트 사용자") + val greeting = context.getString(HomeR.string.home_tab_greeting, "테스트 사용자") composeRule.waitUntilAtLeastOneExists(hasText(greeting), timeoutMillis = 10_000) composeRule.onNodeWithText(greeting).assertIsDisplayed() @@ -228,10 +234,27 @@ class ReceiverRuntimeCompletionAndroidTest { @Test fun receiverHome_allFailureThenRetryPartialSuccess_keepsAvailableSectionsAndReportsBothStages() { val repository = CompletionReceiverRepository() - val allFailureAttempt = repository.enqueueHomeAttempt() - val partialAttempt = repository.enqueueHomeAttempt() + val mindRecordRepository = CompletionMindRecordReceiverRepository() + val timeLetterRepository = CompletionReceiverTimeLetterRepository() + + fun homeCallCounts(): List = + listOf( + repository.afterNotesCalls, + mindRecordRepository.getAllCalls, + timeLetterRepository.listCalls, + repository.senderMessageCalls, + ) + + val allFailureAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository) + val partialAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository) val reporter = FakeErrorReporter() - val viewModel = ReceiverHomeViewModel(repository, reporter) + val viewModel = + ReceiverHomeViewModel( + receiverRepository = repository, + mindRecordReceiverRepository = mindRecordRepository, + receiverTimeLetterRepository = timeLetterRepository, + errorReporter = reporter, + ) composeRule.setContent { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value @@ -244,7 +267,7 @@ class ReceiverRuntimeCompletionAndroidTest { } } - composeRule.waitUntil(timeoutMillis = 5_000) { repository.homeCallCounts.all { it == 1 } } + composeRule.waitUntil(timeoutMillis = 5_000) { homeCallCounts().all { it == 1 } } composeRule.runOnIdle { assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value) } @@ -257,13 +280,13 @@ class ReceiverRuntimeCompletionAndroidTest { senderMessage = Result.failure(offline), ) composeRule - .onNodeWithText(context.getString(R.string.home_tab_error_message)) + .onNodeWithText(context.getString(ReceiverR.string.receiver_home_error_message)) .assertIsDisplayed() composeRule - .onNodeWithText(context.getString(R.string.home_tab_retry)) + .onNodeWithText(context.getString(ReceiverR.string.receiver_home_retry)) .performClick() - composeRule.waitUntil(timeoutMillis = 5_000) { repository.homeCallCounts.all { it == 2 } } + composeRule.waitUntil(timeoutMillis = 5_000) { homeCallCounts().all { it == 2 } } composeRule.runOnIdle { assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value) } @@ -281,7 +304,7 @@ class ReceiverRuntimeCompletionAndroidTest { ), ), mindRecords = Result.failure(IllegalStateException("mind records unavailable")), - timeLetters = Result.success(LoadCountResult(totalCount = 8)), + timeLetters = Result.success(ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 8)), senderMessage = Result.success( SenderMessageInfo( @@ -293,11 +316,16 @@ class ReceiverRuntimeCompletionAndroidTest { ) composeRule - .onNodeWithText(context.getString(R.string.receiver_home_sender_record_title, "이발신")) + .onNodeWithText(context.getString(ReceiverR.string.receiver_home_sender_record_title, "이발신")) .assertIsDisplayed() composeRule.onNodeWithText("언제나 응원할게").assertIsDisplayed() composeRule - .onNodeWithText("0개 마음의 기록이 있습니다.") + .onNodeWithText(context.getString(ReceiverR.string.receiver_home_section_count_unavailable)) + .performScrollTo() + .assertIsDisplayed() + composeRule + .onNodeWithText("8개 라이프 이벤트 레터가 있습니다.") + .performScrollTo() .assertIsDisplayed() composeRule .onNodeWithText("2개의 애프터노트가 있습니다.") @@ -308,7 +336,7 @@ class ReceiverRuntimeCompletionAndroidTest { assertEquals("receiver_home_load", reporter.failures[0].second["home_stage"]) assertEquals("receiver_home_partial_load", reporter.failures[1].second["home_stage"]) assertEquals("mind_records", reporter.failures[1].second["home_failed_sources"]) - assertEquals(listOf(2, 2, 2, 2), repository.homeCallCounts) + assertEquals(listOf(2, 2, 2, 2), homeCallCounts()) } @Test @@ -622,14 +650,14 @@ class ReceiverRuntimeCompletionAndroidTest { ) val viewModel = ReceivedAfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to "202")), + savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 202L)), receiverRepository = repository, errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute(onBack = {}, viewModel = viewModel) + ReceivedAfternoteDetailRoute(onBack = {}, onNavigateToPlaylist = {}, viewModel = viewModel) } } @@ -664,11 +692,11 @@ class ReceiverRuntimeCompletionAndroidTest { ) val viewModel = ReceivedAfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to "303")), + savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 303L)), receiverRepository = repository, errorReporter = FakeErrorReporter(), ) - val playlistRoutes = mutableListOf() + val playlistRoutes = mutableListOf() composeRule.setContent { AfternoteTheme { @@ -689,21 +717,21 @@ class ReceiverRuntimeCompletionAndroidTest { .onNode(hasText("추억 플레이리스트") and hasClickAction()) .performClick() - assertEquals(listOf("303"), playlistRoutes) + assertEquals(listOf(303L), playlistRoutes) assertEquals(listOf(303L), repository.detailIds) } } private data class PendingHomeAttempt( val afterNotes: CompletableDeferred>, - val mindRecords: CompletableDeferred>, - val timeLetters: CompletableDeferred>, + val mindRecords: CompletableDeferred>, + val timeLetters: CompletableDeferred>, val senderMessage: CompletableDeferred>, ) { fun complete( afterNotes: Result, - mindRecords: Result, - timeLetters: Result, + mindRecords: Result, + timeLetters: Result, senderMessage: Result, ) { this.afterNotes.complete(afterNotes) @@ -713,33 +741,39 @@ private data class PendingHomeAttempt( } } +/** 홈 한 번의 로드가 물리는 세 리포지토리 대기열에 결과 게이트를 한 벌씩 건다. */ +private fun enqueueHomeAttempt( + receiverRepository: CompletionReceiverRepository, + mindRecordRepository: CompletionMindRecordReceiverRepository, + timeLetterRepository: CompletionReceiverTimeLetterRepository, +): PendingHomeAttempt { + val attempt = + PendingHomeAttempt( + afterNotes = CompletableDeferred(), + mindRecords = CompletableDeferred(), + timeLetters = CompletableDeferred(), + senderMessage = CompletableDeferred(), + ) + receiverRepository.afterNoteHomeResults.addLast(attempt.afterNotes) + mindRecordRepository.homeResults.addLast(attempt.mindRecords) + timeLetterRepository.homeResults.addLast(attempt.timeLetters) + receiverRepository.senderMessageHomeResults.addLast(attempt.senderMessage) + return attempt +} + private class CompletionReceiverRepository : ReceiverRepository { private val authCode = MutableStateFlow(null) override val authCodeFlow: Flow = authCode - private val afterNoteHomeResults = ArrayDeque>>() - private val mindRecordHomeResults = ArrayDeque>>() - private val timeLetterHomeResults = ArrayDeque>>() - private val senderMessageHomeResults = ArrayDeque>>() + val afterNoteHomeResults = ArrayDeque>>() + val senderMessageHomeResults = ArrayDeque>>() val detailResults = ArrayDeque>() val detailIds = mutableListOf() - val homeCallCounts = mutableListOf(0, 0, 0, 0) - - fun enqueueHomeAttempt(): PendingHomeAttempt { - val attempt = - PendingHomeAttempt( - afterNotes = CompletableDeferred(), - mindRecords = CompletableDeferred(), - timeLetters = CompletableDeferred(), - senderMessage = CompletableDeferred(), - ) - afterNoteHomeResults.addLast(attempt.afterNotes) - mindRecordHomeResults.addLast(attempt.mindRecords) - timeLetterHomeResults.addLast(attempt.timeLetters) - senderMessageHomeResults.addLast(attempt.senderMessage) - return attempt - } + var afterNotesCalls = 0 + private set + var senderMessageCalls = 0 + private set override suspend fun currentAuthCode(): String? = authCode.value @@ -747,14 +781,10 @@ private class CompletionReceiverRepository : ReceiverRepository { authCode.value = code } - override suspend fun clearAuthCode() { - authCode.value = null - } - override fun getPagedReceivedAfternotes(): Flow> = flowOf(PagingData.empty()) override suspend fun getReceivedAfterNotes(): Result { - homeCallCounts[0] += 1 + afterNotesCalls += 1 return afterNoteHomeResults.removeFirst().await() } @@ -771,19 +801,36 @@ private class CompletionReceiverRepository : ReceiverRepository { error("unexpected saveReceivedExportToFile") } - override suspend fun loadMindRecordsCount(): Result { - homeCallCounts[1] += 1 - return mindRecordHomeResults.removeFirst().await() + override suspend fun loadSenderMessage(): Result { + senderMessageCalls += 1 + return senderMessageHomeResults.removeFirst().await() + } +} + +private class CompletionMindRecordReceiverRepository : MindRecordReceiverRepository { + val homeResults = ArrayDeque>>() + var getAllCalls = 0 + private set + + override suspend fun getAll(): Result { + getAllCalls += 1 + return homeResults.removeFirst().await() } +} - override suspend fun loadTimeLettersCount(): Result { - homeCallCounts[2] += 1 - return timeLetterHomeResults.removeFirst().await() +private class CompletionReceiverTimeLetterRepository : ReceiverTimeLetterRepository { + val homeResults = ArrayDeque>>() + var listCalls = 0 + private set + + // 실패는 throw 로 전달 — 인터페이스가 Result 대신 예외 계약이라 ViewModel 쪽 runCatching 이 받는다. + override suspend fun getReceivedTimeLetters(): ReceivedTimeLetterList { + listCalls += 1 + return homeResults.removeFirst().await().getOrThrow() } - override suspend fun loadSenderMessage(): Result { - homeCallCounts[3] += 1 - return senderMessageHomeResults.removeFirst().await() + override suspend fun getReceivedTimeLetterDetail(timeLetterReceiverId: Long): ReceivedTimeLetter { + error("unexpected getReceivedTimeLetterDetail") } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt index b79eb0cc4b..fe413cb52e 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt @@ -31,6 +31,7 @@ import org.junit.runner.RunWith import java.io.IOException import javax.inject.Inject import com.afternote.core.ui.R as CoreUiR +import com.afternote.feature.home.presentation.R as HomeR import com.afternote.feature.onboarding.presentation.R as OnboardingR @HiltAndroidTest @@ -100,7 +101,7 @@ class AppOnboardingCanaryTest { .onNodeWithText(context.getString(CoreUiR.string.core_ui_network_error_retry)) .performClick() - val greeting = context.getString(R.string.home_tab_greeting, "테스트 사용자") + val greeting = context.getString(HomeR.string.home_tab_greeting, "테스트 사용자") composeRule.waitUntilAtLeastOneExists(hasText(greeting), timeoutMillis = 10_000) composeRule.onNodeWithText(greeting).assertIsDisplayed() listOf( diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt index 3bb3cad40c..97d075dba4 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.waitUntilAtLeastOneExists import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.FakeDailyQuestionRepository import com.afternote.afternote_fe.test.FakeDiaryRepository import com.afternote.afternote_fe.test.FakeUserRepository import com.afternote.core.domain.repository.PhotoUploadRepository @@ -22,6 +23,7 @@ import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel +import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.SubmitState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -102,15 +104,14 @@ class MindRecordFlowAndroidTest { @Test fun dailyQuestion_failureThenRetry_keepsAnswerAndAvoidsDuplicateSuccess() { - val repository = - com.afternote.afternote_fe.test - .FakeDailyQuestionRepository() + val repository = FakeDailyQuestionRepository() repository.createResults.addLast(Result.failure(IllegalStateException("offline"))) - repository.createResults.addLast(Result.success(Unit)) + repository.createResults.addLast(Result.success(1L)) val viewModel = DailyQuestionWriteViewModel( repository = repository, photoUploadRepository = PhotoUploadRepository { _, _ -> Result.success("https://cdn.test/question.jpg") }, + draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), ) composeRule.setContent { AfternoteTheme {} } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isQuestionLoading } @@ -150,5 +151,6 @@ class MindRecordFlowAndroidTest { repository = repository, photoUploadRepository = PhotoUploadRepository { _, _ -> Result.success("https://cdn.test/image.jpg") }, userRepository = FakeUserRepository(), + draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), ) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt index 716a82fbbc..777f6c7457 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt @@ -44,6 +44,7 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel +import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel @@ -149,7 +150,12 @@ class MindRecordLifecycleAndroidTest { ), ), ) - val viewModel = DraftListViewModel(diaryRepository, dailyQuestionRepository) + val viewModel = + DraftListViewModel( + loader = MindRecordDraftLoader(diaryRepository, dailyQuestionRepository), + diaryRepository = diaryRepository, + dailyQuestionRepository = dailyQuestionRepository, + ) composeRule.setContent { AfternoteTheme { @@ -174,7 +180,7 @@ class MindRecordLifecycleAndroidTest { assertTrue(diaryRepository.deleteCalls.isEmpty()) composeRule.waitUntil(timeoutMillis = TIMEOUT) { - (viewModel.uiState.value as? DraftListUiState.Success)?.deleteCompleted == false + (viewModel.uiState.value as? DraftListUiState.Success)?.deleteOutcome == null } composeRule.onNodeWithText("선택").performClick() composeRule.onNodeWithText("전체 삭제").performClick() @@ -216,12 +222,14 @@ class MindRecordLifecycleAndroidTest { diaryId = 301L, day = wednesday.dayOfMonth, isDiary = true, + countsAsRecord = true, emotion = TodayMood.HAPPY, ), WeeklyReportDay( diaryId = 302L, day = wednesday.dayOfMonth, isDiary = false, + countsAsRecord = true, emotion = null, ), ), @@ -240,7 +248,7 @@ class MindRecordLifecycleAndroidTest { val success = viewModel.uiState.value as WeeklyReportUiState.Success assertEquals(7, success.weekDays.size) assertEquals(DayOfWeek.entries.toList(), success.weekDays.map { it.dayOfWeek }) - assertEquals(DayContent.EmojiWithDot("😊"), success.weekDays[2].content) + assertEquals(DayContent.EmojiOnly("😊"), success.weekDays[2].content) assertEquals(DayBackground.Green, success.weekDays[2].background) assertEquals(2, success.recordedDays) assertEquals(emptyList(), success.emotionKeywords) @@ -379,12 +387,12 @@ private class PrivateDailyQuestionRepository( override suspend fun getToday(): Result = error("Unexpected today question load") - override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("Unexpected question create") + override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("Unexpected question create") override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = error("Unexpected question update") + ): Result = error("Unexpected question update") override suspend fun delete(id: Long): Result { deleteCalls += id diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt index 7a90d4544d..47d73704af 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt @@ -26,32 +26,31 @@ import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult -import com.afternote.feature.afternote.domain.model.receiver.LoadCountResult -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAccountCredentials -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.MasterKeyScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.MasterKeyViewModel -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.recordsbox.ReceivedRecordsScreen -import com.afternote.feature.afternote.presentation.receiver.recordsbox.ReceivedRecordsViewModel -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistrationScreen -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistrationViewModel -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistry -import com.afternote.feature.afternote.presentation.receiver.summary.ReceiverAfterNoteScreen -import com.afternote.feature.afternote.presentation.receiver.summary.ReceiverDownloadAllEvent -import com.afternote.feature.afternote.presentation.receiver.summary.ReceiverDownloadAllViewModel +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.DeliveryVerification +import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyScreen +import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyViewModel +import com.afternote.feature.receiver.presentation.detail.ReceivedAfternoteDetailRoute +import com.afternote.feature.receiver.presentation.detail.ReceivedAfternoteDetailViewModel +import com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsScreen +import com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsViewModel +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistrationScreen +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistrationViewModel +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry +import com.afternote.feature.receiver.presentation.summary.ReceiverAfterNoteScreen +import com.afternote.feature.receiver.presentation.summary.ReceiverDownloadAllEvent +import com.afternote.feature.receiver.presentation.summary.ReceiverDownloadAllViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -62,6 +61,7 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import com.afternote.feature.afternote.presentation.R as AfternoteFeatureR @RunWith(AndroidJUnit4::class) class ReceiverAdvancedAndroidTest { @@ -158,15 +158,17 @@ class ReceiverAdvancedAndroidTest { .assertIsDisplayed() val nextButton = hasText("다음") and hasClickAction() composeRule.onNode(nextButton).assertIsNotEnabled() - composeRule.onNode(hasSetTextAction()).performTextInput(" MASTER-KEY-73 ") + // 마스터 키는 UUID 형식만 통과하고 소문자로 정규화된다(#887) — 공백·대문자 입력으로 함께 검증. + composeRule.onNode(hasSetTextAction()).performTextInput(" 3F2504E0-4F89-11D3-9A0C-0305E82C3301 ") composeRule.onNode(nextButton).assertIsEnabled().performClick() composeRule.waitUntil(timeoutMillis = 5_000) { verifiedTransitions == 1 } + val normalizedMasterKey = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" val attached = checkNotNull(senderRegistry.findById(sender.id)) - assertEquals(listOf("MASTER-KEY-73"), authRepository.verifiedMasterKeys) - assertEquals(listOf("MASTER-KEY-73"), receiverRepository.savedAuthCodes) - assertEquals("MASTER-KEY-73", receiverRepository.authCode.value) - assertEquals("MASTER-KEY-73", attached.authCode) + assertEquals(listOf(normalizedMasterKey), authRepository.verifiedMasterKeys) + assertEquals(listOf(normalizedMasterKey), receiverRepository.savedAuthCodes) + assertEquals(normalizedMasterKey, receiverRepository.authCode.value) + assertEquals(normalizedMasterKey, attached.authCode) assertEquals("이발신", attached.realSenderName) assertEquals("가족", attached.relation) assertEquals(1, verifiedTransitions) @@ -179,7 +181,7 @@ class ReceiverAdvancedAndroidTest { repository.detailResults.addLast(Result.success(receivedSocialDetail())) val viewModel = ReceivedAfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to "91")), + savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 91L)), receiverRepository = repository, errorReporter = FakeErrorReporter(), ) @@ -188,6 +190,7 @@ class ReceiverAdvancedAndroidTest { AfternoteTheme { ReceivedAfternoteDetailRoute( onBack = {}, + onNavigateToPlaylist = {}, viewModel = viewModel, ) } @@ -234,14 +237,14 @@ class ReceiverAdvancedAndroidTest { confirmDownload() composeRule.waitUntil(timeoutMillis = 5_000) { - viewModel.uiState.value.errorMessageRes == R.string.receiver_download_all_failed + viewModel.uiState.value.errorMessageRes == AfternoteFeatureR.string.receiver_download_all_failed } assertEquals(1, repository.downloadCalls) assertEquals(0, repository.savedBundles.size) confirmDownload() composeRule.waitUntil(timeoutMillis = 5_000) { - viewModel.uiState.value.errorMessageRes == R.string.receiver_download_all_save_failed + viewModel.uiState.value.errorMessageRes == AfternoteFeatureR.string.receiver_download_all_save_failed } assertEquals(2, repository.downloadCalls) assertEquals(listOf(saveFailureBundle), repository.savedBundles) @@ -295,10 +298,6 @@ private class AdvancedReceiverRepository : ReceiverRepository { authCode.value = code } - override suspend fun clearAuthCode() { - authCode.value = null - } - override fun getPagedReceivedAfternotes(): Flow> = flowOf(PagingData.empty()) override suspend fun getReceivedAfterNotes(): Result = @@ -319,10 +318,6 @@ private class AdvancedReceiverRepository : ReceiverRepository { return saveResults.removeFirst() } - override suspend fun loadMindRecordsCount(): Result = Result.success(LoadCountResult(totalCount = 0)) - - override suspend fun loadTimeLettersCount(): Result = Result.success(LoadCountResult(totalCount = 0)) - override suspend fun loadSenderMessage(): Result = Result.success(null) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt index 2f51f09630..faec6f0eef 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt @@ -12,11 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentSlot -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentUploadViewModel -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.IdentityVerificationEmailScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.IdentityVerificationViewModel +import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl @@ -26,6 +22,10 @@ import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import com.afternote.feature.receiver.domain.repository.ReceiverDeliveryDocumentUploadRepository +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentSlot +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadViewModel +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationEmailScreen +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingAccountSecurityAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingAccountSecurityAndroidTest.kt index 283c2ba685..00bf6b3fcb 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingAccountSecurityAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingAccountSecurityAndroidTest.kt @@ -46,6 +46,7 @@ import com.afternote.feature.setting.presentation.viewmodel.ProfileEditViewModel import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel import com.afternote.feature.setting.presentation.viewmodel.SettingUiState import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel +import com.afternote.feature.setting.presentation.viewmodel.WithdrawUiState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -341,15 +342,16 @@ class SettingAccountSecurityAndroidTest { composeRule.onNodeWithText("탈퇴하겠습니다").performTextInput("탈퇴하겠습니다") composeRule.onNodeWithText("탈퇴하기").performClick() - composeRule.onNodeWithText("회원 탈퇴가 완료되었습니다.\n애프터노트를 이용해 주셔서 감사합니다.").assertIsDisplayed() - assertEquals(0, userRepository.deleteAccountCalls) - - composeRule.onNodeWithText("확인하기").performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { userRepository.deleteAccountCalls == 1 } - assertFalse(viewModel.withdrawCompleted.value) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + viewModel.withdrawUiState.value == WithdrawUiState.Error + } + composeRule + .onNodeWithText("회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.") + .assertIsDisplayed() assertEquals(0, successCalls) assertEquals(0, authRepository.clearSessionCalls) assertTrue(runBlocking { authRepository.isLoggedIn.first() }) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingCompletionAndroidTest.kt index f2a804caa7..db967a1b8d 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingCompletionAndroidTest.kt @@ -56,6 +56,7 @@ import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterEven import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel import com.afternote.feature.setting.presentation.viewmodel.SettingUiState import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel +import com.afternote.feature.setting.presentation.viewmodel.WithdrawUiState import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -514,9 +515,9 @@ class SettingCompletionAndroidTest { composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { userApi.completedDeleteCalls == 1 } - composeRule.waitForIdle() - - assertFalse(viewModel.withdrawCompleted.value) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + viewModel.withdrawUiState.value == WithdrawUiState.Error + } assertEquals(0, authRepository.clearSessionCalls) assertTrue(runBlocking { authRepository.isLoggedIn.first() }) @@ -528,7 +529,7 @@ class SettingCompletionAndroidTest { retryGate.complete(BaseResponse(status = 200, code = 200)) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.withdrawCompleted.value + viewModel.withdrawUiState.value == WithdrawUiState.Success } assertEquals(2, userApi.completedDeleteCalls) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index d48fda550a..3fde0c2765 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -61,7 +61,9 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionList import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel +import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceUiState import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceViewModel +import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.SubmitState import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportUiState import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel @@ -414,6 +416,7 @@ class TimeLetterMindRecordCompletionAndroidTest { DailyQuestionWriteViewModel( repository = repository, photoUploadRepository = CompletionPhotoUploadRepository, + draftLoader = MindRecordDraftLoader(CompletionDiaryRepository(mutableListOf()), repository), ) } composeRule.waitUntil(timeoutMillis = TIMEOUT) { @@ -458,10 +461,12 @@ class TimeLetterMindRecordCompletionAndroidTest { ), ), ) + val draftDailyQuestionRepository = CompletionDailyQuestionRepository() val draftListViewModel = DraftListViewModel( + loader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), diaryRepository = repository, - dailyQuestionRepository = CompletionDailyQuestionRepository(), + dailyQuestionRepository = draftDailyQuestionRepository, ) var routedArguments: Pair? = null var writeViewModel by mutableStateOf(null) @@ -487,6 +492,8 @@ class TimeLetterMindRecordCompletionAndroidTest { repository = repository, photoUploadRepository = CompletionPhotoUploadRepository, userRepository = FakeUserRepository(), + draftLoader = + MindRecordDraftLoader(repository, draftDailyQuestionRepository), ) }, ) @@ -534,7 +541,24 @@ class TimeLetterMindRecordCompletionAndroidTest { @Test fun memorySpace_supportedSuccess_opensAndClosesDetailThenNavigatesBack() { - val viewModel = MemorySpaceViewModel() + val memoryDate = LocalDate.now() + val diaryRepository = + CompletionDiaryRepository( + drafts = + mutableListOf( + Diary( + diaryId = 501L, + title = "추억이 된 하루", + content = "이 순간은 나에게 특별한 의미가 있었습니다.", + date = memoryDate.toString(), + createdAt = memoryDate.toString(), + todayMood = TodayMood.HAPPY, + imageUrl = "https://afternote.test/memory.jpg", + isDraft = false, + ), + ), + ) + val viewModel = MemorySpaceViewModel(diaryRepository, CompletionDailyQuestionRepository()) var backCalls = 0 composeRule.setContent { @@ -547,11 +571,15 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("MEMORY SPACE").assertIsDisplayed() - composeRule.onNodeWithContentDescription("기억 1").performClick() + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + viewModel.uiState.value is MemorySpaceUiState.Success + } + composeRule.onNodeWithContentDescription("추억이 된 하루").performClick() composeRule .onNodeWithText("이 순간은 나에게 특별한 의미가 있었습니다.", substring = true) .assertIsDisplayed() - composeRule.onNodeWithText("#평온").assertIsDisplayed() + // 태그는 사용자가 고른 오늘의 기분 이모지다 — 종전 더미의 `#평온` 은 출처가 없었다 (#559). + composeRule.onNodeWithText("#😊").assertIsDisplayed() composeRule.onNodeWithContentDescription("닫기").performClick() composeRule .onNodeWithText("이 순간은 나에게 특별한 의미가 있었습니다.", substring = true) @@ -763,11 +791,12 @@ private class CompletionDailyQuestionRepository( return Result.success(today) } - override suspend fun create(payload: DailyQuestionCreatePayload): Result { + override suspend fun create(payload: DailyQuestionCreatePayload): Result { createCalls += payload + val createdId = 800L + createCalls.size storedAnswers += DailyQuestion( - dailyQuestionId = 800L + createCalls.size, + dailyQuestionId = createdId, title = today.content, content = payload.content, createdAt = "2026-08-22", @@ -775,13 +804,13 @@ private class CompletionDailyQuestionRepository( isDraft = payload.isDraft, ) today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft) - return Result.success(Unit) + return Result.success(createdId) } override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result { + ): Result { updateCalls += id to payload storedAnswers.replaceAll { answer -> if (answer.dailyQuestionId == id) { @@ -794,7 +823,7 @@ private class CompletionDailyQuestionRepository( answer } } - return Result.success(Unit) + return Result.success(id) } override suspend fun delete(id: Long): Result { @@ -931,6 +960,7 @@ private fun completeWeeklyReport(monday: LocalDate): WeeklyReport = diaryId = 91L, day = monday.dayOfMonth, isDiary = true, + countsAsRecord = true, emotion = TodayMood.HAPPY, ), ), diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index 43f4753af8..ba27408d8d 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -227,11 +227,6 @@ class FakeUserProfileRepository : UserProfileRepository { override suspend fun saveUserName(name: String) { cachedUserName = name } - - override suspend fun clear() { - cachedUserName = null - passkeyRegistered.value = false - } } class FakeDiaryRepository( @@ -267,7 +262,7 @@ class FakeDailyQuestionRepository( Result.success(TodayDailyQuestion(1L, 1, "오늘의 질문", false)), ) : DailyQuestionRepository { val createdPayloads = mutableListOf() - val createResults = ArrayDeque>() + val createResults = ArrayDeque>() override suspend fun getList( date: String?, @@ -276,17 +271,22 @@ class FakeDailyQuestionRepository( override suspend fun getToday(): Result = todayResult - override suspend fun create(payload: DailyQuestionCreatePayload): Result { + override suspend fun create(payload: DailyQuestionCreatePayload): Result { createdPayloads += payload - return createResults.removeFirstOrNull() ?: Result.success(Unit) + return createResults.removeFirstOrNull() ?: Result.success(CREATED_DAILY_QUESTION_ID) } override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = Result.success(Unit) + ): Result = Result.success(id) override suspend fun delete(id: Long): Result = Result.success(Unit) + + private companion object { + /** 서버가 돌려주는 `userDailyQuestionId` 자리 (#573). 값 자체에 의미는 없다. */ + const val CREATED_DAILY_QUESTION_ID = 1L + } } class FakeErrorReporter : ErrorReporter { diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index e53c4d7c54..f4ac30b43b 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -2,16 +2,9 @@ package com.afternote.afternote_fe.test import com.afternote.afternote_fe.reporting.ErrorReportingModule import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.data.repoimpl.PhotoUploadRepositoryImpl -import com.afternote.core.data.repoimpl.UserProfileRepositoryImpl -import com.afternote.core.data.repoimpl.VideoUploadRepositoryImpl -import com.afternote.core.data.repoimpl.account.AccountRepositoryImpl -import com.afternote.core.di.CoreRepositoryModule -import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.data.di.CoreUserRepositoryModule import com.afternote.core.domain.repository.UserProfileRepository import com.afternote.core.domain.repository.UserRepository -import com.afternote.core.domain.repository.VideoUploadRepository -import com.afternote.core.domain.repository.account.AccountRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.feature.mindrecord.data.di.MindRecordRepositoryModule import com.afternote.feature.mindrecord.data.repositoryimpl.MindRecordReceiverRepositoryImpl @@ -29,9 +22,9 @@ import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], - replaces = [CoreRepositoryModule::class], + replaces = [CoreUserRepositoryModule::class], ) -object TestCoreRepositoryModule { +object TestCoreUserRepositoryModule { @Provides @Singleton fun provideAuthRepository(): AuthRepository = FakeAuthRepository(loggedIn = false) @@ -43,18 +36,6 @@ object TestCoreRepositoryModule { @Provides @Singleton fun provideUserProfileRepository(): UserProfileRepository = FakeUserProfileRepository() - - @Provides - @Singleton - fun provideAccountRepository(impl: AccountRepositoryImpl): AccountRepository = impl - - @Provides - @Singleton - fun providePhotoUploadRepository(impl: PhotoUploadRepositoryImpl): PhotoUploadRepository = impl - - @Provides - @Singleton - fun provideVideoUploadRepository(impl: VideoUploadRepositoryImpl): VideoUploadRepository = impl } @Module diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e3b606f481..5bd10ba64f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,6 +47,23 @@ android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="@string/fcm_notification_channel_id" /> + + + + + + diff --git a/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt b/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt index c27c593e01..00cddf32e0 100644 --- a/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt +++ b/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt @@ -22,6 +22,6 @@ class GlobalApplication : override fun onCreate() { super.onCreate() FcmNotificationChannel.create(this) - // 공통 초기화는 core:startup에 두고, app 전용 Firebase 채널만 여기서 생성한다. + // 기동 초기화는 startup 패키지의 Initializer 에 두고, Firebase 채널만 여기서 생성한다. } } diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt index ea4c2343c5..0575adaf13 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt @@ -23,17 +23,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState -import com.afternote.afternote_fe.screen.HomeTabScreen -import com.afternote.afternote_fe.screen.HomeTabViewModel -import com.afternote.afternote_fe.screen.receiver.ReceiverHomeEntry import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomBar import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.afternote.presentation.author.navigation.afternoteNavGraph -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute -import com.afternote.feature.afternote.presentation.receiver.navigation.receiverNavGraph +import com.afternote.feature.home.presentation.HomeTabScreen +import com.afternote.feature.home.presentation.HomeTabViewModel import com.afternote.feature.mindrecord.presentation.navigation.mindRecordNavGraph import com.afternote.feature.onboarding.presentation.navigation.onboardingNavGraph +import com.afternote.feature.receiver.presentation.home.ReceiverHomeEntry +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import com.afternote.feature.receiver.presentation.navigation.receiverNavGraph import com.afternote.feature.setting.presentation.navigation.settingNavGraph import com.afternote.feature.timeletter.presentation.navigation.timeLetterNavGraph import kotlinx.coroutines.launch @@ -49,8 +49,6 @@ fun AppNavigation( val showBottomBar = appState.shouldShowBottomBar(currentDestination) val currentTab = appState.getCurrentNavTab(currentDestination) - NavigationDebugLogger(navEntry, currentDestination, showBottomBar, currentTab) - val onboardingNavActions = rememberOnboardingNavActions(appState.navController) val mindRecordNavActions = rememberMindRecordNavActions(appState.navController) val settingNavActions = rememberSettingNavActions(appState) diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index 8fb2ef82c9..3e325d2ad6 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -5,20 +5,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.navigation.NavController -import com.afternote.afternote_fe.screen.HomeTabActions -import com.afternote.afternote_fe.screen.receiver.ReceiverHomeActions import com.afternote.core.model.MindRecordCategory import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomNavTab import com.afternote.feature.afternote.presentation.author.editor.model.EditorCategory import com.afternote.feature.afternote.presentation.author.navigation.AfternoteNavActions import com.afternote.feature.afternote.presentation.author.navigation.model.AfternoteRoute -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceiverNavActions -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute +import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordNavActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute import com.afternote.feature.onboarding.presentation.navigation.OnboardingNavActions import com.afternote.feature.onboarding.presentation.navigation.OnboardingRoute +import com.afternote.feature.receiver.presentation.home.ReceiverHomeActions +import com.afternote.feature.receiver.presentation.navigation.ReceiverNavActions +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import com.afternote.feature.setting.presentation.navigation.SettingNavActions import com.afternote.feature.setting.presentation.navigation.SettingRoute import com.afternote.feature.timeletter.presentation.navigation.TimeLetterNavActions @@ -344,7 +344,9 @@ fun rememberHomeTabActions( } override fun onAnswerClick() { - // TODO: 데일리 질문 답변 화면 Route 추가 후 연결 + // 카드 문구가 "데일리질문 답변하기" 라 답변 작성 화면으로 보낸다. + // 기록 탭의 작성 진입(`MindRecordNavActions.onWriteDailyQuestion`)과 같은 목적지다. + appState.navController.navigate(MindRecordRoute.DailyQuestionWriteRoute) } override fun onNextStepClick() { diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/NavigationDebugLogger.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/NavigationDebugLogger.kt deleted file mode 100644 index c0887467ce..0000000000 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/NavigationDebugLogger.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.afternote.afternote_fe.navigation - -import android.util.Log -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy -import com.afternote.afternote_fe.BuildConfig -import com.afternote.core.ui.Route -import com.afternote.core.ui.bottombar.BottomNavTab -import com.afternote.feature.afternote.presentation.author.navigation.model.AfternoteRoute - -private const val NAV_BOTTOM_BAR_DEBUG_TAG = "NavBottomBarDebug" - -@Composable -fun NavigationDebugLogger( - navEntry: NavBackStackEntry?, - currentDestination: NavDestination?, - showBottomBar: Boolean, - currentTab: BottomNavTab, -) { - if (BuildConfig.DEBUG) { - val hierarchyRoutes = - currentDestination?.hierarchy?.mapNotNull { it.route }?.joinToString(prefix = "[", postfix = "]") - val hasRouteAfternoteGraph = currentDestination?.hasRoute(Route.Afternote::class) == true - val hasRouteAfternoteHome = currentDestination?.hasRoute(AfternoteRoute.AfternoteHomeRoute::class) == true - val hasRouteEditor = currentDestination?.hasRoute(AfternoteRoute.EditorRoute::class) == true - LaunchedEffect(navEntry?.id, currentDestination?.route, showBottomBar, currentTab) { - Log.d( - NAV_BOTTOM_BAR_DEBUG_TAG, - "entryId=${navEntry?.id} destRoute=${currentDestination?.route} " + - "hierarchyRoutes=$hierarchyRoutes " + - "shouldShowBottomBar=$showBottomBar currentNavTab=$currentTab " + - "hasRoute(Route.Afternote)=$hasRouteAfternoteGraph " + - "hasRoute(AfternoteHomeRoute)=$hasRouteAfternoteHome " + - "hasRoute(EditorRoute)=$hasRouteEditor", - ) - } - } -} diff --git a/core/startup/src/main/kotlin/com/afternote/core/startup/DailyNotificationInitializer.kt b/app/src/main/java/com/afternote/afternote_fe/startup/DailyNotificationInitializer.kt similarity index 82% rename from core/startup/src/main/kotlin/com/afternote/core/startup/DailyNotificationInitializer.kt rename to app/src/main/java/com/afternote/afternote_fe/startup/DailyNotificationInitializer.kt index f5380b8cae..fe1400f0d4 100644 --- a/core/startup/src/main/kotlin/com/afternote/core/startup/DailyNotificationInitializer.kt +++ b/app/src/main/java/com/afternote/afternote_fe/startup/DailyNotificationInitializer.kt @@ -1,4 +1,4 @@ -package com.afternote.core.startup +package com.afternote.afternote_fe.startup import android.content.Context import androidx.startup.Initializer @@ -7,9 +7,9 @@ import com.afternote.core.common.notification.NotificationScheduler /** * 앱 기동 시 일일 알림 WorkManager 예약. - * `app`이 `core:common`의 [NotificationScheduler]를 직접 호출해도 되지만, App Startup으로 초기화를 한곳에 모은다. * * [WorkManagerInitializer]를 의존성에 넣어 [androidx.work.WorkManager]가 먼저 준비된 뒤 스케줄링하도록 한다. + * `Application.onCreate()`에서 [NotificationScheduler]를 직접 불러서는 이 선후 보장을 얻지 못한다. */ class DailyNotificationInitializer : Initializer { override fun create(context: Context) { diff --git a/core/startup/src/main/kotlin/com/afternote/core/startup/KakaoInitializer.kt b/app/src/main/java/com/afternote/afternote_fe/startup/KakaoInitializer.kt similarity index 63% rename from core/startup/src/main/kotlin/com/afternote/core/startup/KakaoInitializer.kt rename to app/src/main/java/com/afternote/afternote_fe/startup/KakaoInitializer.kt index 3eb25f5357..a15b602b81 100644 --- a/core/startup/src/main/kotlin/com/afternote/core/startup/KakaoInitializer.kt +++ b/app/src/main/java/com/afternote/afternote_fe/startup/KakaoInitializer.kt @@ -1,14 +1,15 @@ -package com.afternote.core.startup +package com.afternote.afternote_fe.startup import android.content.Context import androidx.startup.Initializer +import com.afternote.afternote_fe.BuildConfig import com.kakao.sdk.common.KakaoSdk /** * 카카오 SDK 초기화. `Application`이 아닌 App Startup으로 실행한다. * - * `app` 모듈의 [com.afternote.afternote_fe.BuildConfig]는 라이브러리에서 참조할 수 없으므로 - * [BuildConfig]는 `core:startup`의 `build.gradle.kts`에서 동일 키로 생성한다. + * [GlobalApplication][com.afternote.afternote_fe.GlobalApplication]의 `onCreate()`를 초기화 코드로 + * 채우지 않고, 기동 시 실행할 일을 Initializer 단위로 나눠 둔다. */ class KakaoInitializer : Initializer { override fun create(context: Context) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65b5c9e47f..521c78f4a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,35 +3,4 @@ afternote_fcm 애프터노트 알림 AFTERNOTE - - - 안녕하세요,\n%1$s님 - 오늘도 당신의 하루를 차분히 기록해보세요. - AFTER NOTE NEXT STEP - 가족들의 \u2018주거래 은행\u2019 정보를\n입력하신 건 확인하셨나요? - 애프터노트 입력하러가기 - 정보를 불러오지 못했습니다. - 다시 시도 - - - 故 %1$s님이 남기신 기록 - %1$s님의 한 마디 - TOTAL - 데일리 질문 - 일기 - +%1$d - 마음의 기록 - 고인의 일상적인 생각과 감정, 일기들입니다. - 마음의 기록 확인하러 가기 - 타임레터 - 고인이 특별한 날에\n수신자님에게 작성한 편지입니다. - 라이프 이벤트 레터 확인하러 가기 - 애프터노트 - 고인이 사후 정리하고자 하는 데이터입니다. - 애프터노트 확인하러 가기 - 기록 수를 불러오지 못했습니다. - 모든 기록 내려받기 - 모든 기록을 내려받으시겠습니까? - 모든 기록 내려받기에 실패했습니다. - 파일 저장에 실패했습니다. diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 31825d3c7a..c79836dc8a 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -11,6 +11,25 @@ dependencies { compileOnly("org.jetbrains.kotlin:kotlin-serialization:${libs.versions.kotlin.get()}") testImplementation(libs.junit) testImplementation(gradleTestKit()) + + // 루트 build.gradle.kts 의 보안 하한은 별도 빌드인 여기까지 미치지 않는다 — 같은 근거(#921·#981· + // #982·#985). AGP 9.2.1 이 이 클래스패스에도 같은 취약 버전을 끌어온다(netty 는 여기 없다). + constraints { + listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").forEach { artifact -> + implementation("org.bouncycastle:$artifact:${libs.versions.bouncycastle.get()}") { + because("GHSA-574f-3g2m-x479 등 1.84 미만 취약 — #921") + } + } + implementation("org.apache.commons:commons-lang3:${libs.versions.commonsLang3.get()}") { + because("GHSA-j288-q9x7-2f5v — 3.18.0 미만 취약 — #981") + } + implementation("org.bitbucket.b_c:jose4j:${libs.versions.jose4j.get()}") { + because("GHSA-3677-xxcr-wjqv — 0.9.6 미만 취약 — #982") + } + implementation("org.jdom:jdom2:${libs.versions.jdom2.get()}") { + because("GHSA-2363-cqg2-863c — 2.0.6.1 미만 취약 — #985") + } + } } tasks.withType().configureEach { diff --git a/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt index fa890301e0..53fd04514b 100644 --- a/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt @@ -3,6 +3,7 @@ import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.getByType import org.jlleitschuh.gradle.ktlint.KtlintExtension +import org.jlleitschuh.gradle.ktlint.reporter.ReporterType class AndroidLintConventionPlugin : Plugin { override fun apply(target: Project) { @@ -16,14 +17,20 @@ class AndroidLintConventionPlugin : Plugin { .named("libs") extensions.configure(KtlintExtension::class.java) { - // ktlint binary 버전을 CI(ScaCap/action-ktlint) 와 동일하게 핀. - // 추가 룰셋(compose-rules 등)은 CI 가 적재하지 않으므로 로컬도 적재하지 않는다. + // ktlint binary 버전 정본은 이 카탈로그 하나다 — CI 도 같은 Gradle 태스크를 + // 돌리므로 워크플로에 손으로 적은 버전이 없다 (#1012). + // 추가 룰셋(compose-rules 등)은 적재하지 않는다. version.set(libs.findVersion("ktlint").get().requiredVersion) debug.set(false) verbose.set(true) android.set(true) outputToConsole.set(true) ignoreFailures.set(false) + // PLAIN 은 콘솔·로컬용, CHECKSTYLE 은 CI 가 job summary 로 렌더할 기계 판독용. + reporters { + reporter(ReporterType.PLAIN) + reporter(ReporterType.CHECKSTYLE) + } filter { exclude { it.file.path.contains("build/") } } diff --git a/build.gradle.kts b/build.gradle.kts index c31689f803..bb76ed224c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,37 @@ import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension import org.gradle.kotlin.dsl.configure +// AGP 9.2.1·Firebase App Distribution 5.3.0 이 buildscript classpath 로 끌어오는 전이 의존성 중 +// 보안 권고 영향권인 것들의 하한(#921·#975~#985). 루트 classpath 는 plugins 블록 처리 시점에 +// 리졸브가 끝나 아래 본문 훅으로는 늦고, buildscript 블록에서는 버전 카탈로그 accessor 를 쓸 수 없다 +// — libs.versions.toml 의 같은 이름 버전과 값을 맞춰 유지할 것. +buildscript { + dependencies { + constraints { + listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").forEach { artifact -> + add("classpath", "org.bouncycastle:$artifact:1.84") { + because("GHSA-574f-3g2m-x479 등 1.84 미만 취약 — #921") + } + } + add("classpath", "org.apache.commons:commons-lang3:3.18.0") { + because("GHSA-j288-q9x7-2f5v — 3.18.0 미만 취약 — #981") + } + add("classpath", "org.bitbucket.b_c:jose4j:0.9.6") { + because("GHSA-3677-xxcr-wjqv — 0.9.6 미만 취약 — #982") + } + add("classpath", "org.jdom:jdom2:2.0.6.1") { + because("GHSA-2363-cqg2-863c — 2.0.6.1 미만 취약 — #985") + } + } + // netty 는 grpc-netty 가 끌어오는 모듈이 10개가 넘고 서로 같은 버전이어야 해 BOM 으로 정렬한다 + // (GHSA-558v-64gr-wgg4 등 33건, #975~#980). 이 BOM 을 아래 본문의 allprojects 하한에 함께 걸면 + // AGP 의 android-reverse-meta-data usage 가 platform 변형을 못 찾아 리졸브가 깨진다. netty 는 + // 루트 buildscript classpath 에만 있고(전 프로젝트 실측 53건 전부 여기) 산출물·테스트 클래스패스에는 + // 없으므로 여기서만 건다. + add("classpath", platform("io.netty:netty-bom:4.1.137.Final")) + } +} + // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.kover) @@ -16,6 +47,73 @@ plugins { alias(libs.plugins.firebase.crashlytics) apply false } +// 빌드·테스트 클래스패스의 보안 하한(#921·#975~#985). 상류가 취약 버전을 물고 있고 상류 최신판도 +// 아직 패치 버전 미만이라 constraint 로 올린다 — Robolectric 4.15.1(bcprov 1.80)·AGP 9.2.1(bcprov +// 1.79·commons-lang3 3.16.0·jose4j 0.9.5·jdom2 2.0.6)·Firebase App Distribution 5.3.0(netty +// 4.1.110.Final). androidLintTool 처럼 AGP 가 뒤늦게 만드는 configuration 까지 잡도록 configureEach +// 로 걸고, require 시맨틱이라 상류가 하한 이상을 선언하게 되면 그쪽이 이긴다. 여기 목록은 전부 빌드 +// 도구 경유라 APK 산출물에는 없다(releaseRuntimeClasspath 0건 실측). build-logic 은 별도 빌드라 +// build-logic/build.gradle.kts 가 같은 하한을 선언한다. +data class SecurityFloor( + val module: String, + val version: String, + val because: String, +) + +val securityFloors = + listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").map { artifact -> + SecurityFloor( + module = "org.bouncycastle:$artifact", + version = libs.versions.bouncycastle.get(), + because = "GHSA-574f-3g2m-x479 등 1.84 미만 취약 — #921", + ) + } + + listOf( + SecurityFloor( + module = "org.apache.commons:commons-lang3", + version = libs.versions.commonsLang3.get(), + because = "GHSA-j288-q9x7-2f5v — 3.18.0 미만 취약 — #981", + ), + SecurityFloor( + module = "org.bitbucket.b_c:jose4j", + version = libs.versions.jose4j.get(), + because = "GHSA-3677-xxcr-wjqv — 0.9.6 미만 취약 — #982", + ), + SecurityFloor( + module = "org.jdom:jdom2", + version = libs.versions.jdom2.get(), + because = "GHSA-2363-cqg2-863c — 2.0.6.1 미만 취약 — #985", + ), + ) + +fun Project.securityFloorConstraintSet() = + securityFloors.map { floor -> + dependencies.constraints.create("${floor.module}:${floor.version}") { + because(floor.because) + } + } + +allprojects { + // Gradle 9 는 declarable 이 아닌 configuration(compileClasspath 등)에 constraint 선언을 금지한다. + // 전용 declarable configuration 에 담아 모든 resolvable configuration 이 extend 하게 한다. + val floor = + configurations.create("securityFloors") { + isCanBeResolved = false + isCanBeConsumed = false + securityFloorConstraintSet().forEach { dependencyConstraints.add(it) } + } + configurations.configureEach { + if (name != floor.name && isCanBeResolved) extendsFrom(floor) + } +} +// buildscript 컨테이너에는 configuration 을 추가할 수 없어 declarable 인 classpath 에 constraint 를 +// 직접 단다. 루트 자신의 classpath 는 최상단 buildscript 블록이 담당한다(여기 시점엔 리졸브 완료라 불가). +subprojects { + buildscript.configurations.named("classpath") { + securityFloorConstraintSet().forEach { dependencyConstraints.add(it) } + } +} + val coverageProjects = subprojects.filter { project -> project.buildFile.isFile && diff --git a/core/di/src/main/java/com/afternote/core/di/DispatcherModule.kt b/core/common/src/main/kotlin/com/afternote/core/common/di/DispatcherModule.kt similarity index 82% rename from core/di/src/main/java/com/afternote/core/di/DispatcherModule.kt rename to core/common/src/main/kotlin/com/afternote/core/common/di/DispatcherModule.kt index eb3dd661d2..8a543d85c9 100644 --- a/core/di/src/main/java/com/afternote/core/di/DispatcherModule.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/di/DispatcherModule.kt @@ -1,6 +1,5 @@ -package com.afternote.core.di +package com.afternote.core.common.di -import com.afternote.core.common.di.IoDispatcher import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/core/data/src/main/java/com/afternote/core/data/di/CoreAccountRepositoryModule.kt b/core/data/src/main/java/com/afternote/core/data/di/CoreAccountRepositoryModule.kt new file mode 100644 index 0000000000..0a54f8388f --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/di/CoreAccountRepositoryModule.kt @@ -0,0 +1,18 @@ +package com.afternote.core.data.di + +import com.afternote.core.data.repoimpl.account.AccountRepositoryImpl +import com.afternote.core.domain.repository.account.AccountRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** 구현체와 같은 모듈에 있으므로 `internal` 로 닫는다 — 바깥에서 impl 을 만질 수 없다. */ +@InstallIn(SingletonComponent::class) +@Module +internal interface CoreAccountRepositoryModule { + @Binds + @Singleton + fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository +} diff --git a/core/data/src/main/java/com/afternote/core/data/di/CoreUploadRepositoryModule.kt b/core/data/src/main/java/com/afternote/core/data/di/CoreUploadRepositoryModule.kt new file mode 100644 index 0000000000..8eb3dd20a2 --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/di/CoreUploadRepositoryModule.kt @@ -0,0 +1,24 @@ +package com.afternote.core.data.di + +import com.afternote.core.data.repoimpl.PhotoUploadRepositoryImpl +import com.afternote.core.data.repoimpl.VideoUploadRepositoryImpl +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.VideoUploadRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** 구현체와 같은 모듈에 있으므로 `internal` 로 닫는다 — 바깥에서 impl 을 만질 수 없다. */ +@InstallIn(SingletonComponent::class) +@Module +internal interface CoreUploadRepositoryModule { + @Binds + @Singleton + fun bindPhotoUploadRepository(impl: PhotoUploadRepositoryImpl): PhotoUploadRepository + + @Binds + @Singleton + fun bindVideoUploadRepository(impl: VideoUploadRepositoryImpl): VideoUploadRepository +} diff --git a/core/data/src/main/java/com/afternote/core/data/di/CoreUserRepositoryModule.kt b/core/data/src/main/java/com/afternote/core/data/di/CoreUserRepositoryModule.kt new file mode 100644 index 0000000000..4c9880b589 --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/di/CoreUserRepositoryModule.kt @@ -0,0 +1,40 @@ +package com.afternote.core.data.di + +import com.afternote.core.data.repoimpl.UserProfileRepositoryImpl +import com.afternote.core.data.repoimpl.UserRepositoryImpl +import com.afternote.core.data.repoimpl.auth.AuthRepositoryImpl +import com.afternote.core.domain.repository.UserProfileRepository +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.auth.AuthRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * 세션·사용자 저장소 바인딩. + * + * 이 모듈만 `public` 인 이유 — `app` 의 androidTest 가 + * `@TestInstallIn(replaces = [CoreUserRepositoryModule::class])` 로 이 셋을 페이크로 갈아끼운다. + * `replaces` 는 모듈 클래스를 참조하므로 `internal` 로 닫을 수 없다. + * + * 그래서 형제 모듈들과 달리 `interface` 가 아니라 `abstract class` 다. 바인딩 메서드가 `public` 이면 + * `'public' function exposes its 'internal' parameter type` 으로 컴파일되지 않는데, interface 멤버에는 + * 가시성을 줄 수 없다. abstract class 라야 클래스는 열어 두고 메서드만 `internal` 로 닫는다. + */ +@InstallIn(SingletonComponent::class) +@Module +abstract class CoreUserRepositoryModule { + @Binds + @Singleton + internal abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + internal abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository + + @Binds + @Singleton + internal abstract fun bindUserProfileRepository(impl: UserProfileRepositoryImpl): UserProfileRepository +} diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt index 2e9750f15d..ed643ef980 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt @@ -62,7 +62,7 @@ private fun resolveExtension(mime: String?): String { return resolved?.takeIf { it in ALLOWED_EXTENSIONS } ?: DEFAULT_EXTENSION } -class PhotoUploadRepositoryImpl +internal class PhotoUploadRepositoryImpl @Inject constructor( @param:ApplicationContext private val context: Context, @@ -80,15 +80,8 @@ class PhotoUploadRepositoryImpl val extension = resolveExtension(mime) android.util.Log.d("PhotoUpload", "mime=$mime → extension=$extension") - val presigned = - imageApi - .getPresignedUrl( - PresignedUrlRequestDto( - directory = directory, - extension = extension, - ), - ).requireData() - + // presigned 요청에 파일 크기가 필수라, 임시 파일을 **먼저** 만들어 크기를 + // 확정한 뒤 요청한다. 순서가 뒤집히면 크기를 모른 채 요청이 나가 400 이 된다 (#950). val tempFile = withContext(ioDispatcher) { val file = File.createTempFile("photo_upload_", ".$extension", context.cacheDir) @@ -98,6 +91,21 @@ class PhotoUploadRepositoryImpl file } + val presigned = + try { + imageApi + .getPresignedUrl( + PresignedUrlRequestDto( + directory = directory, + extension = extension, + contentLength = tempFile.length(), + ), + ).requireData() + } catch (e: Throwable) { + tempFile.delete() + throw e + } + try { val contentType = presigned.contentType.ifBlank { DEFAULT_CONTENT_TYPE } val requestBody = tempFile.asRequestBody(contentType.toMediaType()) diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt index c57855c483..4a6b0a2373 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt @@ -5,7 +5,7 @@ import com.afternote.core.domain.repository.UserProfileRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject -class UserProfileRepositoryImpl +internal class UserProfileRepositoryImpl @Inject constructor( private val dataSource: UserProfileDataSource, @@ -21,8 +21,4 @@ class UserProfileRepositoryImpl override suspend fun saveUserName(name: String) { dataSource.saveUserName(name) } - - override suspend fun clear() { - dataSource.clear() - } } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt index 2f56b0d9eb..8c797f04d4 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt @@ -30,6 +30,8 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject import com.afternote.core.data.mapper.delivery.toDomain as toDeliveryConditionsDomain +// `app` 의 androidTest(SettingCompletionAndroidTest)가 Hilt 를 우회해 이 구현을 직접 조립하므로 +// 형제 구현체들과 달리 `internal` 로 닫지 못한다. 닫으려면 그 테스트의 페이크 기반 개조가 선행돼야 한다 (#930). class UserRepositoryImpl @Inject constructor( diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt index 29693b7c3f..94a0f32c4a 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt @@ -23,7 +23,7 @@ import javax.inject.Named /** MIME 타입에서 확장자를 못 뽑았을 때 폴백. 대부분의 안드로이드 영상이 mp4 라 합리적 디폴트. */ private const val DEFAULT_VIDEO_EXTENSION = "mp4" -class VideoUploadRepositoryImpl +internal class VideoUploadRepositoryImpl @Inject constructor( @param:ApplicationContext private val context: Context, @@ -39,15 +39,7 @@ class VideoUploadRepositoryImpl val uri = uriString.toUri() val extension = videoExtensionFromUri(uri) - val presigned = - imageApi - .getPresignedUrl( - PresignedUrlRequestDto( - directory = directory, - extension = extension, - ), - ).requireData() - + // presigned 요청에 파일 크기가 필수라 임시 파일을 먼저 만든다 (#950). val tempFile = withContext(ioDispatcher) { val file = File.createTempFile("video_upload_", ".$extension", context.cacheDir) @@ -57,6 +49,21 @@ class VideoUploadRepositoryImpl file } + val presigned = + try { + imageApi + .getPresignedUrl( + PresignedUrlRequestDto( + directory = directory, + extension = extension, + contentLength = tempFile.length(), + ), + ).requireData() + } catch (e: Throwable) { + tempFile.delete() + throw e + } + try { val contentType = presigned.contentType.ifBlank { "video/$extension" } val requestBody = tempFile.asRequestBody(contentType.toMediaType()) diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt index 2ec06529fc..c7a4fe4c27 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt @@ -16,7 +16,7 @@ import com.afternote.core.network.model.requireStatus import com.afternote.core.network.service.AccountApiService import javax.inject.Inject -class AccountRepositoryImpl +internal class AccountRepositoryImpl @Inject constructor( private val accountApiService: AccountApiService, diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt index a91b32cf10..c083076f02 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt @@ -2,6 +2,8 @@ package com.afternote.core.data.repoimpl.auth import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.data.mapper.auth.AuthMapper +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource import com.afternote.core.domain.error.InvalidLoginCredentialsException import com.afternote.core.domain.error.NetworkUnavailableException @@ -22,7 +24,7 @@ import kotlinx.coroutines.flow.Flow import java.io.IOException import javax.inject.Inject -class AuthRepositoryImpl +internal class AuthRepositoryImpl @Inject constructor( private val tokenDataSource: TokenDataSource, @@ -30,15 +32,14 @@ class AuthRepositoryImpl private val tokenApiService: TokenApiService, // 발급(로그인) 응답의 expiresIn 으로 선제 reissue deadline 을 기록하고, 세션 종료 시 함께 정리한다 (#408/#410). private val expiryTracker: AccessTokenExpiryTracker, + // 로그아웃·탈퇴 시 SESSION 스코프 로컬 저장소 일괄 정리 (#912). + private val localStoreRegistry: LocalStoreRegistry, ) : AuthRepository { override suspend fun clearSession() = runCatchingCancellable { - tokenDataSource.clearTokens() - // deadline 은 세션의 산물 — 세션을 끝내는 책임이 여기 있으므로 토큰과 함께 정리한다. - // 남기면 재로그인 후 이전 토큰 기준 deadline 으로 임박을 오판한다. - // TokenDataSource.clearTokens() 내부로 내리지 않는 이유: datastore 는 저장만 아는 - // 잎 모듈이라 network 의 tracker 를 알면 의존 방향이 뒤집히고, 기록(record)은 이 - // 레이어(로그인 응답·TokenReissuer)가 하므로 폐기만 내리면 수명 관리가 두 레이어로 찢어진다. + localStoreRegistry.clearScope(StoreScope.SESSION) + // tracker 는 network 계층 in-memory 상태라 레지스트리(디스크 저장소 전담) 관할 밖. + // 남기면 재로그인 후 이전 토큰 기준 deadline 으로 만료 임박을 오판한다. expiryTracker.clear() } @@ -128,7 +129,7 @@ class AuthRepositoryImpl /** * 서버 로그아웃은 best-effort (네트워크 실패해도 사용자는 로그아웃 상태로 가야 함). - * 로컬 토큰과 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다. + * SESSION 스코프 로컬 저장소와 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다. */ override suspend fun logout(): Result = runCatchingCancellable { @@ -136,12 +137,10 @@ class AuthRepositoryImpl if (refreshToken != null) { runCatchingCancellable { authApiService.logout(LogoutRequestDto(refreshToken)) } } - tokenDataSource.clearTokens() - // deadline 은 방금 지운 액세스 토큰의 잔여 수명 기록 — 토큰이 사라지는 순간 의미를 잃으므로 - // 세션을 끝내는 여기서 함께 버린다 (남기면 재로그인 후 죽은 토큰 기준으로 임박 오판). - // 반드시 위 API 호출 뒤여야 한다: 로그아웃 HTTP 요청도 AuthInterceptor 를 지나므로, 토큰이 - // 만료 임박이면 요청 직전에 선제 reissue(401 을 기다리지 않고 미리 새 토큰 쌍으로 교체)가 - // 일어나 tracker 에 새 deadline 이 기록될 수 있다 — clear 를 먼저 하면 그 기록이 되살아난다. + // 두 정리 모두 반드시 위 API 호출 뒤여야 한다. 로그아웃 HTTP 요청도 AuthInterceptor 를 + // 지나므로 그 시점엔 토큰이 살아 있어야 하고, 토큰이 만료 임박이면 요청 직전 선제 reissue 가 + // tracker 에 새 deadline 을 기록할 수 있다 — tracker 를 먼저 비우면 그 기록이 되살아난다. + localStoreRegistry.clearScope(StoreScope.SESSION) expiryTracker.clear() } diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt index 249309c418..3c4536013b 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt @@ -3,6 +3,8 @@ package com.afternote.core.data.repoimpl.auth import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource import com.afternote.core.domain.error.InvalidLoginCredentialsException import com.afternote.core.domain.error.NetworkUnavailableException @@ -38,14 +40,25 @@ import kotlin.coroutines.cancellation.CancellationException * * 계약 — 발급(로그인) 응답의 `expiresIn` 은 기록하고, 생략(null)이면 이전 토큰 기준 stale * deadline 이 새 세션에 적용되지 않게 비우며(`TokenReissuer` 회전 경로와 같은 규칙), - * 세션 종료(`logout`/`clearSession`)는 로컬 토큰과 deadline 을 함께 정리한다. + * 세션 종료(`logout`/`clearSession`)는 SESSION 스코프 저장소 일괄 정리(#912)와 deadline + * 정리를 함께 수행한다. * * [AccessTokenExpiryTracker] 는 실물 사용 — 시계(SystemClock)는 `isReturnDefaultValues` 로 * 0 에 고정되므로, 잔여 30초 기록 = 임박 true / 비움 = false 로 상태를 관찰한다 (임계 60초). */ class AuthRepositoryImplTest { private val tracker = AccessTokenExpiryTracker() - private val tokenDataSource = TokenDataSource(InMemoryPreferencesDataStore()) + private val tokenStore = InMemoryPreferencesDataStore() + private val tokenDataSource = TokenDataSource(tokenStore) + + // 실제 레지스트리처럼 SESSION 정리가 토큰 저장소를 비우게 흉내 낸다 — 그래야 아래 + // 토큰 잔존·서버 호출 순서(정리가 먼저면 refresh 가 없어 서버 호출 불가) 단언이 유효하다. + private val localStoreRegistry = + FakeLocalStoreRegistry( + onClearScope = { scope -> + if (scope == StoreScope.SESSION) tokenStore.updateData { emptyPreferences() } + }, + ) private fun repository( authApiService: AuthApiService = FakeAuthApiService(), @@ -55,6 +68,7 @@ class AuthRepositoryImplTest { authApiService = authApiService, tokenApiService = tokenApiService, expiryTracker = tracker, + localStoreRegistry = localStoreRegistry, ) @Test @@ -105,7 +119,7 @@ class AuthRepositoryImplTest { } @Test - fun `logout - 저장돼 있던 refresh 로 서버 호출 후 로컬 토큰·deadline 정리`() { + fun `logout - 저장돼 있던 refresh 로 서버 호출 후 SESSION 스코프·deadline 정리`() { runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") } tracker.record(expiresInSeconds = 30) val authApiService = FakeAuthApiService() @@ -113,13 +127,15 @@ class AuthRepositoryImplTest { val result = runBlocking { repository(authApiService).logout() } assertTrue(result.isSuccess) + // 정리가 서버 호출보다 먼저였다면 refresh 가 이미 없어 이 요청 자체가 못 나간다 (순서 가드). assertEquals(listOf(LogoutRequestDto("stored-refresh")), authApiService.logoutRequests) + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) assertNull(runBlocking { tokenDataSource.getRefreshToken() }) assertFalse(tracker.isExpiringSoon()) } @Test - fun `logout - 서버 호출 실패해도 (best-effort) 토큰·deadline 정리는 진행`() { + fun `logout - 서버 호출 실패해도 (best-effort) SESSION 스코프·deadline 정리는 진행`() { runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") } tracker.record(expiresInSeconds = 30) val authApiService = @@ -130,18 +146,20 @@ class AuthRepositoryImplTest { val result = runBlocking { repository(authApiService).logout() } assertTrue(result.isSuccess) + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) assertNull(runBlocking { tokenDataSource.getRefreshToken() }) assertFalse(tracker.isExpiringSoon()) } @Test - fun `clearSession - 로컬 토큰·deadline 함께 정리`() { + fun `clearSession - SESSION 스코프·deadline 함께 정리 (탈퇴 경로도 이 메서드를 쓴다)`() { runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") } tracker.record(expiresInSeconds = 30) val result = runBlocking { repository().clearSession() } assertTrue(result.isSuccess) + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) assertNull(runBlocking { tokenDataSource.getAccessToken() }) assertFalse(tracker.isExpiringSoon()) } @@ -331,6 +349,23 @@ private class FakeTokenApiService( override suspend fun reissue(body: ReissueRequestDto): BaseResponse = onReissue(body) } +/** [LocalStoreRegistry] 테스트 대역 — clearScope 호출을 기록하고, 주입된 동작으로 실제 정리를 흉내 낸다. */ +private class FakeLocalStoreRegistry( + private val onClearScope: suspend (StoreScope) -> Unit = {}, +) : LocalStoreRegistry { + val clearedScopes = mutableListOf() + + override fun store( + name: String, + scope: StoreScope, + ): DataStore = error("store 는 이 테스트에서 호출되면 안 됨") + + override suspend fun clearScope(scope: StoreScope) { + clearedScopes += scope + onClearScope(scope) + } +} + /** 단위 테스트용 in-memory `DataStore` — 디스크 없이 [TokenDataSource] 실물을 구동한다. */ private class InMemoryPreferencesDataStore : DataStore { private val state = MutableStateFlow(emptyPreferences()) diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistry.kt b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistry.kt new file mode 100644 index 0000000000..ae814ad35f --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistry.kt @@ -0,0 +1,38 @@ +package com.afternote.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +/** + * 앱의 모든 Preferences DataStore 를 만들어 나눠주는 단일 창구 (#912). + * + * 각 모듈이 `preferencesDataStore(name = ...)` 델리게이트를 독립 선언하던 구조에서는 + * (1) 로그아웃 정리 대상 등록을 빼먹어도 어디서도 안 걸리고, (2) 파일명 충돌을 아무도 못 막았다. + * 레지스트리 경유로 바꾸면 획득 시점에 수명([StoreScope])이 함께 등록되고, 같은 name 은 + * 항상 같은 인스턴스를 돌려받아(DataStore 는 파일당 인스턴스 1개가 강제 조건) 둘 다 구조적으로 풀린다. + * + * 반환 타입은 [DataStore] 그대로다 — `Flow` 관찰·원자적 다중 키 `edit` 등 DataStore API 를 + * 좁히지 않는다(#912 하지 말 것). 타입드 접근자(스키마)는 각 feature 의 DataSource 가 유지한다. + */ +interface LocalStoreRegistry { + /** + * `files/datastore/.preferences_pb` 를 쓰는 DataStore 를 만들어 돌려주고, + * [scope] 를 수명으로 등록한다. 같은 [name] 재요청은 같은 인스턴스를 돌려주며, + * 같은 [name] 을 다른 [scope] 로 재요청하면 즉시 실패한다. + * + * [name] 은 저장 파일명 계약이다 — 바꾸면 기존 사용자의 데이터가 통째로 끊긴다 (#912 필수 주의). + */ + fun store( + name: String, + scope: StoreScope, + ): DataStore + + /** + * [scope] 로 등록된 모든 저장소의 키를 비운다 (파일은 유지). + * + * 등록 이력은 디스크에도 남으므로, 이번 프로세스에서 아직 획득된 적 없는 저장소도 + * 함께 지워진다 — Hilt provider 는 최초 주입 때에야 실행되므로 in-memory 등록만으로는 + * "이전 프로세스에서 쓰고 이번 프로세스에서 안 연" 저장소가 새는 구멍이 있다. + */ + suspend fun clearScope(scope: StoreScope) +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt new file mode 100644 index 0000000000..9a73ff296e --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt @@ -0,0 +1,204 @@ +package com.afternote.core.datastore + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException +import javax.inject.Inject + +/** + * [LocalStoreRegistry] 구현. + * + * 파일 경로는 기존 `preferencesDataStore` 델리게이트와 동일한 + * `files/datastore/.preferences_pb` — 델리게이트 → 레지스트리 전환에서 기존 사용자 + * 데이터가 그대로 이어진다. + * + * (name → scope) 등록은 in-memory 캐시와 별도로 자체 매니페스트 파일에도 영속한다. + * Hilt `@Provides` 는 최초 주입 때에야 실행되므로, in-memory 만으로는 "이전 프로세스에서 + * 기록되고 이번 프로세스에서 아직 안 열린" 저장소를 [clearScope] 가 놓친다 — 매니페스트가 + * 그 구멍을 막는다. 매니페스트 기록이 유실된 이름은 다음 획득 때 다시 기록되므로 자가 복구된다. + */ +internal class LocalStoreRegistryImpl( + private val produceFile: (name: String) -> File, + private val registryScope: CoroutineScope, +) : LocalStoreRegistry { + @Inject + constructor( + @ApplicationContext context: Context, + ) : this( + produceFile = { name -> context.preferencesDataStoreFile(name) }, + registryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + ) + + /** [registeredByCode] false = 매니페스트 기록만으로 열림([clearScope] 경유) — 코드의 scope 선언이 오면 그쪽이 정본. */ + private class Entry( + var scope: StoreScope, + val dataStore: DataStore, + var registeredByCode: Boolean, + ) + + private val lock = Any() + private val entries = mutableMapOf() + private val pendingManifestJobs = mutableListOf() + + private val manifest: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = newStoreScope()) { produceFile(MANIFEST_NAME) } + } + + override fun store( + name: String, + scope: StoreScope, + ): DataStore { + require(name != MANIFEST_NAME) { "'$MANIFEST_NAME' 은 레지스트리 예약 이름입니다." } + val (dataStore, needsRecord) = + synchronized(lock) { + val existing = entries[name] + when { + existing == null -> { + val created = createStore(name) + entries[name] = Entry(scope, created, registeredByCode = true) + created to true + } + + existing.registeredByCode -> { + check(existing.scope == scope) { + "저장소 '$name' 의 scope 충돌: ${existing.scope} 로 이미 등록됐는데 $scope 로 재요청됨" + } + existing.dataStore to false + } + + else -> { + val scopeChanged = existing.scope != scope + existing.scope = scope + existing.registeredByCode = true + existing.dataStore to scopeChanged + } + } + } + if (needsRecord) scheduleManifestRecord(name, scope) + return dataStore + } + + override suspend fun clearScope(scope: StoreScope) { + awaitPendingRegistrations() + val manifestNames = readManifestNames(scope) + val targets = + synchronized(lock) { + val inMemoryNames = entries.filterValues { it.scope == scope }.keys + (manifestNames + inMemoryNames).mapNotNull { name -> + val entry = entries[name] + when { + entry == null -> { + // 이번 프로세스에서 아직 획득되지 않은 저장소 — 매니페스트 기록으로 연다. + // 캐시에 넣어 이후 store() 가 같은 인스턴스를 받게 한다 (파일당 1개 강제). + val created = createStore(name) + entries[name] = Entry(scope, created, registeredByCode = false) + created + } + + entry.scope == scope -> { + entry.dataStore + } + + else -> { + // 코드에서 scope 가 바뀐 저장소 — 매니페스트가 낡았다. 지우지 않는다. + null + } + } + } + } + targets.forEach { store -> store.edit { it.clear() } } + } + + /** 아직 디스크에 안 간 매니페스트 기록을 기다린다 — [clearScope] 선행 단계이자 재기동 테스트의 flush 지점. */ + internal suspend fun awaitPendingRegistrations() { + synchronized(lock) { pendingManifestJobs.toList() }.joinAll() + } + + private fun createStore(name: String): DataStore = + PreferenceDataStoreFactory.create(scope = newStoreScope()) { + produceFile(name) + } + + /** + * 저장소별 독립 [SupervisorJob] 을 [registryScope] 아래에 둔다 — 한 저장소의 실패가 다른 + * 저장소로 번지지 않으면서, 부모 취소(테스트의 프로세스 종료 시뮬레이션) 시 함께 닫혀 + * DataStore 의 "파일당 활성 인스턴스 1개" 등록이 해제된다. + */ + private fun newStoreScope(): CoroutineScope = + CoroutineScope( + registryScope.coroutineContext + SupervisorJob(registryScope.coroutineContext.job), + ) + + private fun scheduleManifestRecord( + name: String, + scope: StoreScope, + ) { + val job = + registryScope.launch { + // 실패해도 앱은 계속 가야 한다 — 이 이름은 다음 획득 때 재기록되고, + // 이번 프로세스의 clearScope 는 in-memory 등록으로 커버된다. + runCatching { recordInManifest(name, scope) } + .onFailure { Log.e(TAG, "매니페스트 기록 실패($name): ${it.javaClass.name}") } + } + synchronized(lock) { pendingManifestJobs += job } + job.invokeOnCompletion { synchronized(lock) { pendingManifestJobs.remove(job) } } + } + + private suspend fun recordInManifest( + name: String, + scope: StoreScope, + ) { + manifest.edit { prefs -> + StoreScope.entries.forEach { candidate -> + val key = manifestKey(candidate) + val current = prefs[key] ?: emptySet() + // 소속 scope 에는 더하고 나머지에서는 빼서, scope 이동 시 낡은 기록이 남지 않게 한다. + prefs[key] = if (candidate == scope) current + name else current - name + } + } + } + + private suspend fun readManifestNames(scope: StoreScope): Set = + runCatching { manifest.data.first()[manifestKey(scope)] } + .getOrElse { exception -> + if (exception is IOException) { + // 매니페스트 손상 — in-memory 등록만으로 진행한다. 이름들은 다음 획득 때 재기록된다. + Log.e(TAG, "매니페스트 읽기 실패: ${exception.javaClass.name}") + null + } else { + throw exception + } + }.orEmpty() + + private fun manifestKey(scope: StoreScope): Preferences.Key> = + when (scope) { + StoreScope.SESSION -> SESSION_NAMES + StoreScope.DEVICE -> DEVICE_NAMES + } + + companion object { + private const val TAG = "LocalStoreRegistry" + + /** 레지스트리 자신의 (name → scope) 영속 기록 파일 — [store] 의 name 으로 쓸 수 없다. */ + internal const val MANIFEST_NAME = "local_store_registry" + + private val SESSION_NAMES = stringSetPreferencesKey("session_store_names") + private val DEVICE_NAMES = stringSetPreferencesKey("device_store_names") + } +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/StoreScope.kt b/core/datastore/src/main/java/com/afternote/core/datastore/StoreScope.kt new file mode 100644 index 0000000000..48325c1e86 --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/StoreScope.kt @@ -0,0 +1,15 @@ +package com.afternote.core.datastore + +/** + * 로컬 저장소(Preferences DataStore)의 수명 계약 (#912). + * + * 저장소는 [LocalStoreRegistry.store] 로 얻는 순간 scope 가 함께 등록되므로, + * 세션 종료 시 정리 대상에서 빠뜨리는 것이 구조적으로 차단된다. + */ +enum class StoreScope { + /** 로그인 세션에 귀속 — 로그아웃·회원 탈퇴 시 [LocalStoreRegistry.clearScope] 가 전부 비운다. */ + SESSION, + + /** 기기에 귀속 — 세션 종료와 무관하게 유지 (앱 삭제 전까지). */ + DEVICE, +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt index f626cef795..916be8cc02 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt @@ -54,12 +54,6 @@ class TokenDataSource } } - suspend fun clearTokens() { - dataStore.edit { prefs -> - prefs.clear() - } - } - suspend fun updateTokens( accessToken: String, refreshToken: String, diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/TokenPreferencesDataStore.kt b/core/datastore/src/main/java/com/afternote/core/datastore/TokenPreferencesDataStore.kt deleted file mode 100644 index d8612da848..0000000000 --- a/core/datastore/src/main/java/com/afternote/core/datastore/TokenPreferencesDataStore.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.core.datastore - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 액세스·리프레시 토큰 저장용 Preferences DataStore. - * [TokenDataSource]와 [com.afternote.core.datastore.di.TokenDataStoreModule]에서 동일 인스턴스를 참조합니다. - */ -internal val Context.tokenPreferencesDataStore: DataStore by preferencesDataStore( - name = "Token", -) diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt b/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt index 977304afd2..ed54c70758 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt @@ -57,10 +57,4 @@ class UserProfileDataSource prefs[Keys.IS_PASSKEY_REGISTERED] = registered } } - - suspend fun clear() { - dataStore.edit { prefs -> - prefs.clear() - } - } } diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfilePreferencesDataStore.kt b/core/datastore/src/main/java/com/afternote/core/datastore/UserProfilePreferencesDataStore.kt deleted file mode 100644 index 8b3115a2bf..0000000000 --- a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfilePreferencesDataStore.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.afternote.core.datastore - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 사용자 프로필 캐시용 Preferences DataStore. - * - * 프로필 정보(이름 등)는 거의 변하지 않으므로, 콜드스타트 시 GET /users/me RTT(약 1.3초)를 - * 기다리지 않고 즉시 표시할 수 있도록 마지막 응답을 디스크에 보관한다. - * - * [com.afternote.core.datastore.UserProfileDataSource]와 - * [com.afternote.core.datastore.di.UserProfileDataStoreModule]에서 동일 인스턴스를 참조한다. - */ -internal val Context.userProfilePreferencesDataStore: DataStore by preferencesDataStore( - name = "UserProfile", -) diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/di/LocalStoreRegistryModule.kt b/core/datastore/src/main/java/com/afternote/core/datastore/di/LocalStoreRegistryModule.kt new file mode 100644 index 0000000000..c7f3f9ec41 --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/di/LocalStoreRegistryModule.kt @@ -0,0 +1,17 @@ +package com.afternote.core.datastore.di + +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.LocalStoreRegistryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class LocalStoreRegistryModule { + @Binds + @Singleton + abstract fun bindLocalStoreRegistry(impl: LocalStoreRegistryImpl): LocalStoreRegistry +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt b/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt index 937f8df1a4..9a63d3e34d 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt @@ -1,13 +1,12 @@ package com.afternote.core.datastore.di -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import com.afternote.core.datastore.tokenPreferencesDataStore +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -17,7 +16,7 @@ internal object TokenDataStoreModule { @Provides @Singleton @TokenDataStore - fun provideTokenDataStore( - @ApplicationContext context: Context, - ): DataStore = context.tokenPreferencesDataStore + fun provideTokenDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 — 바꾸면 기존 사용자 로그인이 풀린다 (#912 필수 주의). + registry.store(name = "Token", scope = StoreScope.SESSION) } diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt b/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt index fd605463dc..bcd63817d1 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt @@ -1,13 +1,12 @@ package com.afternote.core.datastore.di -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import com.afternote.core.datastore.userProfilePreferencesDataStore +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -17,7 +16,7 @@ internal object UserProfileDataStoreModule { @Provides @Singleton @UserProfileDataStore - fun provideUserProfileDataStore( - @ApplicationContext context: Context, - ): DataStore = context.userProfilePreferencesDataStore + fun provideUserProfileDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 — 바꾸면 기존 사용자의 프로필 캐시가 끊긴다 (#912 필수 주의). + registry.store(name = "UserProfile", scope = StoreScope.SESSION) } diff --git a/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt b/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt new file mode 100644 index 0000000000..5d6553a9ee --- /dev/null +++ b/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt @@ -0,0 +1,170 @@ +package com.afternote.core.datastore + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * [LocalStoreRegistryImpl] 의 수명(scope) 계약 가드 (#912). + * + * 핵심은 재기동 시나리오다 — Hilt provider 는 최초 주입 때에야 실행되므로, 이전 프로세스에서 + * 기록된 저장소를 이번 프로세스에서 한 번도 안 연 채 로그아웃하면 in-memory 등록만으로는 + * 잔존이 재현된다. 매니페스트 영속이 그 구멍을 막는 것을 레지스트리 재생성(= 프로세스 재시작 + * 시뮬레이션)으로 검증한다. 실물 파일 DataStore 를 쓰며, 프로세스 종료는 registryScope 취소로 + * 흉내 낸다 (DataStore 의 "파일당 활성 인스턴스 1개" 등록이 scope 종료로 풀린다). + */ +class LocalStoreRegistryImplTest { + @get:Rule + val tmp = TemporaryFolder() + + private val key = stringPreferencesKey("value") + + private fun newRegistry(scope: CoroutineScope) = + LocalStoreRegistryImpl( + produceFile = { name -> File(tmp.root, "$name.preferences_pb") }, + registryScope = scope, + ) + + private fun newScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** 프로세스 종료 시뮬레이션 — 이 레지스트리의 모든 DataStore 활성 등록을 푼다. */ + private fun shutdown(scope: CoroutineScope) = + runBlocking { + val job = scope.coroutineContext.job + job.cancel() + job.join() + } + + @Test + fun `store - 같은 name 재요청은 같은 인스턴스 (파일당 1개 강제)`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + + val first = registry.store("alpha", StoreScope.SESSION) + val second = registry.store("alpha", StoreScope.SESSION) + + assertSame(first, second) + } finally { + shutdown(scope) + } + } + + @Test + fun `store - 같은 name 을 다른 scope 로 재요청하면 즉시 실패`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + registry.store("alpha", StoreScope.SESSION) + + val thrown = runCatching { registry.store("alpha", StoreScope.DEVICE) }.exceptionOrNull() + + assertTrue(thrown is IllegalStateException) + } finally { + shutdown(scope) + } + } + + @Test + fun `store - 매니페스트 예약 이름은 거부`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + + val thrown = + runCatching { + registry.store(LocalStoreRegistryImpl.MANIFEST_NAME, StoreScope.DEVICE) + }.exceptionOrNull() + + assertTrue(thrown is IllegalArgumentException) + } finally { + shutdown(scope) + } + } + + @Test + fun `clearScope(SESSION) - SESSION 저장소만 비우고 DEVICE 는 유지`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + runBlocking { + registry.store("session_store", StoreScope.SESSION).edit { it[key] = "세션 값" } + registry.store("device_store", StoreScope.DEVICE).edit { it[key] = "기기 값" } + + registry.clearScope(StoreScope.SESSION) + + assertNull(registry.store("session_store", StoreScope.SESSION).data.first()[key]) + assertEquals("기기 값", registry.store("device_store", StoreScope.DEVICE).data.first()[key]) + } + } finally { + shutdown(scope) + } + } + + @Test + fun `clearScope - 이번 프로세스에서 획득된 적 없는 저장소도 매니페스트 기록으로 지운다 (재기동 시뮬레이션)`() { + // 프로세스 1 — 저장소를 획득해 값을 쓰고 종료한다. + val scope1 = newScope() + val registry1 = newRegistry(scope1) + runBlocking { + registry1.store("session_store", StoreScope.SESSION).edit { it[key] = "잔존 후보" } + registry1.store("device_store", StoreScope.DEVICE).edit { it[key] = "기기 값" } + registry1.awaitPendingRegistrations() + } + shutdown(scope1) + + // 프로세스 2 — 어떤 store() 호출도 없이 곧장 로그아웃 (Hilt lazy 주입으로 미획득인 상황). + val scope2 = newScope() + try { + val registry2 = newRegistry(scope2) + runBlocking { + registry2.clearScope(StoreScope.SESSION) + + assertNull(registry2.store("session_store", StoreScope.SESSION).data.first()[key]) + assertEquals("기기 값", registry2.store("device_store", StoreScope.DEVICE).data.first()[key]) + } + } finally { + shutdown(scope2) + } + } + + @Test + fun `clearScope 가 연 저장소도 이후 store() 와 같은 인스턴스를 공유한다`() { + // 재기동 후 clearScope 가 매니페스트 기록만으로 먼저 연 저장소를, 뒤늦게 feature 가 + // 획득해도 두 번째 인스턴스가 만들어지면 안 된다 (DataStore 파일당 1개 강제 조건). + val scope1 = newScope() + val registry1 = newRegistry(scope1) + runBlocking { + registry1.store("session_store", StoreScope.SESSION).edit { it[key] = "값" } + registry1.awaitPendingRegistrations() + } + shutdown(scope1) + + val scope2 = newScope() + try { + val registry2 = newRegistry(scope2) + runBlocking { + registry2.clearScope(StoreScope.SESSION) + + val store = registry2.store("session_store", StoreScope.SESSION) + store.edit { it[key] = "새 세션 값" } + assertEquals("새 세션 값", store.data.first()[key]) + } + } finally { + shutdown(scope2) + } + } +} diff --git a/core/di/.gitignore b/core/di/.gitignore deleted file mode 100644 index 42afabfd2a..0000000000 --- a/core/di/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts deleted file mode 100644 index b1b575e6d9..0000000000 --- a/core/di/build.gradle.kts +++ /dev/null @@ -1,16 +0,0 @@ -plugins { - id("afternote.android.library") - id("afternote.android.hilt") -} - -android { - namespace = "com.afternote.core.di" -} - -dependencies { - implementation(libs.androidx.work.runtime.ktx) - implementation(projects.core.common) - implementation(projects.core.data) - implementation(projects.core.domain) - implementation(libs.kakao.sdk.auth) -} diff --git a/core/di/consumer-rules.pro b/core/di/consumer-rules.pro deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/core/di/proguard-rules.pro b/core/di/proguard-rules.pro deleted file mode 100644 index 481bb43481..0000000000 --- a/core/di/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/di/src/main/java/com/afternote/core/di/CoreRepositoryModule.kt b/core/di/src/main/java/com/afternote/core/di/CoreRepositoryModule.kt deleted file mode 100644 index 997b34dfb1..0000000000 --- a/core/di/src/main/java/com/afternote/core/di/CoreRepositoryModule.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.afternote.core.di - -import com.afternote.core.data.repoimpl.PhotoUploadRepositoryImpl -import com.afternote.core.data.repoimpl.UserProfileRepositoryImpl -import com.afternote.core.data.repoimpl.UserRepositoryImpl -import com.afternote.core.data.repoimpl.VideoUploadRepositoryImpl -import com.afternote.core.data.repoimpl.account.AccountRepositoryImpl -import com.afternote.core.data.repoimpl.auth.AuthRepositoryImpl -import com.afternote.core.domain.repository.PhotoUploadRepository -import com.afternote.core.domain.repository.UserProfileRepository -import com.afternote.core.domain.repository.UserRepository -import com.afternote.core.domain.repository.VideoUploadRepository -import com.afternote.core.domain.repository.account.AccountRepository -import com.afternote.core.domain.repository.auth.AuthRepository -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@InstallIn(SingletonComponent::class) -@Module -interface CoreRepositoryModule { - @Binds - @Singleton - fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository - - @Binds - @Singleton - fun bindPhotoUploadRepository(impl: PhotoUploadRepositoryImpl): PhotoUploadRepository - - @Binds - @Singleton - fun bindVideoUploadRepository(impl: VideoUploadRepositoryImpl): VideoUploadRepository - - @Binds - @Singleton - fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository - - @Binds - @Singleton - fun bindUserRepository(impl: UserRepositoryImpl): UserRepository - - @Binds - @Singleton - fun bindUserProfileRepository(impl: UserProfileRepositoryImpl): UserProfileRepository -} diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt index 0503410c6e..b662275782 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt @@ -19,6 +19,4 @@ interface UserProfileRepository { suspend fun getCachedUserName(): String? suspend fun saveUserName(name: String) - - suspend fun clear() } diff --git a/core/network/src/main/kotlin/com/afternote/core/network/dto/PresignedUrlDto.kt b/core/network/src/main/kotlin/com/afternote/core/network/dto/PresignedUrlDto.kt index e7044e430d..5a4b9b0542 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/dto/PresignedUrlDto.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/dto/PresignedUrlDto.kt @@ -2,10 +2,19 @@ package com.afternote.core.network.dto import kotlinx.serialization.Serializable +/** + * `POST /files/presigned-url` 요청. + * + * 세 필드 모두 명세가 required 로 선언한 값이다. [contentLength] 가 빠지면 서버가 + * `400 파일 크기는 필수입니다` 로 거절해 **모든 이미지 업로드가 실패한다** (#950). + * 기본값을 두지 않는 이유도 같다 — 크기를 모른 채 요청이 나가면 안 된다. + */ @Serializable data class PresignedUrlRequestDto( val directory: String, val extension: String, + /** 업로드할 파일 크기(바이트). Presigned PUT 의 `Content-Length` 와 같아야 한다. */ + val contentLength: Long, ) @Serializable diff --git a/core/network/src/test/kotlin/com/afternote/core/network/dto/PresignedUrlContractTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/dto/PresignedUrlContractTest.kt new file mode 100644 index 0000000000..eb4b6d6fcf --- /dev/null +++ b/core/network/src/test/kotlin/com/afternote/core/network/dto/PresignedUrlContractTest.kt @@ -0,0 +1,44 @@ +package com.afternote.core.network.dto + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `POST /files/presigned-url` 요청 계약 가드 (#950). + * + * 명세는 세 필드를 모두 required 로 선언한다. + * ``` + * PresignedUrlRequest required: [contentLength, directory, extension] + * ``` + * `contentLength` 가 빠지면 서버가 `400 파일 크기는 필수입니다` 로 거절해 **앱의 모든 이미지 + * 업로드가 실패한다** (2026-08-23 실측). 세 필드가 실제로 나가는지 여기서 고정한다. + */ +class PresignedUrlContractTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `요청에 세 필드가 모두 실린다`() { + val encoded = + json.encodeToString( + PresignedUrlRequestDto.serializer(), + PresignedUrlRequestDto(directory = "mindrecords", extension = "png", contentLength = 357L), + ) + + assertTrue(encoded.contains("\"directory\":\"mindrecords\"")) + assertTrue(encoded.contains("\"extension\":\"png\"")) + assertTrue("파일 크기가 빠지면 서버가 400 으로 거절한다", encoded.contains("\"contentLength\":357")) + } + + @Test + fun `크기가 0 이어도 필드는 생략되지 않는다`() { + // 기본값을 두면 kotlinx 가 기본값과 같은 값을 생략할 수 있다 — 크기는 언제나 명시한다. + val encoded = + json.encodeToString( + PresignedUrlRequestDto.serializer(), + PresignedUrlRequestDto(directory = "profiles", extension = "jpg", contentLength = 0L), + ) + + assertTrue(encoded.contains("\"contentLength\":0")) + } +} diff --git a/core/startup/build.gradle.kts b/core/startup/build.gradle.kts deleted file mode 100644 index eb9608223e..0000000000 --- a/core/startup/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - id("afternote.android.library") -} - -val kakaoKey = socialLoginKey("KAKAO_NATIVE_APP_KEY") - -android { - namespace = "com.afternote.core.startup" - - buildFeatures { - buildConfig = true - } - - defaultConfig { - buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"") - } -} - -dependencies { - implementation(projects.core.common) - implementation(libs.androidx.startup.runtime) - implementation(libs.androidx.work.runtime.ktx) - implementation(libs.kakao.sdk.auth) -} diff --git a/core/startup/consumer-rules.pro b/core/startup/consumer-rules.pro deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/core/startup/proguard-rules.pro b/core/startup/proguard-rules.pro deleted file mode 100644 index fb164d6662..0000000000 --- a/core/startup/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ -# Add project specific ProGuard rules here. diff --git a/core/startup/src/main/AndroidManifest.xml b/core/startup/src/main/AndroidManifest.xml deleted file mode 100644 index 4ad7566174..0000000000 --- a/core/startup/src/main/AndroidManifest.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - diff --git a/docs/qa/assumptions.md b/docs/qa/assumptions.md new file mode 100644 index 0000000000..c0e0367ea7 --- /dev/null +++ b/docs/qa/assumptions.md @@ -0,0 +1,260 @@ +# 추측으로 진행한 항목 + +확정 근거(시안·명세·이슈 답변)가 없어 **판단해서 정한** 것들을 모아 둔다. 각 항목은 되돌리기 +쉬운 형태로 구현했고, 확정 답변이 오면 여기 적힌 자리만 고치면 된다. + +근거가 있는 결정은 여기 적지 않는다 — 이 문서에 있는 것은 전부 "나중에 뒤집힐 수 있다". + +| 이슈 | PR | 추측한 것 | 확정되면 고칠 자리 | +|---|---|---|---| +| #627 | #894 | 홈 TODAY'S QUESTION `데일리질문 답변하기` 의 **목적지** | `AppNavigationActions.onAnswerClick()` 한 줄 | +| #442 | #896 | 임시저장 **삭제 실패 안내의 표현** | `DraftListScreen.DraftDeleteFailedToast` · `mindrecord_draft_list_delete_failed_toast` | +| #442 | #896 | 비활성 "선택 삭제" 라벨 **색** (`gray6`) | `DraftListScreen.DeleteActionBar` | +| #716 | #904 | 실패·진행 **표현** (기존 텍스트 재사용, 새 컴포넌트 미신설) | 각 화면의 진행/실패 `Text` — 표현 통일은 #446 | +| #592 | #909 | `getToday()` **실패 시 표시 방식** (영역을 그리지 않음) | `DailyQuestionAnswerListScreen.TodayRecommendedQuestion` | +| #751 | #908 | 날짜 파싱 실패 항목을 **목록에서 제외** (내용까지 숨김) | `MindRecordUiMapper.toUi()` · `WeeklyReportViewModel.toUi()` | + +--- + +## #627 — TODAY'S QUESTION CTA 목적지 + +**정한 것**: `MindRecordRoute.DailyQuestionWriteRoute`(데일리질문 답변 작성 화면). + +**근거가 없는 이유**: 이슈 본문이 "이 CTA 의 목적지(데일리질문 답변 작성 화면인지 기록 탭인지)가 +시안에서 어떻게 정의돼 있는지는 확인하지 않았다" 로 남겨 뒀다. Figma 카드 노드에 프로토타입 +연결이 없다. + +**판단 근거**: 카드 문구가 "데일리질문 **답변하기**" 이고, 같은 동작의 기존 진입점(기록 탭 → +FAB)이 이 작성 화면이다. 기록 탭으로 보내면 사용자가 한 번 더 눌러야 한다. + +**뒤집을 때**: `AppNavigationActions.onAnswerClick()` 의 `navigate(...)` 대상만 바꾼다. + +## #442 — 삭제 실패 안내 표현 + +**정한 것**: 완료 토스트(Figma `2372:25211`)와 같은 자리·같은 모양에 문구만 다르게. +`"%1$d개를 삭제하지 못했어요. 다시 시도해 주세요."` + +**근거가 없는 이유**: 시안에 삭제 **실패** 상태가 없다. 완료 토스트만 정의돼 있다. + +**판단 근거**: 새 표현을 만들면 미확정 디자인이 제품 동작으로 굳는다. 이미 있는 컴포넌트를 +재사용하고 문구만 바꾸는 쪽이 되돌리기 쉽다. + +**뒤집을 때**: `DraftDeleteFailedToast` 컴포저블과 문자열 리소스만 교체한다. 상태 모델 +(`DraftDeleteOutcome.SomeFailed`)은 표현과 무관하게 유지된다. + +비활성 "선택 삭제" 라벨 색(`gray6`)도 같은 이유로 임시값이다. + +## #716 — 실패·진행 표현 + +**정한 것**: 새 컴포넌트를 만들지 않고 기존 텍스트 표시에 문구와 재시도만 붙였다. + +**근거가 없는 이유**: 실패 안내 컴포넌트의 표현 통일은 이슈 노트대로 #446 범위다. + +**판단 근거**: 지금 새 표현을 만들면 #446 이 확정될 때 두 벌이 된다. 동작(재시도 결선· +문구 치환)만 고치고 표현은 그대로 뒀다. + +## #592 — 오늘의 추천 질문 조회 실패 + +**정한 것**: `getToday()` 가 실패하면 그 영역을 그리지 않는다. + +**근거가 없는 이유**: 시안에 이 영역의 실패 상태가 없다. + +**판단 근거**: 목록 자체는 정상인 상황이라 보조 영역 하나 때문에 화면을 오류로 바꾸는 것은 +과하다. 다만 이슈가 "실패가 드러나지 않는다" 를 지적한 것은 그대로 남는다. + +**뒤집을 때**: `TodayRecommendedQuestion` 하나만 고치면 된다. + +## #751 — 날짜 파싱 실패 항목 처리 + +**정한 것**: 목록에서 제외한다(내용도 함께 안 보인다). + +**근거가 없는 이유**: 이슈가 세 선택지(제외 / 날짜 자리 비움 / 상위로 전파)를 제시하고 +고르지 않았다. + +**판단 근거**: 같은 파일의 `Diary.toUi()` 가 이미 "제외" 규칙을 쓰고 KDoc 에 근거가 있다. +새 정책을 만들기보다 갈라진 쪽을 맞추는 편이 낫다고 봤다. + +**뒤집을 때**: 내용을 살리려면 `DailyQuestion.date` 를 nullable 로 바꾸고 카드가 날짜 자리를 +비우게 한다. + +## #789 — 기분 enum 의 "모르는 값" 처리 + +**정한 것**: 기분 필드의 **키 누락**은 파싱 실패로 두되, 클라가 모르는 **값**은 +그 칸만 비운다. nullable 기분 필드(`weeklyDominantMood`, `week[].emotion`)에 +`NullableTodayMoodSerializer` 를 달아 모르는 값을 `null` 로 접는다. + +**근거가 없는 이유**: 이슈는 "기본값을 없애 계약 누락을 드러내라" 까지만 정했다. +그런데 `coerceInputValues` 는 생성자 기본값이 있어야 동작해서, 기본값을 걷어내면 +키 누락뿐 아니라 값 확장까지 함께 실패로 바뀐다. 이슈가 이 부수 효과를 다루지 않았다. + +**판단 근거**: 두 상황은 성격이 다르다. 키 누락은 계약이 바뀐 것이라 드러나야 하지만, +값 확장(서버가 기분 종류를 늘리거나 표기를 바꾸는 것)은 이모지 한 칸 대신 그 주/그 달 +목록 전체를 날릴 일이 아니다. 감정 값이 한글로 관측된 전례도 있다 (#591). + +**남는 위험**: 비-nullable 인 일기 항목의 `todayMood` 는 이슈 지시대로 non-null 이라, +모르는 값이 오면 **그 달 목록 전체**가 실패한다. 사용자가 3종 중 고르는 값이고 실서버가 +`"SOSO"` 형태로 내려주는 것은 확인했지만, 서버가 종류를 늘리면 여기서 먼저 깨진다. + +**뒤집을 때**: `DiaryListItemDto.todayMood` / `ReceiverDiaryItemDto.todayMood` 도 +nullable 로 낮추고 같은 직렬화기를 달면 된다 (도메인 `Diary.todayMood` 까지 전파). + +## #729 — 과거 주간리포트를 어디까지 제공할지 + +**정한 것**: 이번 주 포함 **52주(1년)**. + +**근거가 없는 이유**: 이슈는 "고정 상수 5개가 아니라 제품에서 허용하는 과거 주간리포트 +범위" 라고만 적었고, 그 범위가 얼마인지는 명세·시안 어디에도 없다. 시안의 열린 메뉴는 +가시 항목 5개와 스크롤 표시만 보여줄 뿐 전체 개수를 알려주지 않는다. + +**판단 근거**: 계정 생성 주까지로 좁히는 쪽이 가장 정확하지만 서버에 가입일이 없다 +(`GET /users/me` 는 이름·이메일·전화·프로필만 준다). 기록이 없는 주를 골라도 +`GET /mind-record` 는 0 건으로 정상 응답하므로 빈 주차를 보여주는 것 자체는 안전하고, +1년이 "지난 리포트를 돌아본다" 는 화면 목적에 무난한 상한이라고 봤다. + +**뒤집을 때**: `WeeklyReportWeekOptions.kt` 의 `WEEK_OPTION_COUNT` 하나만 바꾸면 된다. +가입일 API 가 생기면 `buildWeekOptions(today, count)` 의 `count` 를 가입 주까지의 주 수로 +계산해 넘기면 되고, 메뉴 높이(가시 항목 수)는 이 값과 무관하게 유지된다. + +## #725 — 부분 실패를 어떤 상태로 볼지 / 폴링 간격 + +**정한 것 1**: `pending == 0 && succeeded > 0 && failed > 0`(일부만 실패)은 +**완료**로 본다 — 건진 키워드를 그대로 보여준다. + +**근거가 없는 이유**: 이슈는 "분석 대기, 분석 실패, 분석 완료, 실제 분석 대상 없음" +네 상태만 요구하고, 부분 실패가 어느 쪽인지는 정하지 않았다. + +**판단 근거**: 실패 화면으로 덮으면 이미 성공한 키워드까지 가린다. "아무것도 건지지 +못한 경우"만 실패로 두는 편이 손실이 적다고 봤다. + +**뒤집을 때**: `EmotionAnalysis.status` 의 `succeeded == 0 && failed > 0` 조건 하나다. +부분 실패를 따로 표시하려면 `PARTIALLY_FAILED` 를 추가하고 카드에 분기를 하나 더 둔다. + +**정한 것 2**: 분석 완료를 기다리는 재조회는 **8초 × 8회(약 1분)** 로 끊는다. + +**근거가 없는 이유**: 분석 완료를 알려주는 채널(푸시·웹소켓·완료 플래그)이 없고, +분석이 보통 얼마나 걸리는지도 명세에 없다. 실측(2026-08-23)에서는 일기 1건이 +1분 넘게 `pending` 이었고 그 세션에서는 끝나지 않았다. + +**판단 근거**: 무한 폴링은 하지 않는다는 것만 확실했다. 화면에 머무는 동안 한 번은 +잡을 만한 길이로 1분을 뒀고, 그 뒤는 화면 이탈·복귀(`refreshOnReturn`)와 사용자의 +재시도 버튼에 맡긴다. + +**뒤집을 때**: `WeeklyReportViewModel` 의 `EMOTION_ANALYSIS_POLL_ATTEMPTS` / +`EMOTION_ANALYSIS_POLL_INTERVAL_MILLIS` 두 상수다. 서버가 완료 신호를 주게 되면 +(Afternote-BE#117) `awaitEmotionAnalysis` 를 그 구독으로 갈아끼우면 된다. + +**남는 것**: FE 의 "다시 시도" 는 **재조회**일 뿐 서버 측 재분석 트리거가 아니다. +재시도 소진(`failed`) 상태에서 다시 분석을 걸 방법은 아직 없다 — Afternote-BE#117 소관. + +## #547 — 주간리포트 날짜 파싱 실패 처리 + +**정한 것**: 해석하지 못한 날짜의 항목은 **DTO→도메인 경계에서 제외**한다. +도메인 `WeeklyReportDailyQuestion.date` 는 non-null `LocalDate` 로 올린다. + +**근거가 없는 이유**: 이슈가 "실패 처리 정책(제외 / nullable 로 노출 후 UI 에서 날짜 +비우기 등)을 한 곳에서 정한다" 라고만 적고 어느 쪽인지 고르지 않았다. + +**판단 근거**: 같은 모듈이 이미 "제외" 로 수렴하고 있다 — `Diary.toUi()`, +`DailyQuestion.toUi()`(#751) 가 날짜를 못 정하면 항목을 뺀다. 새 정책을 하나 더 +만들기보다 갈라진 쪽을 맞추는 편이 낫다고 봤다. 종전의 `LocalDate.now()` 폴백은 +파싱 못 한 기록을 **오늘 작성한 것처럼** 보여 이상을 감추므로 어느 쪽으로 가든 폐기 대상이다. + +**남는 위험**: 날짜만 깨진 항목의 제목·본문까지 함께 사라진다. 실서버는 늘 +`yyyy.MM.dd 요일` 로 내려주고 세 포맷을 모두 허용하므로 실제로 걸릴 일은 적지만, +서버가 포맷을 바꾸면 HISTORY 가 통째로 비는 방향으로 실패한다. 매퍼가 로그를 남긴다. + +**뒤집을 때**: `WeeklyReportDailyQuestion.date` 를 `LocalDate?` 로 낮추고 +`toDomainOrNull()` 을 `toDomain()` 으로 되돌린 뒤, 카드가 날짜 자리를 비우게 한다. +집계는 이미 `mapNotNull` 이라 그대로 두면 된다. + +## #618 · #614 — 수신자 화면의 구성과 문구 + +**정한 것 1 (#618)**: 받은 기록 본문을 **별도 라우트가 아니라 바텀시트**로 연다. +구성은 날짜·기분 → 제목 → 본문 순. + +**근거가 없는 이유**: 이슈가 시안 미대조다. 수신자 기록 상세 화면의 시안이 있는지 +확인하지 못했다. + +**판단 근거**: 이 화면에는 뒤로가기가 없어(#614) 새 화면을 쌓으면 시스템 백키 외에 +나올 방법이 없다. 시트는 스와이프·바깥 탭으로 닫힌다. 같은 모듈의 `MemoryDetailOverlay` +가 이미 쓰는 방식이기도 하다. + +**뒤집을 때**: `ReceiverRecordDetailSheet` 한 컴포넌트만 고치면 된다. 라우트로 옮기려면 +`MindRecordRoute` 에 항목 ID 를 받는 라우트를 더하고 화면에서 시트 호출을 네비게이션으로 +바꾼다. + +**정한 것 2 (#614)**: 전달 조건 미충족(403 / code 2009) 문구를 +«아직 열람할 수 있는 기록이 없어요. 작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요.» +로 적었다. + +**근거가 없는 이유**: 이슈가 «대체 문구를 확정할 때 디자이너 확인이 선행돼야 한다» 고 +명시했다. 확인을 받지 못했다. + +**판단 근거**: 원문("아직 전달 조건이 충족되지 않았습니다")은 사용자가 무엇을 기다리는지 +알 수 없다. 수신자가 할 수 있는 일이 없는 상태이므로 **기다리는 상태임과 누가 정하는지**를 +알리는 쪽으로 적었다. + +**뒤집을 때**: `mindrecord_receiver_delivery_not_ready` 문자열 하나다. + +**남는 것**: 두 이슈 모두 **실기기 종단 확인을 하지 못했다.** 수신자 기록 화면은 +열람 승인(`delivery-verification` APPROVED)된 계정이 있어야 열리는데, 승인은 관리자만 +할 수 있고 수신자·발신자 API 어디에도 승인 경로가 없다. 403 응답 자체는 재현했고 +(수신자 생성 → `X-Auth-Code` 조회 → `403 {"code":2009}`), 화면 동작은 Robolectric +Compose 테스트로 고정했다. + +## #207 — 깊은 생각 제거 후 홈 요약 그리드 레이아웃 + +**정한 것**: `WeeklySummaryGrid` 의 «최근 깊은생각» 카드를 없애고, 오른쪽에 남은 +«이번 주 기록 수» 카드가 **왼쪽 큰 카드와 같은 높이**를 채우게 했다 (3장 → 2장). + +**근거가 없는 이유**: 깊은 생각 제거는 기획 결정으로 전달받았지만, 카드가 빠진 뒤의 +그리드 시안은 받지 못했다. + +**판단 근거**: 오른쪽 컬럼은 «작은 카드 2장 + 간격» 이 왼쪽 큰 카드 높이와 같도록 +짜여 있었다. 한 장만 남기고 아래를 비우면 그리드가 어긋나므로, 남은 한 장을 늘려 +2열 구성을 유지하는 쪽을 택했다. + +**뒤집을 때**: `WeeklySummaryGrid` 한 파일이다. 카드를 다른 것으로 채우기로 하면 +오른쪽 컬럼을 다시 `smallSquareSize` 두 장으로 되돌리면 된다. + +**함께 정리한 것**: 서버는 `deepThoughtAmount`·`week[].type=DEEP_THOUGHT` 를 계속 +내려준다. **DTO 에 선언하지 않아 그대로 무시**하고(#207), 기록일수 집계에서도 +제외한다(#590). 서버가 나중에 필드를 빼도 앱은 영향받지 않는다. + +## #759 데일리 질문·일기 상세 화면 — 시안에 없는 값들 + +시안 4종에 나타나지 않아 판단으로 정한 것들. + +| 항목 | 정한 것 | 근거 | +|---|---|---| +| 헤더 배경 이미지의 출처 | 본문 HTML 의 **첫 `img`** | 서버가 대표 이미지를 따로 주지 않는다. 목록·상세 어느 응답에도 썸네일 필드가 없다. | +| 이미지가 여러 장일 때 | 첫 장만 헤더로, 나머지는 본문 순서대로 | 시안에 다중 이미지 변형이 없다. | +| 빈 `src` 의 `img` | 이미지 없음으로 취급 | 통과시키면 "이미지 O" 변형으로 그려진 뒤 영원히 비는 헤더가 된다. | +| 수신인이 여러 명일 때 | 이름을 이어 붙인다 | 시안은 한 명("박채연")만 보여 준다. | +| 수신인이 없을 때 | 줄 자체를 그리지 않는다 | 시안에 미지정 변형이 없다. "수신인" 라벨만 남는 편보다 낫다고 봤다. | +| 상세 진입 시 데이터 | 목록 API 를 다시 호출해 대상 하나를 고른다 | 단건 조회 API 가 없다(`GET /api/v1/diary`·`/daily-questions` 만 있음). 작성 화면 프리필과 같은 방식. | +| 조회 실패 | 상세 화면에 오류 문구 | 시안에 오류 변형이 없다. | + +**이슈 본문과 실측이 다른 부분** — Note 의 "'프로필 설정하러가기' 요소가 포함돼 있어" 는 +상세 4종(3814:18721·18813 확인)에서 찾을 수 없다. 하단 빈 44dp 는 nav 컴포넌트의 빈 +슬롯이다. 구현하지 않았고, PR #1006 에 남겼다. + +## #955 일기 수정 요청의 수신자 — "빈 선택" 의 뜻 + +서버는 `receiverIds` 를 세 가지로 읽는다(실측): 목록이 오면 그대로 반영, **생략하면 기존 +유지**, **빈 목록이면 전체 해제**. + +문제는 수정 화면 프리필이 기존 수신자를 불러오지 않는다는 것이다. 그래서 화면의 "고른 게 +없음" 이 "원래 없었음" 인지 "사용자가 다 뺐음" 인지 구분되지 않는다. + +**빈 선택은 생략(null)으로 보내기로 했다.** 빈 목록으로 보내면 수신자를 건드리지 않은 +편집이 기존 지정을 지우고, 목록 응답에 수신자가 없어 되살릴 수도 없다. 반대로 생략을 +고르면 잃는 것은 "전체 해제" 기능뿐이다 — 애초에 프리필이 없어 사용자가 표현할 수도 없던 +동작이다. 생성 경로(#933)와도 같은 규칙이 된다. + +프리필까지 붙으면(#759 가 목록 응답의 `receivers` 를 도메인에 싣는다) 빈 선택을 해제로 +해석하는 쪽으로 바꿀 수 있다. + +**분리한 것** — 일기의 기록 날짜는 생성·수정 어느 쪽으로도 서버에 보낼 수 없다(양쪽 +스키마에 `date` 없음, 보내도 무시됨을 실측). 날짜 선택이 표시 전용인 문제는 BE 계약 +확장이나 기획 결정이 필요해 #1008 로 분리했다. diff --git a/feature/afternote/data/build.gradle.kts b/feature/afternote/data/build.gradle.kts index d1d80bd367..47d6d40416 100644 --- a/feature/afternote/data/build.gradle.kts +++ b/feature/afternote/data/build.gradle.kts @@ -14,4 +14,7 @@ dependencies { implementation(projects.core.model) implementation(projects.feature.afternote.domain) implementation(projects.feature.receiver.domain) + + // LeaveMessageBlockContractTest 가 수신 상세 응답 샘플(receiver:data DTO)로 계약을 검증한다. + testImplementation(projects.feature.receiver.data) } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt index 4a6a71755a..b93becda86 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt @@ -1,16 +1,12 @@ package com.afternote.feature.afternote.data.di import com.afternote.feature.afternote.data.repositoryimpl.author.AfternoteRepositoryImpl -import com.afternote.feature.afternote.data.repositoryimpl.author.AuthorReceiverRepositoryImpl -import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialPhotoUploadRepositoryImpl +import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialMediaUploadRepositoryImpl import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialThumbnailUploadRepositoryImpl -import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialVideoUploadRepositoryImpl import com.afternote.feature.afternote.data.repositoryimpl.author.MusicSearchRepositoryImpl import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository -import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository import com.afternote.feature.afternote.domain.repository.author.MusicSearchRepository import dagger.Binds import dagger.Module @@ -26,11 +22,6 @@ interface AfternoteAuthorRepositoryModule { @Singleton fun bindAfternoteRepository(impl: AfternoteRepositoryImpl): AfternoteRepository - @Suppress("unused") - @Binds - @Singleton - fun bindAuthorReceiverRepository(impl: AuthorReceiverRepositoryImpl): AuthorReceiverRepository - @Suppress("unused") @Binds @Singleton @@ -44,10 +35,5 @@ interface AfternoteAuthorRepositoryModule { @Suppress("unused") @Binds @Singleton - fun bindMemorialVideoUploadRepository(impl: MemorialVideoUploadRepositoryImpl): MemorialVideoUploadRepository - - @Suppress("unused") - @Binds - @Singleton - fun bindMemorialPhotoUploadRepository(impl: MemorialPhotoUploadRepositoryImpl): MemorialPhotoUploadRepository + fun bindMemorialMediaUploadRepository(impl: MemorialMediaUploadRepositoryImpl): MemorialMediaUploadRepository } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteServiceModule.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteServiceModule.kt index 2f556b964c..6d7c04cb16 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteServiceModule.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteServiceModule.kt @@ -2,8 +2,6 @@ package com.afternote.feature.afternote.data.di import com.afternote.feature.afternote.data.service.AfternoteApiService import com.afternote.feature.afternote.data.service.MusicApiService -import com.afternote.feature.afternote.data.service.ReceiverAfternoteApiService -import com.afternote.feature.afternote.data.service.ReceiverAuthApiService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -22,12 +20,4 @@ object AfternoteServiceModule { @Provides @Singleton fun provideMusicApiService(retrofit: Retrofit): MusicApiService = retrofit.create() - - @Provides - @Singleton - fun provideReceiverAfternoteApiService(retrofit: Retrofit): ReceiverAfternoteApiService = retrofit.create() - - @Provides - @Singleton - fun provideReceiverAuthApiService(retrofit: Retrofit): ReceiverAuthApiService = retrofit.create() } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStoreModule.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStoreModule.kt deleted file mode 100644 index 45bf8a1c23..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStoreModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.afternote.feature.afternote.data.di - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import com.afternote.feature.afternote.data.local.identityVerificationPreferencesDataStore -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object IdentityVerificationDataStoreModule { - @Provides - @Singleton - @IdentityVerificationDataStore - fun provideIdentityVerificationDataStore( - @ApplicationContext context: Context, - ): DataStore = context.identityVerificationPreferencesDataStore -} diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStoreModule.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStoreModule.kt deleted file mode 100644 index 4ebff2b3e6..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStoreModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.afternote.feature.afternote.data.di - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import com.afternote.feature.afternote.data.local.receiverAuthCodePreferencesDataStore -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object ReceiverAuthCodeDataStoreModule { - @Provides - @Singleton - @ReceiverAuthCodeDataStore - fun provideReceiverAuthCodeDataStore( - @ApplicationContext context: Context, - ): DataStore = context.receiverAuthCodePreferencesDataStore -} diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/IdentityVerificationPreferencesDataStore.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/IdentityVerificationPreferencesDataStore.kt deleted file mode 100644 index ccc0fe3942..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/IdentityVerificationPreferencesDataStore.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.feature.afternote.data.local - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 본인 확인 완료 여부 영구 저장용 DataStore. - * 발신자별로 분리하지 않는 사람 단위 1회 검증 정책 — 단일 boolean 키. - */ -internal val Context.identityVerificationPreferencesDataStore: DataStore by preferencesDataStore( - name = "afternote_identity_verification", -) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodePreferencesDataStore.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodePreferencesDataStore.kt deleted file mode 100644 index 88497960e3..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodePreferencesDataStore.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.feature.afternote.data.local - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 수신 인증 코드용 Preferences DataStore. - * 기존 SharedPreferences XML(`afternote_receiver_auth`)과는 별도 파일이며 자동 마이그레이션은 하지 않습니다. - */ -internal val Context.receiverAuthCodePreferencesDataStore: DataStore by preferencesDataStore( - name = "afternote_receiver_auth", -) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt index 56c3d95760..66b03a4631 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt @@ -18,7 +18,7 @@ private const val LEGACY_MEMORIAL_CATEGORY = "MUSIC" internal fun AfternoteType.toServerCategory(): String? = serverCategoryByType[this] -internal fun afternoteTypeFromServerCategory(value: String): AfternoteType? { +fun afternoteTypeFromServerCategory(value: String): AfternoteType? { val normalized = value.uppercase() if (normalized == LEGACY_MEMORIAL_CATEGORY) return AfternoteType.MEMORIAL return serverCategoryByType.entries.firstOrNull { it.value == normalized }?.key diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt index 638c2105b4..7fcb36b5cf 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.domain.AfternoteType -internal fun formatDateFromServer(serverDateTime: String): String = +fun formatDateFromServer(serverDateTime: String): String = try { // Server returns "2025-11-26T14:30:00" -> display "2025.11.26" val datePart = serverDateTime.substringBefore('T') @@ -16,5 +16,4 @@ internal fun formatDateFromServer(serverDateTime: String): String = * * 값 표는 [afternoteTypeFromServerCategory] 가 정본이다 — 여기에 다시 적으면 갈라진다. */ -internal fun categoryToAfternoteType(category: String): AfternoteType = - afternoteTypeFromServerCategory(category) ?: AfternoteType.SOCIAL_NETWORK +fun categoryToAfternoteType(category: String): AfternoteType = afternoteTypeFromServerCategory(category) ?: AfternoteType.SOCIAL_NETWORK diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AuthorReceiverRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AuthorReceiverRepositoryImpl.kt deleted file mode 100644 index 9d54bf7d22..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AuthorReceiverRepositoryImpl.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.afternote.feature.afternote.data.repositoryimpl.author - -import com.afternote.feature.afternote.domain.model.author.AuthorReceiverEntry -import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import javax.inject.Inject -import javax.inject.Singleton - -/** - * 작성자 수신자 목록 SSOT. API 연동 시 [refreshReceivers]에서 원격을 받아 갱신합니다. - */ -@Singleton -class AuthorReceiverRepositoryImpl - @Inject - constructor() : AuthorReceiverRepository { - private val receivers = - MutableStateFlow>(emptyList()) - - override fun observeReceivers(): Flow> = receivers.asStateFlow() - - override fun currentReceivers(): List = receivers.value - - // TODO: AuthDataSource 등 사용자 정보 의존성 주입 후 실제 값으로 교체 - override fun currentAuthorUserId(): Long? = null - - override suspend fun refreshReceivers(): Result { - val userId = - currentAuthorUserId() ?: run { - clearReceivers() - return Result.failure(IllegalStateException("No current author user id")) - } - return fetchReceiversForUser(userId) - .onSuccess { entries -> receivers.update { entries } } - .map { } - } - - override suspend fun clearReceivers() { - receivers.update { emptyList() } - } - - private suspend fun fetchReceiversForUser(userId: Long): Result> = Result.success(emptyList()) - } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialMediaUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialMediaUploadRepositoryImpl.kt new file mode 100644 index 0000000000..85ac597210 --- /dev/null +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialMediaUploadRepositoryImpl.kt @@ -0,0 +1,47 @@ +package com.afternote.feature.afternote.data.repositoryimpl.author + +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.VideoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MediaInput +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import javax.inject.Inject + +/** + * 서버 S3 의 애프터노트 미디어 폴더명. presigned URL 경로에 박혀 `bucket/afternotes/` 형태가 됨. 서버와 약속된 문자열. + * + * 같은 패키지의 [MemorialThumbnailUploadRepositoryImpl] 가 이 값을 공유하므로 `internal`. + */ +internal const val DIRECTORY_AFTERNOTES = "afternotes" + +/** + * 추모 미디어 *상태 해석* + 필요 시 업로드. 업로드 자체(presigned 발급·S3 PUT·MIME 추론)는 + * [PhotoUploadRepository] · [VideoUploadRepository] 가 담당하고, 본 클래스는 분기 라우팅만 한다. + */ +class MemorialMediaUploadRepositoryImpl + @Inject + constructor( + private val photoUploadRepository: PhotoUploadRepository, + private val videoUploadRepository: VideoUploadRepository, + ) : MemorialMediaUploadRepository { + override suspend fun resolve( + input: MediaInput, + kind: MediaKind, + ): Result = + when (input) { + MediaInput.None -> { + Result.success(null) + } + + is MediaInput.Remote -> { + Result.success(input.url) + } + + is MediaInput.Local -> { + when (kind) { + MediaKind.PHOTO -> photoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES) + MediaKind.VIDEO -> videoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES) + } + } + } + } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialPhotoUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialPhotoUploadRepositoryImpl.kt deleted file mode 100644 index 37b72eb3f3..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialPhotoUploadRepositoryImpl.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.afternote.feature.afternote.data.repositoryimpl.author - -import com.afternote.core.domain.repository.PhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import javax.inject.Inject - -/** - * 서버 S3 의 애프터노트 미디어 폴더명. presigned URL 경로에 박혀 `bucket/afternotes/` 형태가 됨. 서버와 약속된 문자열. - * - * 같은 패키지의 [MemorialVideoUploadRepositoryImpl] · [MemorialThumbnailUploadRepositoryImpl] 가 이 값을 공유하므로 `internal`. - */ -internal const val DIRECTORY_AFTERNOTES = "afternotes" - -/** - * 영정 사진 *상태 해석* + 필요 시 업로드. 입력 [MediaInput] 의 sealed 분기: - * - [MediaInput.Local] → [PhotoUploadRepository] 업로드 후 [PhotoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [PhotoUploadOutcome.Existing] - * - [MediaInput.None] → [PhotoUploadOutcome.Empty] - * - * 픽 우선순위·로컬/원격 판별은 호출부가 [MediaInput] 을 구성할 때 끝낸다. - */ -class MemorialPhotoUploadRepositoryImpl - @Inject - constructor( - private val photoUploadRepository: PhotoUploadRepository, - ) : MemorialPhotoUploadRepository { - override suspend fun resolvePhoto(input: MediaInput): Result = - when (input) { - MediaInput.None -> { - Result.success(PhotoUploadOutcome.Empty) - } - - is MediaInput.Remote -> { - Result.success(PhotoUploadOutcome.Existing(input.url)) - } - - is MediaInput.Local -> { - photoUploadRepository - .upload(input.uri, DIRECTORY_AFTERNOTES) - .map { PhotoUploadOutcome.FreshlyUploaded(it) } - } - } - } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt index a3c6c4bab2..836ee4df64 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt @@ -15,7 +15,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import javax.inject.Inject import javax.inject.Named -// DIRECTORY_AFTERNOTES 는 같은 패키지 [MemorialPhotoUploadRepositoryImpl] 의 internal const 공유. +// DIRECTORY_AFTERNOTES 는 같은 패키지 [MemorialMediaUploadRepositoryImpl] 의 internal const 공유. private const val EXTENSION_JPG = "jpg" private const val CONTENT_TYPE_JPEG = "image/jpeg" @@ -37,6 +37,8 @@ class MemorialThumbnailUploadRepositoryImpl PresignedUrlRequestDto( directory = DIRECTORY_AFTERNOTES, extension = EXTENSION_JPG, + // 메모리에 이미 바이트가 있어 그대로 크기를 보낸다 (#950). + contentLength = jpegBytes.size.toLong(), ), ).requireData() diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialVideoUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialVideoUploadRepositoryImpl.kt deleted file mode 100644 index cfee51a238..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialVideoUploadRepositoryImpl.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.afternote.feature.afternote.data.repositoryimpl.author - -import com.afternote.core.domain.repository.VideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome -import javax.inject.Inject - -// DIRECTORY_AFTERNOTES 는 같은 패키지 [MemorialPhotoUploadRepositoryImpl] 의 internal const 공유. - -/** - * 추모 영상 *상태 해석* + 필요 시 업로드. 입력 [MediaInput] 의 sealed 분기로 반환: - * - [MediaInput.Local] → [VideoUploadRepository] 위임 후 [VideoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [VideoUploadOutcome.Existing] - * - [MediaInput.None] → [VideoUploadOutcome.Empty] - * - * 로컬/원격 판별은 호출부에서 [MediaInput] 으로 끝나 있다. S3 PUT·MIME 추론·temp 파일 관리는 - * [VideoUploadRepository] 가 담당. 본 클래스는 분기 라우팅만. - */ -class MemorialVideoUploadRepositoryImpl - @Inject - constructor( - private val videoUploadRepository: VideoUploadRepository, - ) : MemorialVideoUploadRepository { - override suspend fun resolveVideo(input: MediaInput): Result = - when (input) { - MediaInput.None -> { - Result.success(VideoUploadOutcome.Empty) - } - - is MediaInput.Remote -> { - Result.success(VideoUploadOutcome.Existing(input.url)) - } - - is MediaInput.Local -> { - videoUploadRepository - .upload(input.uri, DIRECTORY_AFTERNOTES) - .map { VideoUploadOutcome.FreshlyUploaded(it) } - } - } - } diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt index 8b20c437e3..8719d54aa7 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt @@ -3,11 +3,12 @@ package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.data.dto.AfternoteCreateAccountRequestDto import com.afternote.feature.afternote.data.dto.AfternoteDetailDto import com.afternote.feature.afternote.data.dto.LeaveMessageBlockDto -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteDetailDto -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteListDto import com.afternote.feature.afternote.data.mapper.response.toDetailDomain -import com.afternote.feature.afternote.data.mapper.response.toDomain import com.afternote.feature.afternote.domain.model.LeaveMessageBlock +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDetailDto +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto +import com.afternote.feature.receiver.data.mapper.response.toDomain +import com.afternote.feature.receiver.data.mapper.toReceiverDomainList import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/feature/afternote/domain/build.gradle.kts b/feature/afternote/domain/build.gradle.kts index edbfbd1739..462c1d3be2 100644 --- a/feature/afternote/domain/build.gradle.kts +++ b/feature/afternote/domain/build.gradle.kts @@ -7,7 +7,6 @@ android { } dependencies { implementation(projects.core.domain) - implementation(projects.feature.receiver.domain) implementation(libs.coroutines.core) implementation(libs.androidx.paging.common) } diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AuthorReceiverEntry.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AuthorReceiverEntry.kt deleted file mode 100644 index 1851f135ab..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AuthorReceiverEntry.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.afternote.feature.afternote.domain.model.author - -data class AuthorReceiverEntry( - val receiverId: Long, - val name: String, - val relation: String, - val phone: String = "", -) diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AuthorReceiverRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AuthorReceiverRepository.kt deleted file mode 100644 index 4b444d57e7..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AuthorReceiverRepository.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -import com.afternote.feature.afternote.domain.model.author.AuthorReceiverEntry -import kotlinx.coroutines.flow.Flow - -/** - * 작성자 본인의 userId 및 수신자 목록(이름/관계 조회용) 접근. - * - * 수신자 목록의 SSOT는 데이터 레이어 구현의 [observeReceivers] / [currentReceivers] 입니다. - * 로그아웃·계정 전환 시 [clearReceivers]로 비워 두면 UI 상태 오염을 막을 수 있습니다. - */ -interface AuthorReceiverRepository { - fun currentAuthorUserId(): Long? - - fun observeReceivers(): Flow> - - fun currentReceivers(): List - - suspend fun refreshReceivers(): Result - - suspend fun clearReceivers() -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt index bd2565b425..66fa12acca 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt @@ -5,7 +5,7 @@ package com.afternote.feature.afternote.domain.repository.author * * 진입 시점(presentation)에 로컬/원격을 *한 번* 확정해 resolve 경계로 넘긴다. 그러면 data 레이어가 * `content://` 같은 prefix 를 런타임에 문자열 비교하지 않고 [when] 분기(exhaustive)로 처리할 수 있다. - * 새 상태가 늘면 분기 누락이 컴파일 에러로 드러난다. (출력단 sealed `*UploadOutcome` #247 의 입력단 대칭.) + * 새 상태가 늘면 분기 누락이 컴파일 에러로 드러난다. */ sealed interface MediaInput { /** 사용자가 이번 세션에 새로 고른 로컬 파일(`content://` URI). 업로드 대상. */ diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaKind.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaKind.kt new file mode 100644 index 0000000000..b59d876a48 --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaKind.kt @@ -0,0 +1,7 @@ +package com.afternote.feature.afternote.domain.repository.author + +/** 추모 미디어의 종류. 업로드 위임 대상을 가른다. */ +enum class MediaKind { + PHOTO, + VIDEO, +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialMediaUploadRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialMediaUploadRepository.kt new file mode 100644 index 0000000000..520eaf4a16 --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialMediaUploadRepository.kt @@ -0,0 +1,17 @@ +package com.afternote.feature.afternote.domain.repository.author + +/** + * 추모 미디어(영정 사진·추모 영상)의 *상태 해석* + 필요 시 업로드. + * + * 로컬/원격 판별은 호출부가 [MediaInput] 을 구성할 때 끝내고, 여기서는 [when] 분기로만 처리한다. + * + * - [MediaInput.Local] → [kind] 에 맞는 업로드 후 그 결과 URL + * - [MediaInput.Remote] → 입력 URL 그대로 + * - [MediaInput.None] → null + */ +fun interface MemorialMediaUploadRepository { + suspend fun resolve( + input: MediaInput, + kind: MediaKind, + ): Result +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialPhotoUploadRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialPhotoUploadRepository.kt deleted file mode 100644 index ed0c3774d2..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialPhotoUploadRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 영정 사진의 *서버 자원 상태 해석* + 필요 시 업로드. - * - * 새로 고른 로컬 사진과 기존 원격 사진의 우선순위(픽 우선)는 *호출부* 가 [MediaInput] 을 구성할 때 정한다. - * Repository 는 확정된 [MediaInput] 을 [when] 분기로 처리한다. - * - * - [MediaInput.Local] → 업로드 → [PhotoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [PhotoUploadOutcome.Existing] - * - [MediaInput.None] → [PhotoUploadOutcome.Empty] - */ -fun interface MemorialPhotoUploadRepository { - suspend fun resolvePhoto(input: MediaInput): Result -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialVideoUploadRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialVideoUploadRepository.kt deleted file mode 100644 index 551e97e967..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialVideoUploadRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 추모(플레이리스트) 영상의 *서버 자원 상태 해석* + 필요 시 업로드. - * - * 입력 [MediaInput] 으로 로컬/원격이 이미 확정돼 들어오므로, Repository 는 `content://` prefix 를 - * 판별하지 않고 [when] 분기로 [VideoUploadOutcome] 를 만든다. - * - * - [MediaInput.Local] → S3 presigned upload → [VideoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [VideoUploadOutcome.Existing] - * - [MediaInput.None] → [VideoUploadOutcome.Empty] - */ -fun interface MemorialVideoUploadRepository { - suspend fun resolveVideo(input: MediaInput): Result -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/PhotoUploadOutcome.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/PhotoUploadOutcome.kt deleted file mode 100644 index 2854cab0ce..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/PhotoUploadOutcome.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 영정 사진의 *서버 자원 상태* 를 표현하는 sealed 타입. - * - * 영상과 달리 사진은 *기존 영구 URL* 과 *새로 고른 로컬 URI* 가 별도 인자로 들어오며, Repository 가 - * 둘을 함께 보고 결과 분기를 결정한다. - * - * - [Empty] — 사진 없음 - * - [Existing] — 기존 영구 URL 그대로 (사용자가 새로 안 고름) - * - [FreshlyUploaded] — 사용자가 새로 고른 로컬 URI 를 업로드한 결과 - */ -sealed interface PhotoUploadOutcome { - data object Empty : PhotoUploadOutcome - - data class Existing( - val url: String, - ) : PhotoUploadOutcome - - data class FreshlyUploaded( - val url: String, - ) : PhotoUploadOutcome -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/VideoUploadOutcome.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/VideoUploadOutcome.kt deleted file mode 100644 index 1148ef0cf9..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/VideoUploadOutcome.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 추모 영상의 *서버 자원 상태* 를 표현하는 sealed 타입. - * - * Repository 가 입력 String 을 보고 *형식을 직접 판별* 한 뒤 그 결과를 sealed 분기로 도메인에 전달한다. - * 도메인은 `"content://"`, `"X-Amz-"` 같은 인프라 디테일 문자열을 모르고 `when` 분기로만 처리. - * - * - [Empty] — 영상 없음 - * - [Existing] — 이미 서버가 알고 있는 자원 (입력 그대로 통과된 원격 URL). PATCH 시 페이로드에서 제거해 - * *변경 없음* 신호. - * - [FreshlyUploaded] — 방금 업로드해 새로 등록된 자원. POST/PATCH 페이로드에 그대로 보내야 서버가 - * 새 자원으로 등록. - */ -sealed interface VideoUploadOutcome { - data object Empty : VideoUploadOutcome - - data class Existing( - val url: String, - ) : VideoUploadOutcome - - data class FreshlyUploaded( - val url: String, - ) : VideoUploadOutcome -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt index f320f58511..2f1d6edfde 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt @@ -1,10 +1,8 @@ package com.afternote.feature.afternote.domain.usecase.editor import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import javax.inject.Inject class MemorialVideoSaveException( @@ -18,24 +16,14 @@ class MemorialPhotoSaveException( /** * 추모 영상·영정 사진의 *서버 저장 직전 정리* 도메인 로직. * - * 호출부가 로컬/원격을 확정한 [MediaInput] 을 받아 Repository 가 sealed [VideoUploadOutcome]/[PhotoUploadOutcome] - * 로 결과를 주고, 본 UseCase 는 그 sealed 분기를 저장 페이로드 규칙에 매핑한다. - * 도메인 본문이 `"content://"`, `"X-Amz-"` 같은 인프라 형식 디테일 문자열을 직접 비교하지 않는다. - * - * **POST/PATCH 동일 규칙** — 백엔드가 `S3Service.resolvePublicUrl(key)` 로 영구 public URL 을 발급하므로 - * 클라이언트가 GET 응답에서 받은 URL 을 그대로 PATCH 페이로드에 다시 보낼 수 있다. 따라서 PATCH 전용 분기 - * (`isUpdate`, presigned 마커 검사, `urlForUpdate`) 불필요 — #258 BE 확정 결과 반영. - * - * 각 Outcome 의 처리: - * - [VideoUploadOutcome.Empty] / [PhotoUploadOutcome.Empty] → null (미첨부) - * - [VideoUploadOutcome.Existing] / [PhotoUploadOutcome.Existing] → URL 그대로 전송 (영구 public URL) - * - [VideoUploadOutcome.FreshlyUploaded] / [PhotoUploadOutcome.FreshlyUploaded] → 업로드 결과 URL 전송 + * 호출부가 로컬/원격을 확정한 [MediaInput] 을 Repository 가 저장 페이로드에 실을 URL 로 해석해 주고, + * 본 UseCase 는 그 실패를 매체별 도메인 예외로 wrap 한다. 도메인 본문이 `"content://"` 같은 인프라 + * 형식 디테일 문자열을 직접 비교하지 않는다. */ class ResolveMemorialMediaForSaveUseCase @Inject constructor( - private val memorialVideoUploadRepository: MemorialVideoUploadRepository, - private val memorialPhotoUploadRepository: MemorialPhotoUploadRepository, + private val memorialMediaUploadRepository: MemorialMediaUploadRepository, ) { /** * @param video 영상 입력 — 호출부가 로컬/원격/없음을 확정한 [MediaInput]. @@ -45,37 +33,23 @@ class ResolveMemorialMediaForSaveUseCase video: MediaInput, photo: MediaInput, ): Result { - // 두 Repository 중 하나라도 실패 → 도메인 예외로 wrap 후 invoke 자체를 즉시 종료 + // 두 resolve 중 하나라도 실패 → 도메인 예외로 wrap 후 invoke 자체를 즉시 종료 // (getOrElse 람다 안의 `return` 은 non-local return — 함수 전체에서 빠져나감) - val videoOutcome = - memorialVideoUploadRepository - .resolveVideo(video) + val resolvedVideoUrl = + memorialMediaUploadRepository + .resolve(video, MediaKind.VIDEO) .getOrElse { return Result.failure(MemorialVideoSaveException(it)) } - val photoOutcome = - memorialPhotoUploadRepository - .resolvePhoto(photo) + val resolvedPhotoUrl = + memorialMediaUploadRepository + .resolve(photo, MediaKind.PHOTO) .getOrElse { return Result.failure(MemorialPhotoSaveException(it)) } return Result.success( ResolvedMemorialMediaForSave( - resolvedVideoUrl = videoOutcome.urlOrNull(), - resolvedMemorialPhotoUrl = photoOutcome.urlOrNull(), + resolvedVideoUrl = resolvedVideoUrl, + resolvedMemorialPhotoUrl = resolvedPhotoUrl, ), ) } - - private fun VideoUploadOutcome.urlOrNull(): String? = - when (this) { - VideoUploadOutcome.Empty -> null - is VideoUploadOutcome.Existing -> url - is VideoUploadOutcome.FreshlyUploaded -> url - } - - private fun PhotoUploadOutcome.urlOrNull(): String? = - when (this) { - PhotoUploadOutcome.Empty -> null - is PhotoUploadOutcome.Existing -> url - is PhotoUploadOutcome.FreshlyUploaded -> url - } } diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt index 306a07cf93..c2fddbb7bc 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt @@ -1,10 +1,8 @@ package com.afternote.feature.afternote.domain.usecase.editor import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -16,10 +14,9 @@ import org.junit.Test * [ResolveMemorialMediaForSaveUseCase] 비즈니스 로직 회귀 가드. * * 검증 핵심: - * 1. 입력 [MediaInput] 을 각 Repository 에 그대로 전달하는지 (로컬/원격 확정은 호출부 책임) - * 2. sealed [VideoUploadOutcome]/[PhotoUploadOutcome] 분기를 저장 페이로드 URL 로 매핑하는지 - * (Empty→null, Existing→url, FreshlyUploaded→url) - * 3. 영상 resolve 실패면 [MemorialVideoSaveException] 으로 wrap 하고 **사진 Repository 는 호출하지 않은 채** + * 1. 입력 [MediaInput] 과 [MediaKind] 를 Repository 에 그대로 전달하는지 (로컬/원격 확정은 호출부 책임) + * 2. 매체별 resolve 결과를 서로 뒤바꾸지 않고 각 필드에 담는지 (null 은 미첨부) + * 3. 영상 resolve 실패면 [MemorialVideoSaveException] 으로 wrap 하고 **사진은 resolve 하지 않은 채** * short-circuit 하는지 (non-local return) * 4. 사진 resolve 실패면 [MemorialPhotoSaveException] 으로 wrap 하고, 두 경우 모두 원본 예외를 cause 로 보존하는지 * @@ -27,13 +24,16 @@ import org.junit.Test */ class ResolveMemorialMediaForSaveUseCaseTest { @Test - fun `영상-사진 모두 성공 - FreshlyUploaded 영상과 Existing 사진을 각 URL 로 매핑`() { - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.FreshlyUploaded("v-url")) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.Existing("p-url")) } + fun `영상-사진 모두 성공 - 각 결과 URL 을 제 필드에 매핑`() { + val repo = + FakeMemorialMediaUploadRepository().apply { + videoResult = Result.success("v-url") + photoResult = Result.success("p-url") + } val result = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)( + ResolveMemorialMediaForSaveUseCase(repo)( video = MediaInput.Local("content://video"), photo = MediaInput.Remote("https://cdn/photo.jpg"), ) @@ -45,13 +45,12 @@ class ResolveMemorialMediaForSaveUseCaseTest { } @Test - fun `Empty Outcome 은 null 로 매핑 - 미첨부`() { - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Empty) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.Empty) } + fun `null 결과는 null 로 매핑 - 미첨부`() { + val repo = FakeMemorialMediaUploadRepository() val resolved = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.None, MediaInput.None) + ResolveMemorialMediaForSaveUseCase(repo)(MediaInput.None, MediaInput.None) }.getOrThrow() assertNull(resolved.resolvedVideoUrl) @@ -59,13 +58,16 @@ class ResolveMemorialMediaForSaveUseCaseTest { } @Test - fun `Existing 영상과 FreshlyUploaded 사진도 각 URL 로 매핑`() { - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Existing("existing-v")) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.FreshlyUploaded("fresh-p")) } + fun `원격 영상과 로컬 사진 조합도 각 URL 로 매핑`() { + val repo = + FakeMemorialMediaUploadRepository().apply { + videoResult = Result.success("existing-v") + photoResult = Result.success("fresh-p") + } val resolved = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)( + ResolveMemorialMediaForSaveUseCase(repo)( video = MediaInput.Remote("https://cdn/existing-v.mp4"), photo = MediaInput.Local("content://fresh-photo"), ) @@ -76,46 +78,43 @@ class ResolveMemorialMediaForSaveUseCaseTest { } @Test - fun `입력 MediaInput 을 각 Repository 에 그대로 전달`() { - val videoRepo = FakeVideoUploadRepository() - val photoRepo = FakePhotoUploadRepository() + fun `입력 MediaInput 과 MediaKind 를 Repository 에 그대로 전달`() { + val repo = FakeMemorialMediaUploadRepository() val videoInput = MediaInput.Local("content://fv") val photoInput = MediaInput.Remote("https://cdn/mp.jpg") runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(videoInput, photoInput) + ResolveMemorialMediaForSaveUseCase(repo)(videoInput, photoInput) } - assertEquals(videoInput, videoRepo.resolveVideoArg) - assertEquals(photoInput, photoRepo.resolvePhotoArg) + assertEquals(listOf(videoInput to MediaKind.VIDEO, photoInput to MediaKind.PHOTO), repo.calls) } @Test - fun `영상 resolve 실패면 MemorialVideoSaveException 으로 wrap 하고 사진 Repository 는 호출하지 않음`() { + fun `영상 resolve 실패면 MemorialVideoSaveException 으로 wrap 하고 사진은 resolve 하지 않음`() { val videoError = IllegalStateException("video resolve failed") - val videoRepo = FakeVideoUploadRepository().apply { result = Result.failure(videoError) } - val photoRepo = FakePhotoUploadRepository() + val repo = FakeMemorialMediaUploadRepository().apply { videoResult = Result.failure(videoError) } val result = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.Local("content://v"), MediaInput.None) + ResolveMemorialMediaForSaveUseCase(repo)(MediaInput.Local("content://v"), MediaInput.None) } assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is MemorialVideoSaveException) assertSame(videoError, result.exceptionOrNull()?.cause) // 원본 예외를 cause 로 보존 - assertEquals(0, photoRepo.callCount) // short-circuit — 사진 Repository 미호출 + // short-circuit — 사진 resolve 미호출 + assertEquals(0, repo.calls.count { it.second == MediaKind.PHOTO }) } @Test fun `사진 resolve 실패면 MemorialPhotoSaveException 으로 wrap`() { val photoError = IllegalStateException("photo resolve failed") - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Empty) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.failure(photoError) } + val repo = FakeMemorialMediaUploadRepository().apply { photoResult = Result.failure(photoError) } val result = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.None, MediaInput.Remote("https://cdn/p.jpg")) + ResolveMemorialMediaForSaveUseCase(repo)(MediaInput.None, MediaInput.Remote("https://cdn/p.jpg")) } assertTrue(result.isFailure) @@ -123,27 +122,20 @@ class ResolveMemorialMediaForSaveUseCaseTest { assertSame(photoError, result.exceptionOrNull()?.cause) } - private class FakeVideoUploadRepository : MemorialVideoUploadRepository { - var resolveVideoArg: MediaInput? = null - var callCount = 0 - var result: Result = Result.success(VideoUploadOutcome.Empty) - - override suspend fun resolveVideo(input: MediaInput): Result { - callCount++ - resolveVideoArg = input - return result - } - } - - private class FakePhotoUploadRepository : MemorialPhotoUploadRepository { - var resolvePhotoArg: MediaInput? = null - var callCount = 0 - var result: Result = Result.success(PhotoUploadOutcome.Empty) - - override suspend fun resolvePhoto(input: MediaInput): Result { - callCount++ - resolvePhotoArg = input - return result + private class FakeMemorialMediaUploadRepository : MemorialMediaUploadRepository { + val calls = mutableListOf>() + var videoResult: Result = Result.success(null) + var photoResult: Result = Result.success(null) + + override suspend fun resolve( + input: MediaInput, + kind: MediaKind, + ): Result { + calls += input to kind + return when (kind) { + MediaKind.VIDEO -> videoResult + MediaKind.PHOTO -> photoResult + } } } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt index 890477520d..43bb7afea9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt @@ -6,13 +6,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationKind -import com.afternote.feature.afternote.domain.model.author.AuthorReceiverEntry import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository -import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase @@ -194,7 +194,7 @@ class AfternoteEditorViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, - private val authorReceiverRepository: AuthorReceiverRepository, + private val userRepository: UserRepository, private val afternoteRepository: AfternoteRepository, private val memorialThumbnailUploadRepository: MemorialThumbnailUploadRepository, private val resolveMemorialMediaForSave: ResolveMemorialMediaForSaveUseCase, @@ -272,14 +272,6 @@ class AfternoteEditorViewModel ) = mutateForm { it.withProcessingMethodEdited(itemId = itemId, newText = newText) } init { - viewModelScope.launch { - authorReceiverRepository - .observeReceivers() - .map { it.toAfternoteEditorReceivers() } - .collect { mapped -> - internalState.update { it.copy(authorReceivers = mapped) } - } - } val editItemId = readEditItemId() if (editItemId == null) { savedStateHandle.remove(EDITOR_ORIGINAL_CATEGORY_FOR_API_KEY) @@ -307,9 +299,21 @@ class AfternoteEditorViewModel } } + /** + * 작성자가 등록한 수신자 전체를 받아 [InternalState.authorReceivers] 에 채운다. + * + * 신규 작성 진입 시 1회 호출된다. 폼이 비어 있으면 화면이 이 목록으로 수신자를 채우고 + * (`AfternoteNavGraphEditor` 의 `replaceReceiversIfEmpty`), 사용자는 불필요한 수신자를 지운다. + * 수정 진입은 상세 응답 prefill 이 지정 수신자를 채우므로 이 목록을 쓰지 않는다. + */ fun refreshAuthorReceivers() { viewModelScope.launch { - authorReceiverRepository.refreshReceivers() + runCatchingCancellable { userRepository.getReceivers() } + .onSuccess { receivers -> + internalState.update { it.copy(authorReceivers = receivers.toAfternoteEditorReceivers()) } + }.onFailure { e -> + errorReporter.recordAfternoteFailure(AfternoteFailureStage.AUTHOR_RECEIVER_LOAD, e) + } } } @@ -555,7 +559,8 @@ class AfternoteEditorViewModel } } - fun getReceiverById(id: Long): AuthorReceiverEntry? = authorReceiverRepository.currentReceivers().find { it.receiverId == id } + /** 수신자 선택 결과(id)를 폼에 넣기 위해 [refreshAuthorReceivers] 로 받아 둔 목록에서 이름·관계를 찾는다. */ + fun getReceiverById(id: Long): AfternoteEditorReceiver? = internalState.value.authorReceivers.find { it.id == id.toString() } private fun readOriginalCategoryForApiFromSavedState(): EditorCategory? = savedStateHandle diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/AuthorReceiverMapping.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/AuthorReceiverMapping.kt deleted file mode 100644 index 4a07922bbc..0000000000 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/AuthorReceiverMapping.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.afternote.feature.afternote.presentation.author.editor.mapper - -import com.afternote.feature.afternote.domain.model.author.AuthorReceiverEntry -import com.afternote.feature.afternote.presentation.author.editor.receiver.model.AfternoteEditorReceiver - -internal fun AuthorReceiverEntry.toAfternoteEditorReceiver(): AfternoteEditorReceiver = - AfternoteEditorReceiver( - id = receiverId.toString(), - name = name, - label = relation, - ) - -/** [AuthorReceiverEntry] 목록을 Edit 화면용 [AfternoteEditorReceiver] 목록으로 변환합니다. */ -internal fun Iterable.toAfternoteEditorReceivers(): List = - map { it.toAfternoteEditorReceiver() } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/EditorReceiverMapping.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/EditorReceiverMapping.kt new file mode 100644 index 0000000000..fa5b93c717 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/mapper/EditorReceiverMapping.kt @@ -0,0 +1,14 @@ +package com.afternote.feature.afternote.presentation.author.editor.mapper + +import com.afternote.core.model.user.Receiver +import com.afternote.feature.afternote.presentation.author.editor.receiver.model.AfternoteEditorReceiver + +internal fun Receiver.toAfternoteEditorReceiver(): AfternoteEditorReceiver = + AfternoteEditorReceiver( + id = receiverId.toString(), + name = name, + label = relation, + ) + +/** 사용자가 등록한 수신자([Receiver]) 목록을 에디터 표시 모델로 변환합니다. */ +internal fun List.toAfternoteEditorReceivers(): List = map { it.toAfternoteEditorReceiver() } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt index 8a829ed70d..6b98090658 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt @@ -44,7 +44,7 @@ import com.afternote.feature.afternote.presentation.author.navigation.model.Afte import kotlinx.coroutines.launch @Composable -internal fun DetailLoadingContent() { +fun DetailLoadingContent() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -54,7 +54,7 @@ internal fun DetailLoadingContent() { } @Composable -internal fun DesignPendingDetailContent(onBackClick: () -> Unit) { +fun DesignPendingDetailContent(onBackClick: () -> Unit) { Scaffold( modifier = Modifier.fillMaxSize(), containerColor = Color.Transparent, @@ -90,7 +90,7 @@ internal fun DesignPendingDetailContent(onBackClick: () -> Unit) { * 같은 요청을 다시 보내도 결과가 달라지지 않는 실패에 쓴다. */ @Composable -internal fun DetailLoadErrorContent( +fun DetailLoadErrorContent( @StringRes messageRes: Int?, onBackClick: () -> Unit, onRetryClick: (() -> Unit)? = null, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt index 7caa8ff649..724c6b7544 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt @@ -33,7 +33,7 @@ internal fun tryApplyReceiverSelectionFromSavedState( val id = backStackEntry.savedStateHandle[SELECTED_RECEIVER_ID_KEY] as? Long ?: return backStackEntry.savedStateHandle.remove(SELECTED_RECEIVER_ID_KEY) val receiver = viewModel.getReceiverById(id) ?: return - state.addReceiverById(receiver.receiverId, receiver.name, receiver.relation) + state.addReceiverById(id, receiver.name, receiver.label) } internal fun buildOnRegisterClick( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt index 0a46a4dde8..30074dca2f 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt @@ -1,7 +1,7 @@ package com.afternote.feature.afternote.presentation.reporting import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.error.ReceiverServerRejectionException +import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException /** * 애프터노트 흐름에서 실패가 발생한 지점. @@ -21,6 +21,15 @@ enum class AfternoteFailureStage( /** 수정 진입 시 기존 애프터노트 로드 — 여기서 깨지면 빈 폼으로 덮어쓸 위험이 있다. */ PREFILL_LOAD("prefill_load"), + /** + * 신규 작성 진입 시 작성자가 등록한 수신자 목록 조회. + * + * 실패해도 화면에는 수신자 자리가 비어 보일 뿐이라 계측하지 않으면 흔적이 남지 않는다. + * 수신자는 저장 필수값(서버 코드 475)이므로, 여기서 조용히 실패하면 사용자는 원인을 알 수 없는 + * 검증 오류만 만나게 된다. + */ + AUTHOR_RECEIVER_LOAD("author_receiver_load"), + MEMORIAL_THUMBNAIL_UPLOAD("memorial_thumbnail_upload"), /** @@ -68,11 +77,10 @@ enum class AfternoteFailureStage( /** 발신자 상세의 열람 인증 상태 조회. */ SENDER_STATUS_LOAD("sender_status_load"), - /** 모든 기록 내려받기 — 서버에서 묶음을 받아오는 단계. */ - RECEIVED_EXPORT_DOWNLOAD("received_export_download"), - - /** 모든 기록 내려받기 — 받아온 묶음을 기기에 저장하는 단계. */ - RECEIVED_EXPORT_SAVE("received_export_save"), + // RECEIVED_EXPORT_DOWNLOAD·RECEIVED_EXPORT_SAVE 는 수신자 흐름 이전(#615)과 함께 + // feature:receiver 의 ReceiverFailureStage(`receiver_stage` 키)로 이동 — home_stage 와의 + // 이중 기록(#546 참고)이 그 키로 수렴된다. 위 수신자 단계들(RECEIVED_*·RECEIVER_* 등)의 + // 이동은 열람 실패 처리 통일(#611·#614 계열)과 함께 별도 판단. } /** diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/PlaylistSongList.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/PlaylistSongList.kt index eadea27613..96795caffe 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/PlaylistSongList.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/PlaylistSongList.kt @@ -33,7 +33,7 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis // region ── PlaylistSongList (list-level composable) ── -internal fun filterSongsByQuery( +fun filterSongsByQuery( songs: List, searchQuery: String, ): List { @@ -129,7 +129,7 @@ private fun rememberSongSearchFieldState( * view-only(수신자 열람)·selectable(노래 추가)이 주입한다. 관리 모드는 대신 "총 N곡" 헤더를 주입한다. */ @Composable -internal fun SongSearchSection( +fun SongSearchSection( searchQuery: String, onSearchQueryChange: (String) -> Unit, ) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/SongPlaylistScreenParts.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/SongPlaylistScreenParts.kt index 5990978f49..fc0c0235e9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/SongPlaylistScreenParts.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/song/SongPlaylistScreenParts.kt @@ -32,7 +32,7 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis * @param topBarActions [DetailTopBar] 우측 액션 슬롯 (예: 관리 화면의 편집 모드 토글 연필) */ @Composable -internal fun SongPlaylistScaffold( +fun SongPlaylistScaffold( title: String, onBackClick: () -> Unit, modifier: Modifier = Modifier, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt index 5424887bc6..ddfacffc91 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.presentation.reporting -import com.afternote.feature.afternote.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException -import com.afternote.feature.afternote.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException +import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/feature/home/presentation/build.gradle.kts b/feature/home/presentation/build.gradle.kts new file mode 100644 index 0000000000..a2d9dd51fa --- /dev/null +++ b/feature/home/presentation/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("afternote.android.library.compose") + id("afternote.android.hilt") + alias(libs.plugins.compose.screenshot) +} + +android { + namespace = "com.afternote.feature.home.presentation" + experimentalProperties["android.experimental.enableScreenshotTest"] = true +} + +dependencies { + implementation(projects.core.domain) + implementation(projects.core.model) + implementation(projects.core.common) + implementation(projects.core.ui) + implementation(projects.feature.mindrecord.domain) + implementation(projects.feature.mindrecord.presentation) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + + // HomeTabViewModel 경합 테스트 — 가상 시간으로 viewModelScope 요청 순서를 제어한다. + testImplementation(libs.coroutines.test) + + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/core/di/src/main/AndroidManifest.xml b/feature/home/presentation/src/main/AndroidManifest.xml similarity index 80% rename from core/di/src/main/AndroidManifest.xml rename to feature/home/presentation/src/main/AndroidManifest.xml index 44008a4332..e100076157 100644 --- a/core/di/src/main/AndroidManifest.xml +++ b/feature/home/presentation/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/HomeTabScreen.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt similarity index 99% rename from app/src/main/java/com/afternote/afternote_fe/screen/HomeTabScreen.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt index db2a7d6b59..e1b10b2cde 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/HomeTabScreen.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen +package com.afternote.feature.home.presentation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -23,7 +23,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.R import com.afternote.core.model.MindRecordCategory import com.afternote.core.ui.AfternoteOutlinedCard import com.afternote.core.ui.AfternoteSectionHeader diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/HomeTabViewModel.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabViewModel.kt similarity index 96% rename from app/src/main/java/com/afternote/afternote_fe/screen/HomeTabViewModel.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabViewModel.kt index f6ab3479ae..bf2abcc3ef 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/HomeTabViewModel.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabViewModel.kt @@ -1,15 +1,15 @@ -package com.afternote.afternote_fe.screen +package com.afternote.feature.home.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.afternote_fe.reporting.HomeFailureStage -import com.afternote.afternote_fe.reporting.recordHomeFailure -import com.afternote.afternote_fe.usecase.GetHomeSummaryUseCase import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserProfileRepository import com.afternote.core.model.HomeSummary import com.afternote.core.model.MindRecordCategory +import com.afternote.feature.home.presentation.reporting.HomeFailureStage +import com.afternote.feature.home.presentation.reporting.recordHomeFailure +import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/WeeklySummaryGrid.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt similarity index 99% rename from app/src/main/java/com/afternote/afternote_fe/screen/WeeklySummaryGrid.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt index 70cb2faf7d..8bb11cef7f 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/WeeklySummaryGrid.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen +package com.afternote.feature.home.presentation import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/reporting/HomeFailureReporting.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/reporting/HomeFailureReporting.kt new file mode 100644 index 0000000000..de1a0c7aaa --- /dev/null +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/reporting/HomeFailureReporting.kt @@ -0,0 +1,32 @@ +package com.afternote.feature.home.presentation.reporting + +import com.afternote.core.common.reporting.ErrorReporter + +/** + * 발신자 홈 화면에서 실패가 발생한 지점. + * + * [reportingName] 은 리포팅 콘솔의 필터 값이다. 바꾸면 그 시점 이후 기록이 기존 이슈 그룹과 + * 끊겨 추이를 잃으므로, 단계 자체가 사라지지 않는 한 유지한다. + * + * 수신자 홈 단계들은 화면과 함께 `feature:receiver:presentation` 의 ReceiverFailureReporting + * (`receiver_stage` 키)으로 분리됐다. + */ +enum class HomeFailureStage( + val reportingName: String, +) { + /** 발신자 홈 요약(이름·수신인 지정 여부·기록 수) 로드. */ + AUTHOR_SUMMARY_LOAD("author_summary_load"), +} + +/** 발신자 홈 흐름의 handled 실패를 공통 키 규격으로 기록한다. */ +fun ErrorReporter.recordHomeFailure( + stage: HomeFailureStage, + throwable: Throwable, +) { + recordFailure( + throwable = throwable, + attributes = mapOf(KEY_HOME_STAGE to stage.reportingName), + ) +} + +private const val KEY_HOME_STAGE = "home_stage" diff --git a/app/src/main/java/com/afternote/afternote_fe/usecase/GetHomeSummaryUseCase.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt similarity index 86% rename from app/src/main/java/com/afternote/afternote_fe/usecase/GetHomeSummaryUseCase.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt index cee0f3e745..f5951d4712 100644 --- a/app/src/main/java/com/afternote/afternote_fe/usecase/GetHomeSummaryUseCase.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.usecase +package com.afternote.feature.home.presentation.usecase import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository @@ -17,8 +17,10 @@ import javax.inject.Inject * 프로필/수신자 조회는 필수(실패 시 전체 실패), 일기 카운트·오늘의 질문은 보조(실패 시 * 0/null 폴백)로 처리해 보조 호출 한 건의 실패 때문에 홈 화면 자체가 깨지지 않도록 한다. * - * UseCase는 통상 `core:domain`에 위치하나, 이 조합은 `feature/mindrecord/domain`을 끌어와야 - * 하므로 backwards 의존을 피하기 위해 `app` 모듈에 둔다. + * UseCase는 통상 domain 레이어에 위치하나, 이 조합은 `feature/mindrecord/domain`과 + * `core:common`(runCatchingCancellable)을 함께 끌어와야 해서 domain 패키지에 두면 + * 레이어 가드(LayerDependencyKonsistTest, 비-core:model 코어 금지)에 걸린다. + * 홈 전용 조합 로직이므로 홈 presentation 모듈에 둔다. */ class GetHomeSummaryUseCase @Inject diff --git a/feature/home/presentation/src/main/res/values/strings.xml b/feature/home/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000000..2ac0693b4a --- /dev/null +++ b/feature/home/presentation/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + 안녕하세요,\n%1$s님 + 오늘도 당신의 하루를 차분히 기록해보세요. + AFTER NOTE NEXT STEP + 가족들의 \u2018주거래 은행\u2019 정보를\n입력하신 건 확인하셨나요? + 애프터노트 입력하러가기 + 정보를 불러오지 못했습니다. + 다시 시도 + diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTest.kt similarity index 97% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTest.kt index af82d2ae8b..51c269caf4 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen +package com.afternote.feature.home.presentation import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/WeeklySummaryGridScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt similarity index 91% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/WeeklySummaryGridScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt index 1c43306f52..5b4461d3dd 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/WeeklySummaryGridScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen +package com.afternote.feature.home.presentation import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png rename to feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png rename to feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png rename to feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png rename to feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png diff --git a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt similarity index 98% rename from app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt rename to feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt index e9e7c9de71..bc1e219bb8 100644 --- a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt @@ -1,6 +1,5 @@ -package com.afternote.afternote_fe.screen +package com.afternote.feature.home.presentation -import com.afternote.afternote_fe.usecase.GetHomeSummaryUseCase import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.UserProfileRepository import com.afternote.core.domain.repository.UserRepository @@ -13,6 +12,7 @@ import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload @@ -461,12 +461,12 @@ private class FakeDailyQuestionRepository : DailyQuestionRepository { draftOnly: Boolean?, ): Result> = unexpected("DailyQuestionRepository.getList") - override suspend fun create(payload: DailyQuestionCreatePayload): Result = unexpected("DailyQuestionRepository.create") + override suspend fun create(payload: DailyQuestionCreatePayload): Result = unexpected("DailyQuestionRepository.create") override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = unexpected("DailyQuestionRepository.update") + ): Result = unexpected("DailyQuestionRepository.update") override suspend fun delete(id: Long): Result = unexpected("DailyQuestionRepository.delete") } @@ -484,8 +484,6 @@ private class FakeUserProfileRepository : UserProfileRepository { override suspend fun saveUserName(name: String) { savedUserNames += name } - - override suspend fun clear() = unexpected("clear") } private class RecordingErrorReporter : ErrorReporter { diff --git a/feature/mindrecord/data/build.gradle.kts b/feature/mindrecord/data/build.gradle.kts index 0565949d80..bc0474a807 100644 --- a/feature/mindrecord/data/build.gradle.kts +++ b/feature/mindrecord/data/build.gradle.kts @@ -8,5 +8,11 @@ android { dependencies { implementation(projects.feature.mindrecord.domain) + // 취소를 다시 던지는 runCatchingCancellable — repository 가 CancellationException 을 + // Result.failure 로 삼키지 않게 한다 (#670). + implementation(projects.core.common) implementation(projects.core.network) + + // 취소 전파 회귀 테스트 — runTest 로 Job 취소 시점을 제어한다 (#670). + testImplementation(libs.coroutines.test) } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt index ad9b549a48..a2ac76ea39 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt @@ -1,6 +1,7 @@ package com.afternote.feature.mindrecord.data.api import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.mindrecord.data.dto.DailyQuestionAnswerResponseDto import com.afternote.feature.mindrecord.data.dto.DailyQuestionCreateRequestDto import com.afternote.feature.mindrecord.data.dto.DailyQuestionListItemDto import com.afternote.feature.mindrecord.data.dto.DailyQuestionUpdateRequestDto @@ -27,13 +28,13 @@ interface DailyQuestionApiService { @POST("daily-questions") suspend fun createDailyQuestion( @Body request: DailyQuestionCreateRequestDto, - ): BaseResponse + ): BaseResponse @PATCH("daily-questions/{userDailyQuestionId}") suspend fun updateDailyQuestion( @Path("userDailyQuestionId") userDailyQuestionId: Long, @Body request: DailyQuestionUpdateRequestDto, - ): BaseResponse + ): BaseResponse @DELETE("daily-questions/{userDailyQuestionId}") suspend fun deleteDailyQuestion( diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/MindRecordReceiverApiService.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/MindRecordReceiverApiService.kt index be879c6d0e..c820180a5e 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/MindRecordReceiverApiService.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/MindRecordReceiverApiService.kt @@ -10,7 +10,7 @@ import retrofit2.http.Query * 수신자용 마음의 기록 조회 API 2종. * * `X-Auth-Code` 헤더는 `/receiver-auth/` 경로 기준으로 - * [com.afternote.feature.afternote.data.network.ReceiverAuthInterceptor]가 자동 부착한다. + * [com.afternote.feature.receiver.data.network.ReceiverAuthInterceptor]가 자동 부착한다. */ interface MindRecordReceiverApiService { @GET("receiver-auth/daily-question") diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt index 6b48f61d73..b379db34d8 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt @@ -12,16 +12,22 @@ data class DailyQuestionCreateRequestDto( @SerialName("content") val content: String, @SerialName("isDraft") val isDraft: Boolean, @SerialName("questionId") val questionId: Long, - @SerialName("imageUrl") val imageUrl: String? = null, + // 응답/요청 계약에 없는 필드라 타입은 nullable 로 두되 기본값은 두지 않는다. + // 매퍼가 항상 명시적으로 넘기므로 생략이 조용히 통과할 자리가 없다 (#789). + @SerialName("imageUrl") val imageUrl: String?, ) +/** + * `PATCH /daily-questions/{id}` 요청. 선택 필드라 nullable 은 유지하되 기본값은 두지 않는다 — + * 모든 생성 인자를 명시해야 "보내지 않음" 과 "명시적 null" 이 호출부 코드에서 구분된다 (#789). + */ @Serializable data class DailyQuestionUpdateRequestDto( - @SerialName("content") val content: String? = null, - @SerialName("isDraft") val isDraft: Boolean? = null, - @SerialName("date") val date: String? = null, - @SerialName("questionId") val questionId: Long? = null, - @SerialName("imageUrl") val imageUrl: String? = null, + @SerialName("content") val content: String?, + @SerialName("isDraft") val isDraft: Boolean?, + @SerialName("date") val date: String?, + @SerialName("questionId") val questionId: Long?, + @SerialName("imageUrl") val imageUrl: String?, ) @Serializable @@ -35,12 +41,17 @@ data class DailyQuestionListItemDto( @SerialName("title") val title: String, @SerialName("content") val content: String, @SerialName("createdAt") val createdAt: String, + // Swagger `DailyQuestionListResponse` 응답 계약에 없는 필드 — 서버가 주기 시작하면 쓰이고, + // 아니면 계속 null. 계약에 없으므로 여기서만 기본값을 유지한다 (#789). @SerialName("imageUrl") val imageUrl: String? = null, // Swagger `DailyQuestionListResponse` 및 실서버 응답 모두 와이어 키는 `isDraft`. // 과거 QA logcat 에서 `draft` 로 관측된 적이 있어 대체 키도 함께 허용한다. + // + // 기본값 `false` 는 두지 않는다 — 서버가 항상 보내는 키라, 빠졌을 때 "임시저장 아님" 으로 + // 접히면 임시저장 글이 목록에 그대로 노출된다. 계약 누락은 파싱 실패로 드러나야 한다 (#789). @SerialName("isDraft") @JsonNames("draft") - val isDraft: Boolean = false, + val isDraft: Boolean, ) @Serializable @@ -54,12 +65,31 @@ data class TodayDailyQuestionDto( @SerialName("day") val day: Int, @SerialName("content") val content: String, // Swagger `DailyQuestionTodayResponse` 및 실서버 응답 모두 `isAnswered`/`isDraft`. - // 종전 `answered`/`draft` 로 잡혀 있어 필수 필드 누락(MissingFieldException)으로 - // getToday() 가 항상 실패했다 (#548). 과도기 대비로 구 키도 함께 받고 기본값을 둔다. + // 종전 DTO 가 `answered`/`draft` 로 잡혀 있어 필수 필드 누락(MissingFieldException)으로 + // getToday() 가 항상 실패했다 (#548). 과도기 대비로 구 키는 계속 함께 받는다. + // + // 기본값은 두지 않는다 — 두 키 다 서버가 항상 보내므로, 기본값이 있으면 이번 같은 키 + // 불일치가 다시 나도 "미답변·임시저장 아님" 인 정상 응답으로 조용히 통과한다 (#789). @SerialName("isAnswered") @JsonNames("answered") - val isAnswered: Boolean = false, + val isAnswered: Boolean, @SerialName("isDraft") @JsonNames("draft") - val isDraft: Boolean = false, + val isDraft: Boolean, +) + +/** + * 생성·수정 응답 (`DailyQuestionAnswerResponse` 실측, 2026-08-23). + * + * 종전에는 `BaseResponse` 으로 받아 방금 만든 답변의 식별자를 버렸다. 그러면 저장 + * 직후 그 레코드를 가리키려고 목록을 다시 조회해 추측으로 찾아야 한다 (#573). + * + * `userDailyQuestionId`("내 답변")와 요청의 `questionId`("질문")는 의미가 다르다. + * 관찰된 값이 같더라도 섞어 쓰면 안 된다. + */ +@Serializable +data class DailyQuestionAnswerResponseDto( + @SerialName("userDailyQuestionId") val userDailyQuestionId: Long, + @SerialName("content") val content: String = "", + @SerialName("isDraft") val isDraft: Boolean = false, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt index c996fab631..5826c340d8 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt @@ -25,8 +25,11 @@ data class DiaryCreateRequestDto( @SerialName("content") val content: String, @SerialName("isDraft") val isDraft: Boolean, @SerialName("todayMood") val todayMood: TodayMoodDto, - @SerialName("imageUrl") val imageUrl: String? = null, - @SerialName("receiverIds") val receiverIds: List? = null, + // 응답 계약에 없는 필드 — 타입만 nullable 로 두고 기본값은 두지 않는다 (#789). + @SerialName("imageUrl") val imageUrl: String?, + // 생성 API 에서 `null` 과 빈 목록은 모두 "수신자 없음" 으로 정규화되고, 작성 UI 는 항상 + // 목록을 갖고 있다. 빈 목록을 그대로 보내면 되므로 nullable 로 낮추지 않는다 (#789). + @SerialName("receiverIds") val receiverIds: List, ) @Serializable @@ -36,23 +39,20 @@ data class DiaryUpdateRequestDto( @SerialName("isDraft") val isDraft: Boolean, @SerialName("todayMood") val todayMood: TodayMoodDto, @SerialName("date") val date: String, - @SerialName("imageUrl") val imageUrl: String? = null, + @SerialName("imageUrl") val imageUrl: String?, ) /** - * `/diary` 목록 항목 (Swagger `DiaryResponse` 실측, 2026-08-03). + * `/diary` 목록 항목 (Swagger `DiaryResponse`, 실서버 응답 실측 2026-08-23). * - * 식별자를 뺀 나머지에 기본값을 두는 이유는 **실패의 폭**이다. 이 DTO 는 목록 원소라, - * 필드 하나가 비어 `MissingFieldException` 이 나면 그 항목이 아니라 **그 달 목록 전체**가 - * 날아간다. 반면 값이 없을 때 잃는 것은 카드 한 장의 표시 요소뿐이다. - * - * `todayMood` 를 nullable 로 둔 것도 같은 맥락이다. non-null 이면 클라가 모르는 기분 값 - * 하나에 목록이 통째로 사라진다 (`coerceInputValues` 는 기본값이 있어야 동작한다). - * 실제로 `emotion` 은 한글로 내려오는 중이다 (#591). + * 식별자를 뺀 나머지에도 기본값을 두지 않는다. 이 필드들은 서버가 항상 채워 보내는 값이라, + * 기본값이 있으면 응답 키 누락과 계약 변경이 파싱 실패가 아니라 **정상적인 빈 값**으로 바뀐다. + * 특히 `isDraft` 가 `false` 로 접히면 임시저장 일기가 목록에 노출되고, `date` 가 빈 문자열이 + * 되면 캘린더에서 기록이 통째로 사라진다 (#789). * * (문서의 `required` 목록은 근거로 쓰지 않는다 — springdoc 은 `@Schema(requiredMode)` 가 * 없으면 non-null 프로퍼티도 required 에 넣지 않아, 비어 있다는 사실이 서버가 그 필드를 - * 생략한다는 신호가 아니다. 응답 필드의 nullable 판단 기준 자체는 #676 소관.) + * 생략한다는 신호가 아니다. 판단은 실제 응답과 저장 컬럼 필수 여부를 기준으로 한다.) * * `id` 는 노션 명세("Diary 조회") 예시의 키다. Swagger 에는 없지만 두 문서가 갈려 있어 * 대체 키로 함께 받는다 — 실제 응답에 `id` 가 없어 충돌하지 않는다. @@ -62,8 +62,8 @@ data class DiaryListItemDto( @SerialName("diaryId") @JsonNames("id") val diaryId: Long, - @SerialName("title") val title: String = "", - @SerialName("content") val content: String = "", + @SerialName("title") val title: String, + @SerialName("content") val content: String, /** * 사용자가 고른 **일기의 날짜** (`format: date`, 예 `"2026-03-21"`). * @@ -71,21 +71,27 @@ data class DiaryListItemDto( * `createdAt` 은 레코드가 만들어진 시각이다. 캘린더에 찍어야 하는 쪽은 이 값이다. * 둘을 `@JsonNames` 로 한 프로퍼티에 묶으면 서버의 키 순서에 따라 값이 뒤바뀐다. */ - @SerialName("date") val date: String? = null, - @SerialName("createdAt") val createdAt: String = "", + @SerialName("date") val date: String, + @SerialName("createdAt") val createdAt: String, // Swagger `DiaryResponse` 에 없는 필드 — 서버가 주기 시작하면 쓰이고, 아니면 계속 null. @SerialName("imageUrl") val imageUrl: String? = null, - @SerialName("todayMood") val todayMood: TodayMoodDto? = null, + // 저장 컬럼이 필수라 응답도 항상 채워진다. AI 가 매기는 `emotion` 과 달리 사용자가 직접 + // 고른 값이고, 한글 값이 관측된 쪽도 `emotion` 이지 이 필드가 아니다 (#591, #789). + @SerialName("todayMood") val todayMood: TodayMoodDto, @SerialName("isDraft") @JsonNames("draft") - val isDraft: Boolean = false, + val isDraft: Boolean, ) // `/diary` 응답의 `data` 는 객체 — `diaries` 외에 조회 대상 달의 비-임시 다이어리 수 // (`monthDiaryCount`)와 최근 7일 최빈 기분(`weeklyDominantMood`)이 함께 내려옴. @Serializable data class DiaryListDto( - @SerialName("diaries") val diaries: List = emptyList(), - @SerialName("monthDiaryCount") val monthDiaryCount: Int = 0, - @SerialName("weeklyDominantMood") val weeklyDominantMood: TodayMoodDto? = null, + @SerialName("diaries") val diaries: List, + @SerialName("monthDiaryCount") val monthDiaryCount: Int, + // 그 주에 기록이 없으면 서버가 `null` 을 **명시적으로** 보낸다. 값이 없다는 뜻이 실제로 + // 있으므로 nullable 은 유지하되, 키 자체는 계약이라 기본값은 두지 않는다 (#789). + @SerialName("weeklyDominantMood") + @Serializable(with = NullableTodayMoodSerializer::class) + val weeklyDominantMood: TodayMoodDto?, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt index 01def56649..ef6e907932 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable /** `GET /receiver-auth/daily-question` 응답 (`data`). */ @Serializable data class ReceiverDailyQuestionListDto( - @SerialName("dailyQuestions") val dailyQuestions: List = emptyList(), + @SerialName("dailyQuestions") val dailyQuestions: List, ) @Serializable @@ -17,26 +17,35 @@ data class ReceiverDailyQuestionItemDto( @SerialName("content") val content: String, // "yyyy.MM.dd 요일" 형식. @SerialName("createdAt") val createdAt: String, + // 수신자 응답도 발신자와 같은 `DailyQuestionListResponse` 스키마를 재사용한다 — 계약에 + // 있는 값이라 기본값을 두지 않는다 (#956). + @SerialName("isDraft") val isDraft: Boolean, + // Swagger `DailyQuestionListResponse` 계약에 없는 필드 — 기본값을 유지한다 (#789). @SerialName("imageUrl") val imageUrl: String? = null, ) /** `GET /receiver-auth/diary` 응답 (`data`). */ @Serializable data class ReceiverDiaryListDto( - @SerialName("diaries") val diaries: List = emptyList(), + @SerialName("diaries") val diaries: List, ) +/** + * 수신자용 일기 항목. 발신자 목록과 **같은 `DiaryResponse` 계약**을 쓰므로 + * nullability 와 기본값도 [DiaryListItemDto] 와 동일하게 맞춘다 (#789). + */ @Serializable data class ReceiverDiaryItemDto( @SerialName("diaryId") val diaryId: Long, @SerialName("title") val title: String, @SerialName("content") val content: String, - @SerialName("isDraft") val isDraft: Boolean = false, + @SerialName("isDraft") val isDraft: Boolean, + // 응답 계약에 없는 필드 — 기본값을 유지한다. @SerialName("imageUrl") val imageUrl: String? = null, - @SerialName("todayMood") val todayMood: TodayMoodDto? = null, + @SerialName("todayMood") val todayMood: TodayMoodDto, // 작성일 (yyyy-MM-dd). - @SerialName("date") val date: String = "", + @SerialName("date") val date: String, // "yyyy.MM.dd 요일" 형식. - @SerialName("createdAt") val createdAt: String = "", - @SerialName("updatedAt") val updatedAt: String = "", + @SerialName("createdAt") val createdAt: String, + @SerialName("updatedAt") val updatedAt: String, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt new file mode 100644 index 0000000000..9960a734e1 --- /dev/null +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt @@ -0,0 +1,42 @@ +package com.afternote.feature.mindrecord.data.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * 값 자체가 조건부인 **nullable 기분 필드** 전용 직렬화기. + * + * `coerceInputValues` 는 생성자 기본값이 있어야 동작한다. #789 로 기본값을 걷어내면서 + * 이 필드들도 함께 엄격해졌는데, 그 결과 "키 누락" 뿐 아니라 "클라가 모르는 **값**" 까지 + * 파싱 실패가 됐다. 두 상황은 성격이 다르다. + * + * - 키 누락 → 계약이 바뀐 것. 실패해야 드러난다. + * - 모르는 값 → 서버가 기분 종류를 늘렸거나 표기가 바뀐 것. 그 주/그 달 목록 전체를 + * 날릴 일이 아니라 이모지 한 칸만 비우면 된다 (#591 에서 감정 값이 한글로 관측된 전례). + * + * 그래서 키의 존재는 계약으로 두고(`= null` 기본값 없음), 값만 관대하게 접는다. + */ +object NullableTodayMoodSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.afternote.mindrecord.NullableTodayMood", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): TodayMoodDto? { + if (!decoder.decodeNotNullMark()) { + return decoder.decodeNull() + } + val raw = decoder.decodeString() + return TodayMoodDto.entries.firstOrNull { it.name == raw } + } + + override fun serialize( + encoder: Encoder, + value: TodayMoodDto?, + ) { + if (value == null) encoder.encodeNull() else encoder.encodeString(value.name) + } +} diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt index 589cc4b443..6e9cb36337 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt @@ -3,15 +3,19 @@ package com.afternote.feature.mindrecord.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * `GET /mind-record` 응답 (`data`). 카운트·요약·목록 어느 것도 기본값을 두지 않는다 — + * 수치가 `0` 으로, 목록이 빈 목록으로 접히면 잘못된 주간 리포트가 정상 화면으로 보인다 (#789). + */ @Serializable data class WeeklyReportDto( - @SerialName("dailyQuestionAmount") val dailyQuestionAmount: Int = 0, - @SerialName("diaryAmount") val diaryAmount: Int = 0, - @SerialName("summaryText") val summaryText: String = "", - @SerialName("week") val week: List = emptyList(), + @SerialName("dailyQuestionAmount") val dailyQuestionAmount: Int, + @SerialName("diaryAmount") val diaryAmount: Int, + @SerialName("summaryText") val summaryText: String, + @SerialName("week") val week: List, // 서버 JSON 키가 kebab-case (`daily-question`). - @SerialName("daily-question") val dailyQuestions: List = emptyList(), - @SerialName("emotions") val emotions: List = emptyList(), + @SerialName("daily-question") val dailyQuestions: List, + @SerialName("emotions") val emotions: List, ) /** @@ -25,28 +29,31 @@ data class WeeklyReportDto( * 종전 DTO 는 존재하지 않는 `isDiary` 키를 읽어 항상 기본값 `false` 가 됐고, 그래서 모든 * 일기가 non-diary 로 접혀 캘린더 점과 기록일수에서 빠졌다. * - * 세 필드는 명세가 required 로 선언한 값이라 기본값을 두지 않는다 — 빠지면 실패해야 - * 계약 누락이 드러난다. 다만 [type] 은 `enum` 대신 `String` 으로 받는다. 서버가 종류를 - * 하나 더 추가해도(현재 3종) 그 주 전체가 죽지 않고 매퍼에서 "일기 아님" 으로 접히면 - * 되기 때문이다 — 값 집합은 닫혀 있지 않지만 키의 존재는 계약이다. + * 네 필드 모두 기본값을 두지 않는다 — 빠지면 실패해야 계약 누락이 드러난다. [emotion] 은 + * 값 자체가 조건부라 nullable 은 유지하지만, 서버가 키는 명시적으로 보내므로 기본값은 + * 없다 (#789). 다만 [type] 은 `enum` 대신 `String` 으로 받는다. 서버가 종류를 하나 더 + * 추가해도(현재 3종) 그 주 전체가 죽지 않고 매퍼에서 "일기 아님" 으로 접히면 되기 + * 때문이다 — 값 집합은 닫혀 있지 않지만 키의 존재는 계약이다. */ @Serializable data class WeeklyReportDayDto( @SerialName("diaryId") val diaryId: Long, @SerialName("day") val day: Int, @SerialName("type") val type: String, - @SerialName("emotion") val emotion: TodayMoodDto? = null, + @SerialName("emotion") + @Serializable(with = NullableTodayMoodSerializer::class) + val emotion: TodayMoodDto?, ) @Serializable data class WeeklyReportDailyQuestionDto( - @SerialName("title") val title: String = "", - @SerialName("content") val content: String = "", - @SerialName("date") val date: String = "", + @SerialName("title") val title: String, + @SerialName("content") val content: String, + @SerialName("date") val date: String, ) @Serializable data class WeeklyReportEmotionDto( - @SerialName("keyword") val keyword: String = "", - @SerialName("percentage") val percentage: Int = 0, + @SerialName("keyword") val keyword: String, + @SerialName("percentage") val percentage: Int, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt index 08bddf111f..46e89315dd 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt @@ -18,7 +18,7 @@ fun DiaryListItemDto.toDomain(): Diary = content = content, date = date, createdAt = createdAt, - todayMood = todayMood?.toDomain(), + todayMood = todayMood.toDomain(), imageUrl = imageUrl, isDraft = isDraft, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt index 6910cbf6e6..313bc002a9 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt @@ -15,8 +15,7 @@ fun ReceiverDailyQuestionItemDto.toDomain(): MindRecordSummary = title = title, content = content, recordDate = createdAt.toIsoDate(), - // 데일리질문 수신자 응답은 isDraft 미노출 — 서버가 draft 를 전달 대상에서 제외한다고 가정. - isDraft = false, + isDraft = isDraft, createdAt = createdAt, imageUrl = imageUrl, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt index 3174f02b22..95841dc007 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt @@ -22,6 +22,14 @@ fun WeeklyReportDto.toDomain(): WeeklyReport = /** `week[].type` 중 일기를 뜻하는 값. 나머지(`DAILY_QUESTION`·`DEEP_THOUGHT`·미래 종류)는 일기가 아니다. */ private const val WEEK_RECORD_TYPE_DIARY = "DIARY" +/** + * 기록일수에서 제외할 종류. + * + * 깊은 생각은 기획에서 제거됐다. 서버는 `week[]` 에 계속 실어 보내지만 앱은 이 기능을 + * 없는 것으로 다루므로 기록일수에도 세지 않는다 (#590). + */ +private val WEEK_RECORD_TYPES_NOT_COUNTED = setOf("DEEP_THOUGHT") + /** * 와이어의 `type` 문자열을 도메인 의미(`isDiary`)로 접는다. * @@ -33,6 +41,7 @@ fun WeeklyReportDayDto.toDomain(): WeeklyReportDay = diaryId = diaryId, day = day, isDiary = type.equals(WEEK_RECORD_TYPE_DIARY, ignoreCase = true), + countsAsRecord = WEEK_RECORD_TYPES_NOT_COUNTED.none { it.equals(type, ignoreCase = true) }, emotion = emotion?.toDomain(), ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt index ae1631c2b5..b0bee02a84 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.data.repositoryimpl +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus import com.afternote.feature.mindrecord.data.api.DailyQuestionApiService @@ -21,7 +22,7 @@ class DailyQuestionRepositoryImpl date: String?, draftOnly: Boolean?, ): Result> = - runCatching { + runCatchingCancellable { api .getDailyQuestions(date = date, draftOnly = draftOnly) .requireData() @@ -29,27 +30,31 @@ class DailyQuestionRepositoryImpl } override suspend fun getToday(): Result = - runCatching { + runCatchingCancellable { api.getTodayDailyQuestion().requireData().toDomain() } - override suspend fun create(payload: DailyQuestionCreatePayload): Result = - runCatching { - api.createDailyQuestion(payload.toRequest()).requireStatus() + override suspend fun create(payload: DailyQuestionCreatePayload): Result = + runCatchingCancellable { + api + .createDailyQuestion(payload.toRequest()) + .requireData() + .userDailyQuestionId } override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = - runCatching { + ): Result = + runCatchingCancellable { api .updateDailyQuestion(userDailyQuestionId = id, request = payload.toRequest()) - .requireStatus() + .requireData() + .userDailyQuestionId } override suspend fun delete(id: Long): Result = - runCatching { + runCatchingCancellable { api.deleteDailyQuestion(userDailyQuestionId = id).requireStatus() } } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt index 4d2935a844..e5f0eae266 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.data.repositoryimpl +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus import com.afternote.feature.mindrecord.data.api.DiaryApiService @@ -20,7 +21,7 @@ class DiaryRepositoryImpl yearMonth: String, draftOnly: Boolean?, ): Result = - runCatching { + runCatchingCancellable { api .getDiaries(yearMonth = yearMonth, draftOnly = draftOnly) .requireData() @@ -28,7 +29,7 @@ class DiaryRepositoryImpl } override suspend fun create(payload: DiaryCreatePayload): Result = - runCatching { + runCatchingCancellable { api.createDiary(payload.toRequest()).requireStatus() } @@ -36,12 +37,12 @@ class DiaryRepositoryImpl id: Long, payload: DiaryUpdatePayload, ): Result = - runCatching { + runCatchingCancellable { api.updateDiary(diaryId = id, request = payload.toRequest()).requireStatus() } override suspend fun delete(id: Long): Result = - runCatching { + runCatchingCancellable { api.deleteDiary(diaryId = id).requireStatus() } } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt index 21ca3c9bed..c671986eeb 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.data.repositoryimpl +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData import com.afternote.feature.mindrecord.data.api.MindRecordReceiverApiService import com.afternote.feature.mindrecord.data.mapper.toDomain @@ -15,7 +16,7 @@ class MindRecordReceiverRepositoryImpl private val api: MindRecordReceiverApiService, ) : MindRecordReceiverRepository { override suspend fun getAll(): Result = - runCatching { + runCatchingCancellable { coroutineScope { val dailyQuestionsDeferred = async { api.getReceiverDailyQuestions().requireData() } val diariesDeferred = async { api.getReceiverDiaries().requireData() } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/WeeklyReportRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/WeeklyReportRepositoryImpl.kt index c1cbf42ff5..5ef3b61106 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/WeeklyReportRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/WeeklyReportRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.data.repositoryimpl +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData import com.afternote.feature.mindrecord.data.api.WeeklyReportApiService import com.afternote.feature.mindrecord.data.mapper.toDomain @@ -13,7 +14,7 @@ class WeeklyReportRepositoryImpl private val api: WeeklyReportApiService, ) : WeeklyReportRepository { override suspend fun getWeeklyReport(date: String): Result = - runCatching { + runCatchingCancellable { api.getWeeklyReport(date = date).requireData().toDomain() } } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionAnswerResponseContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionAnswerResponseContractTest.kt new file mode 100644 index 0000000000..7671fb49c5 --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionAnswerResponseContractTest.kt @@ -0,0 +1,83 @@ +package com.afternote.feature.mindrecord.data.dto + +import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 생성·수정 응답의 `userDailyQuestionId` 계약을 고정한다 (#573). + * + * 종전에는 `BaseResponse` 으로 받아 이 값을 버렸다. 그러면 저장 직후 그 레코드를 + * 가리키려고 목록을 다시 조회해 추측으로 골라야 한다. + * + * 아래 본문은 2026-08-23 `afternote.kro.kr` 실측 응답 그대로다. + */ +class DailyQuestionAnswerResponseContractTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `생성 응답에서 내 답변 식별자를 읽는다`() { + val body = + """ + { + "status": 200, "code": 200, "message": "성공", + "data": { "userDailyQuestionId": 19, "content": "응답 계약 실측", + "isDraft": true, "receivers": [] } + } + """.trimIndent() + + val decoded = + json.decodeFromString( + BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()), + body, + ) + + assertEquals(19L, decoded.data!!.userDailyQuestionId) + assertEquals("응답 계약 실측", decoded.data!!.content) + assertEquals(true, decoded.data!!.isDraft) + } + + @Test + fun `수정 응답도 같은 스키마다`() { + val body = + """ + { + "status": 200, "code": 200, "message": "성공", + "data": { "userDailyQuestionId": 19, "content": "응답 계약 실측 수정", + "isDraft": true, "receivers": [] } + } + """.trimIndent() + + val decoded = + json.decodeFromString( + BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()), + body, + ) + + assertEquals(19L, decoded.data!!.userDailyQuestionId) + } + + @Test + fun `식별자가 없으면 실패로 드러난다`() { + // 기본값을 두지 않는다 — 이 값이 없으면 저장 직후 흐름이 성립하지 않으므로 + // 조용히 0 으로 메우지 않고 파싱에서 막는다. + val body = + """ + { + "status": 200, "code": 200, "message": "성공", + "data": { "content": "식별자 없음", "isDraft": true } + } + """.trimIndent() + + val failed = + runCatching { + json.decodeFromString( + BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()), + body, + ) + }.isFailure + + assertEquals(true, failed) + } +} diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt index 1736b084c7..ea8a79ec23 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt @@ -1,17 +1,20 @@ package com.afternote.feature.mindrecord.data.dto import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.MissingFieldException import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Test /** * `GET /diary` 응답 파싱 계약 가드 (Swagger `DiaryResponse` 실측, 2026-08-03). * - * 목록 항목의 필수 프로퍼티가 하나라도 비면 `MissingFieldException` 으로 그 달 일기가 - * 통째로 사라진다 — 실패의 폭이 항목 하나가 아니라 목록 전체라, 필드가 빠져도 살아남는지를 - * 여기서 고정한다. + * 서버가 항상 보내는 응답 키는 기본값으로 은폐하지 않는다 (#789). 키가 빠지면 + * `MissingFieldException` 으로 **실패해야** 계약 변경이 드러나므로, 그 실패를 여기서 고정한다. + * 반대로 클라가 모르는 기분 **값**은 그 칸만 비우고 목록은 살린다 — 키 누락(계약 변경)과 + * 값 확장(표기 변경)은 성격이 다르다. * * `date`(사용자가 고른 일기 날짜)와 `createdAt`(레코드 생성 시각)은 **별개 필드**다. * 한 프로퍼티에 묶으면 서버의 키 순서에 따라 값이 뒤바뀌므로 분리 상태를 함께 고정한다. @@ -73,8 +76,9 @@ class DiaryListContractTest { """ { "status": 200, "code": 200, - "data": { "diaries": [{ "id": 123, "date": "2026-03-21", - "title": "t", "content": "c", "todayMood": "SOSO", "isDraft": true }] } + "data": { "diaries": [{ "id": 123, "date": "2026-03-21", "createdAt": "2026.03.21 토", + "title": "t", "content": "c", "todayMood": "SOSO", "isDraft": true }], + "monthDiaryCount": 1, "weeklyDominantMood": null } } """.trimIndent() @@ -87,49 +91,102 @@ class DiaryListContractTest { } @Test - fun `date 가 없으면 null 로 남아 표시 단계가 createdAt 으로 폴백할 수 있다`() { + fun `date 가 빠지면 빈 문자열로 성공하지 않고 실패한다`() { + // 사용자가 고른 일기 날짜는 서버가 항상 채운다. 기본값으로 접히면 캘린더에서 + // 그 기록이 조용히 사라지므로, 누락은 파싱 실패로 드러나야 한다 (#789). val body = """ { "status": 200, "code": 200, - "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "data": { "monthDiaryCount": 1, "weeklyDominantMood": null, + "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "createdAt": "2026.03.21 토", "todayMood": "SAD", "isDraft": false }] } + } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } + } + + @Test + fun `isDraft 가 빠지면 임시저장 아님으로 접히지 않고 실패한다`() { + // `false` 로 접히면 임시저장 일기가 목록에 그대로 노출된다 (#789). + val body = + """ + { + "status": 200, "code": 200, + "data": { "monthDiaryCount": 1, "weeklyDominantMood": null, + "diaries": [{ "diaryId": 1, "title": "t", "content": "c", "date": "2026-03-21", "createdAt": "2026.03.21 토", "todayMood": "SAD" }] } } """.trimIndent() - val decoded = json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } + } - val diary = decoded.data!!.diaries.single() - assertNull(diary.date) - assertEquals("2026.03.21 토", diary.createdAt) + @Test + fun `todayMood 가 빠지면 실패한다`() { + // 저장 컬럼이 필수라 응답에도 항상 있다. 한글 값이 관측된 쪽은 AI 가 매기는 + // `emotion` 이지 사용자가 고르는 이 필드가 아니다 (#591, #789). + val body = + """ + { + "status": 200, "code": 200, + "data": { "monthDiaryCount": 1, "weeklyDominantMood": null, + "diaries": [{ "diaryId": 1, "title": "t", "content": "c", "date": "2026-03-21", + "createdAt": "2026-03-21", "isDraft": false }] } + } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } } @Test - fun `클라가 모르는 기분 값이 와도 그 항목만 기분이 비고 목록은 살아남는다`() { - // 명세 예시에 적힌 "SMILE" 은 클라 enum(HAPPY·SOSO·SAD)에 없다. + fun `그 주에 기록이 없으면 오는 명시적 null 최빈 기분은 정상 파싱된다`() { + // 실제로 `null` 이 오는 필드라 nullable 은 유지한다 — 다만 키 자체는 계약이다 (#789). val body = """ { "status": 200, "code": 200, - "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", - "createdAt": "2026-03-21", "todayMood": "SMILE" }] } + "data": { "monthDiaryCount": 0, "weeklyDominantMood": null, "diaries": [] } } """.trimIndent() val decoded = json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) - val diary = decoded.data!!.diaries.single() - assertEquals(1L, diary.diaryId) - assertNull(diary.todayMood) + assertNull(decoded.data!!.weeklyDominantMood) + assertEquals(emptyList(), decoded.data!!.diaries) } @Test - fun `diaries 키가 없으면 빈 목록으로 접힌다`() { - // 0 건과 "키 자체가 없음" 이 같은 결과로 수렴하는 지점 — 회귀 시 여기부터 의심한다. - val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }""" + fun `클라가 모르는 최빈 기분 값은 그 칸만 비우고 목록은 살아남는다`() { + // 값 확장은 계약 누락과 다르다 — 이모지 한 칸 대신 그 달 목록 전체를 날릴 일이 아니다. + val body = + """ + { + "status": 200, "code": 200, + "data": { "monthDiaryCount": 0, "weeklyDominantMood": "SMILE", "diaries": [] } + } + """.trimIndent() val decoded = json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) - assertEquals(emptyList(), decoded.data!!.diaries) + assertNull(decoded.data!!.weeklyDominantMood) + } + + @Test + fun `diaries 키가 없으면 빈 목록으로 접히지 않고 실패한다`() { + // 0 건은 `"diaries": []` 로 온다. 키 자체가 사라진 건 계약이 바뀐 것이라, + // 빈 목록으로 수렴시키면 "기록 없음" 화면과 구분되지 않는다 (#789). + val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }""" + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } } } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt new file mode 100644 index 0000000000..e12aea4f3c --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt @@ -0,0 +1,154 @@ +package com.afternote.feature.mindrecord.data.dto + +import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * DTO 기본값·nullable 정리 계약 가드 (#789). + * + * 서버가 항상 채우는 응답 키를 `false`·`0`·`""`·`emptyList()` 로 보정하면, 응답 키 누락과 + * 계약 변경이 파싱 실패가 아니라 **정상적인 빈 값**으로 바뀐다. 이 파일은 그 경계를 고정한다. + * + * - 키 누락 → `MissingFieldException` (계약이 바뀐 것) + * - 실제 의미가 있는 명시적 `null` → 정상 파싱 (값이 없는 상태) + * - 클라가 모르는 기분 **값** → 그 칸만 비움 (표기가 늘어난 것) + * + * Json 설정은 `NetworkModule.provideJson` 과 동일 (ignoreUnknownKeys + coerceInputValues). + */ +class MindRecordDtoContractTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + // ---------- 데일리질문 목록 ---------- + + @Test + fun `데일리질문 목록의 isDraft 가 빠지면 실패한다`() { + // `false` 로 접히면 임시저장 답변이 목록에 노출된다. + val body = + """ + { "status": 200, "code": 200, + "data": [{ "userDailyQuestionId": 1, "title": "t", "content": "c", + "createdAt": "2026.08.23 일" }] } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body) + } + } + + @Test + fun `계약에 없는 imageUrl 은 빠져도 파싱을 깨지 않는다`() { + // Swagger `DailyQuestionListResponse` 에 없는 필드 — 기본값을 유지하는 유일한 자리. + val body = + """ + { "status": 200, "code": 200, + "data": [{ "userDailyQuestionId": 1, "title": "t", "content": "c", + "createdAt": "2026.08.23 일", "isDraft": false }] } + """.trimIndent() + + val decoded = json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body) + + assertNull(decoded.data!!.single().imageUrl) + } + + // ---------- 수신자 목록 ---------- + + @Test + fun `수신자 일기 목록은 발신자와 같은 계약이라 키가 빠지면 실패한다`() { + // 두 API 모두 Swagger `DiaryResponse` 를 쓴다 — nullable 과 기본값도 같이 맞춘다. + val body = + """ + { "status": 200, "code": 200, + "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "isDraft": false, "createdAt": "2026.08.23 일", "updatedAt": "2026.08.23 일" }] } } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(ReceiverDiaryListDto.serializer()), body) + } + } + + @Test + fun `수신자 일기 목록 전체 필드는 정상 파싱된다`() { + val body = + """ + { "status": 200, "code": 200, + "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "isDraft": false, "todayMood": "HAPPY", "date": "2026-08-23", + "createdAt": "2026.08.23 일", "updatedAt": "2026.08.23 일" }] } } + """.trimIndent() + + val diary = + json + .decodeFromString(BaseResponse.serializer(ReceiverDiaryListDto.serializer()), body) + .data!! + .diaries + .single() + + assertEquals(TodayMoodDto.HAPPY, diary.todayMood) + assertEquals("2026-08-23", diary.date) + } + + @Test + fun `수신자 목록의 래퍼 키가 빠지면 빈 목록으로 접히지 않고 실패한다`() { + assertThrows(MissingFieldException::class.java) { + json.decodeFromString( + BaseResponse.serializer(ReceiverDailyQuestionListDto.serializer()), + """{ "status": 200, "code": 200, "data": { } }""", + ) + } + } + + // ---------- 요청 직렬화 ---------- + + @Test + fun `수신자를 고르지 않으면 빈 목록이 그대로 나간다`() { + // 생성 API 는 `null` 과 `[]` 를 모두 "수신자 없음" 으로 정규화한다. 작성 UI 가 항상 + // 목록을 갖고 있으므로 nullable 로 낮추지 않고 빈 목록을 보낸다 (#789). + val encoded = + json.encodeToString( + DiaryCreateRequestDto.serializer(), + DiaryCreateRequestDto( + title = "t", + content = "c", + isDraft = false, + todayMood = TodayMoodDto.SOSO, + imageUrl = null, + receiverIds = emptyList(), + ), + ) + + assertEquals(true, encoded.contains("\"receiverIds\":[]")) + } + + @Test + fun `PATCH 는 생략과 명시적 null 이 코드에서 구분된다`() { + // 선택 필드라 nullable 은 유지하되 기본값은 없다 — 호출부가 모든 인자를 적어야 한다. + val encoded = + json.encodeToString( + DailyQuestionUpdateRequestDto.serializer(), + DailyQuestionUpdateRequestDto( + content = "수정 본문", + isDraft = false, + date = null, + questionId = null, + imageUrl = null, + ), + ) + + assertEquals(true, encoded.contains("\"content\":\"수정 본문\"")) + assertEquals(true, encoded.contains("\"isDraft\":false")) + } + + private companion object { + val ListSerializerOf = kotlinx.serialization.builtins.ListSerializer(DailyQuestionListItemDto.serializer()) + } +} diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt index 921f39e71e..639ee859eb 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt @@ -82,4 +82,30 @@ class TodayDailyQuestionContractTest { assertEquals(true, dto.isAnswered) assertEquals(true, dto.isDraft) } + + @Test + fun `isAnswered 키가 빠지면 미답변으로 접히지 않고 실패한다`() { + // 기본값 `false` 를 두면 #548 같은 키 불일치가 다시 나도 "아직 답 안 함" 인 + // 정상 응답으로 조용히 통과한다 — 오늘의 질문 카드가 계속 미답변으로 보인다 (#789). + assertThrows(MissingFieldException::class.java) { + decode( + """ + { "status": 200, "code": 200, + "data": { "questionId": 32, "day": 13, "content": "q", "isDraft": false } } + """.trimIndent(), + ) + } + } + + @Test + fun `isDraft 키가 빠지면 실패한다`() { + assertThrows(MissingFieldException::class.java) { + decode( + """ + { "status": 200, "code": 200, + "data": { "questionId": 32, "day": 13, "content": "q", "isAnswered": false } } + """.trimIndent(), + ) + } + } } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt index 5f29bcc149..8008ae7544 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt @@ -33,10 +33,14 @@ class WeeklyReportContractTest { coerceInputValues = true } + // 카운트·요약·목록 키는 모두 계약이라 기본값이 없다 (#789) — 주(week) 형태만 바꿔가며 + // 보기 위해 나머지 필수 키는 여기서 채운다. private fun decodeWeek(weekJson: String): List { val body = """ - { "status": 200, "code": 200, "data": { "week": $weekJson } } + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "diaryAmount": 0, "summaryText": "", + "daily-question": [], "emotions": [], "week": $weekJson } } """.trimIndent() return json .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) @@ -59,8 +63,8 @@ class WeeklyReportContractTest { val week = decodeWeek( """ - [{ "diaryId": 1, "day": 1, "type": "DAILY_QUESTION" }, - { "diaryId": 2, "day": 2, "type": "DEEP_THOUGHT" }] + [{ "diaryId": 1, "day": 1, "type": "DAILY_QUESTION", "emotion": null }, + { "diaryId": 2, "day": 2, "type": "DEEP_THOUGHT", "emotion": null }] """.trimIndent(), ) @@ -70,7 +74,7 @@ class WeeklyReportContractTest { @Test fun `명세에 없는 새 종류가 와도 그 주가 죽지 않고 일기 아님으로 접힌다`() { // type 을 enum 으로 받으면 여기서 그 주 전체가 MissingFieldException 으로 날아간다. - val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "FUTURE_KIND" }]""") + val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "FUTURE_KIND", "emotion": null }]""") assertEquals(false, week.single().toDomain().isDiary) } @@ -79,7 +83,7 @@ class WeeklyReportContractTest { fun `type 키가 빠진 응답은 실패한다`() { // 명세가 required 로 선언한 값이라 조용히 기본값으로 성공시키지 않는다. assertThrows(MissingFieldException::class.java) { - decodeWeek("""[{ "diaryId": 1, "day": 1 }]""") + decodeWeek("""[{ "diaryId": 1, "day": 1, "emotion": null }]""") } } @@ -87,13 +91,14 @@ class WeeklyReportContractTest { fun `종전 isDiary 키만 온 형태는 더 이상 통과하지 않는다`() { // 회귀 방향 고정 — isDiary 로 되돌리면 이 테스트가 먼저 깨진다. assertThrows(MissingFieldException::class.java) { - decodeWeek("""[{ "diaryId": 1, "day": 1, "isDiary": true }]""") + decodeWeek("""[{ "diaryId": 1, "day": 1, "isDiary": true, "emotion": null }]""") } } @Test fun `클라가 모르는 기분 값은 그 원소의 기분만 비운다`() { - // 서버가 한글 감정을 내려주는 중이다 (#591). + // 서버가 한글 감정을 내려준 전례가 있다 (#591). #789 로 기본값을 걷어냈어도 + // **값** 확장까지 실패로 만들지는 않는다 — 이모지 한 칸 대신 그 주가 통째로 날아간다. val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "DIARY", "emotion": "슬픔" }]""") val day = week.single().toDomain() @@ -102,11 +107,49 @@ class WeeklyReportContractTest { } @Test - fun `week 키가 없으면 빈 목록으로 접힌다`() { - val body = """{ "status": 200, "code": 200, "data": { "summaryText": "" } }""" + fun `emotion 이 명시적 null 이면 정상 파싱된다`() { + // 일기가 아닌 기록에는 기분이 없다 — 실제 의미가 있는 null 이라 nullable 은 유지한다. + val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "DEEP_THOUGHT", "emotion": null }]""") - val decoded = json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + assertNull(week.single().emotion) + } + + @Test + fun `emotion 키 자체가 빠지면 실패한다`() { + // 값이 조건부인 것과 키가 사라진 것은 다르다 — 후자는 계약 변경이라 드러나야 한다 (#789). + assertThrows(MissingFieldException::class.java) { + decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "DIARY" }]""") + } + } + + @Test + fun `week 키가 없으면 빈 목록으로 접히지 않고 실패한다`() { + // 기록 0 건은 `"week": []` 로 온다. 키가 사라진 건 계약이 바뀐 것이라, + // 빈 목록으로 수렴시키면 "이번 주 기록 없음" 리포트와 구분되지 않는다 (#789). + val body = + """ + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "diaryAmount": 0, "summaryText": "", + "daily-question": [], "emotions": [] } } + """.trimIndent() - assertEquals(emptyList(), decoded.data!!.week) + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + } + } + + @Test + fun `카운트 키가 빠지면 0 으로 접히지 않고 실패한다`() { + // `0` 으로 접히면 잘못된 주간 리포트가 정상 화면으로 보인다 (#789). + val body = + """ + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "summaryText": "", + "daily-question": [], "emotions": [], "week": [] } } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + } } } diff --git a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/mapper/ReceiverMindRecordDraftTest.kt b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/mapper/ReceiverMindRecordDraftTest.kt new file mode 100644 index 0000000000..cd4cc40fc1 --- /dev/null +++ b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/mapper/ReceiverMindRecordDraftTest.kt @@ -0,0 +1,54 @@ +package com.afternote.feature.mindrecord.data.mapper + +import com.afternote.feature.mindrecord.data.dto.ReceiverDailyQuestionItemDto +import kotlinx.serialization.json.Json +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 수신자 데일리질문의 임시저장 방어 가드 (#956). + * + * 매퍼가 `isDraft` 를 파싱하지 않고 `false` 리터럴로 접고 있어서, 화면의 + * `filterNot { it.isDraft }` 가 **통과율 100% 인 무효 필터**였다. 서버가 draft 를 전달 + * 대상에서 빼기를 기대하는 것과, 빼지 않았을 때 막을 수단이 있는 것은 다른 문제다. + * + * 수신자 응답은 발신자와 같은 `DailyQuestionListResponse` 스키마를 재사용하므로 + * (Swagger 실측 2026-08-24: `[userDailyQuestionId, title, content, createdAt, isDraft, receivers]`) + * 계약에 있는 값을 그대로 싣는다. + */ +class ReceiverMindRecordDraftTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun item(isDraft: Boolean) = + json.decodeFromString( + """ + { + "userDailyQuestionId": 7, + "title": "질문", + "content": "답변", + "createdAt": "2026.08.24 월", + "isDraft": $isDraft, + "receivers": [] + } + """.trimIndent(), + ) + + @Test + fun `임시저장 답변은 임시저장으로 실린다`() { + assertTrue(item(isDraft = true).toDomain().isDraft) + } + + @Test + fun `정식 답변은 임시저장이 아니다`() { + assertFalse(item(isDraft = false).toDomain().isDraft) + } + + @Test + fun `화면의 draft 제외 필터가 실제로 걸러낸다`() { + // 종전에는 매퍼가 항상 false 를 넣어 이 필터가 아무것도 거르지 못했다. + val records = listOf(item(isDraft = true), item(isDraft = false)).map { it.toDomain() } + + assertTrue(records.filterNot { it.isDraft }.size == 1) + } +} diff --git a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordCancellationTest.kt b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordCancellationTest.kt new file mode 100644 index 0000000000..bae7e3fe8e --- /dev/null +++ b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordCancellationTest.kt @@ -0,0 +1,101 @@ +package com.afternote.feature.mindrecord.data.repositoryimpl + +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.mindrecord.data.api.DailyQuestionApiService +import com.afternote.feature.mindrecord.data.api.DiaryApiService +import com.afternote.feature.mindrecord.data.dto.DailyQuestionAnswerResponseDto +import com.afternote.feature.mindrecord.data.dto.DailyQuestionCreateRequestDto +import com.afternote.feature.mindrecord.data.dto.DailyQuestionListItemDto +import com.afternote.feature.mindrecord.data.dto.DailyQuestionUpdateRequestDto +import com.afternote.feature.mindrecord.data.dto.DiaryCreateRequestDto +import com.afternote.feature.mindrecord.data.dto.DiaryListDto +import com.afternote.feature.mindrecord.data.dto.DiaryUpdateRequestDto +import com.afternote.feature.mindrecord.data.dto.TodayDailyQuestionDto +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 코루틴 취소가 `Result.failure` 로 삼켜지지 않는지 고정한다 (#670). + * + * stdlib `runCatching` 은 `CancellationException` 까지 잡아 정상 반환으로 바꾼다. 그러면 + * 이미 취소된 코루틴에서 호출부의 `onFailure` 갈래(오류 상태 갱신·스낵바)가 실행된다. + * `core:common` 의 `runCatchingCancellable` 은 취소만 다시 던지므로 호출 지점에서 그대로 빠져나간다. + * + * 판정 기준은 "반환값이 대입되지 않는 것" 이다 — 취소된 Job 은 어느 쪽이든 `isCancelled` 라 + * 그것만으로는 두 동작을 구분할 수 없다. + */ +class MindRecordCancellationTest { + @Test + fun `일기 조회 취소는 Result 로 돌아오지 않는다`() = + runTest { + val repository = DiaryRepositoryImpl(NeverReturningDiaryApi()) + var returned: Result<*>? = null + + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + returned = repository.getList(yearMonth = "2026-08", draftOnly = null) + } + job.cancelAndJoin() + + assertTrue(job.isCancelled) + assertNull(returned) + } + + @Test + fun `데일리질문 조회 취소는 Result 로 돌아오지 않는다`() = + runTest { + val repository = DailyQuestionRepositoryImpl(NeverReturningDailyQuestionApi()) + var returned: Result<*>? = null + + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + returned = repository.getList() + } + job.cancelAndJoin() + + assertTrue(job.isCancelled) + assertNull(returned) + } +} + +/** 응답을 영영 주지 않는다 — 호출이 정지한 사이에 취소를 넣기 위한 fake. */ +private class NeverReturningDiaryApi : DiaryApiService { + override suspend fun getDiaries( + yearMonth: String, + draftOnly: Boolean?, + ): BaseResponse = awaitCancellation() + + override suspend fun createDiary(request: DiaryCreateRequestDto): BaseResponse = awaitCancellation() + + override suspend fun updateDiary( + diaryId: Long, + request: DiaryUpdateRequestDto, + ): BaseResponse = awaitCancellation() + + override suspend fun deleteDiary(diaryId: Long): BaseResponse = awaitCancellation() +} + +private class NeverReturningDailyQuestionApi : DailyQuestionApiService { + override suspend fun getDailyQuestions( + date: String?, + draftOnly: Boolean?, + ): BaseResponse> = awaitCancellation() + + override suspend fun getTodayDailyQuestion(): BaseResponse = awaitCancellation() + + override suspend fun createDailyQuestion(request: DailyQuestionCreateRequestDto): BaseResponse = + awaitCancellation() + + override suspend fun updateDailyQuestion( + userDailyQuestionId: Long, + request: DailyQuestionUpdateRequestDto, + ): BaseResponse = awaitCancellation() + + override suspend fun deleteDailyQuestion(userDailyQuestionId: Long): BaseResponse = awaitCancellation() +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt index 4b325fea07..9ac8f68e14 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt @@ -10,11 +10,11 @@ data class Diary( val diaryId: Long, val title: String, val content: String, - /** 사용자가 고른 일기 날짜. 서버가 주지 않으면 null 이라 [createdAt] 으로 폴백한다. */ - val date: String?, + /** 사용자가 고른 일기 날짜 (yyyy-MM-dd). 서버가 항상 채우는 값이다 (#789). */ + val date: String, val createdAt: String, - /** 서버가 기분을 주지 않았거나 클라가 모르는 값이면 null — 목록 표시에서 이모지만 생략한다. */ - val todayMood: TodayMood?, + /** 사용자가 직접 고른 오늘의 기분. 저장 컬럼이 필수라 응답에도 항상 있다 (#789). */ + val todayMood: TodayMood, val imageUrl: String? = null, val isDraft: Boolean = false, ) @@ -31,8 +31,8 @@ data class DiaryCreatePayload( val isDraft: Boolean, val todayMood: TodayMood, val imageUrl: String? = null, - /** 이 일기를 전달받을 수신자 ID 목록. 미선택 시 null (서버 기본 동작). */ - val receiverIds: List? = null, + /** 이 일기를 전달받을 수신자 ID 목록. 미선택 시 빈 목록 — 서버가 "수신자 없음" 으로 본다. */ + val receiverIds: List, ) data class DiaryUpdatePayload( diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt index 25eee759ef..6155d128a7 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt @@ -13,6 +13,13 @@ data class WeeklyReportDay( val diaryId: Long, val day: Int, val isDiary: Boolean, + /** + * 기록일수에 세는 종류인지 (#590). + * + * `week[]` 는 일기 외의 종류도 싣는다. 데일리질문은 세지만 **깊은 생각은 기획에서 + * 제거된 기능이라 세지 않는다** — 서버가 계속 내려줘도 무시한다. + */ + val countsAsRecord: Boolean, val emotion: TodayMood?, ) diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt index 51671c88d5..b001a7a158 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt @@ -19,12 +19,19 @@ interface DailyQuestionRepository { suspend fun getToday(): Result - suspend fun create(payload: DailyQuestionCreatePayload): Result + /** + * 답변 생성. + * + * 서버가 돌려주는 `userDailyQuestionId`("내 답변" 식별자)를 그대로 올린다 — 저장 직후 + * 그 레코드를 가리켜야 하는 흐름(이어쓰기·수정·삭제)이 목록을 다시 뒤지지 않게 한다 (#573). + */ + suspend fun create(payload: DailyQuestionCreatePayload): Result + /** 답변 수정. 생성과 같은 스키마로 응답하므로 식별자를 그대로 올린다 (#573). */ suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result + ): Result suspend fun delete(id: Long): Result } diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 76d0c5e121..9d593b9c83 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -7,6 +7,9 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" resourcePrefix = "mindrecord_" + + // Robolectric 이 컴파일된 리소스로 화면을 띄운다 (#729). + testOptions.unitTests.isIncludeAndroidResources = true } dependencies { @@ -16,7 +19,19 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.model) implementation(libs.coil.compose) - implementation(libs.compose.rich.editor) + // 쓰는 건 BasicRichTextEditor(foundation 계열)뿐인데, richeditor 가 딸려 보내는 Compose + // Multiplatform material3 1.11.0-alpha07 이 androidx material3 를 BOM 판 1.4.0 대신 + // 1.5.0-alpha17(alpha)로 끌어올린다(#973). 쓰지 않는 material3 갈래를 끊어 BOM 을 정본으로 되돌린다. + implementation(libs.compose.rich.editor) { + exclude(group = "org.jetbrains.compose.material3", module = "material3") + } testImplementation(libs.coroutines.test) + + // 컴파일된 리소스로 문구를 검증한다 — aapt2 의 앞뒤 공백 제거는 소스 XML 만 봐서는 잡히지 않는다 (#732). + // 열린 주차 메뉴의 스크롤·최하단 선택도 JVM 에서 그대로 재현한다 (#729). + // 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751). + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/BottomSheetCalendar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/BottomSheetCalendar.kt index f187055d6c..ed9170850a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/BottomSheetCalendar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/BottomSheetCalendar.kt @@ -15,7 +15,7 @@ fun BottomSheetCalendar( CoreBottomSheetCalendar( onDismiss = onDismiss, onDateSelect = onDateSelect, - title = stringResource(R.string.mindrecord_calendar_send_date_title), + title = stringResource(R.string.mindrecord_calendar_record_date_title), initialDate = initialDate, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt index 69713f2e23..f29d1b9c17 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt @@ -44,7 +44,8 @@ fun BottomToolbar( onLinkClick: () -> Unit = {}, onSaveDraftClick: () -> Unit = {}, onDraftCountClick: () -> Unit = {}, - draftCount: Int = 0, + /** 임시저장 개수. `null` 은 아직 모름(조회 중·실패) — 0 으로 단정하지 않는다. */ + draftCount: Int? = null, ) { Row( modifier = @@ -101,7 +102,9 @@ fun BottomToolbar( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = draftCount.toString(), + // 조회 전·실패에 0 을 보이면 "임시저장이 없다" 는 틀린 사실을 말하게 된다. + // 진입점(탭 영역)은 유지해야 해서 숨기는 대신 임시저장 목록의 미지정 표기와 같은 '–' 를 쓴다. + text = draftCount?.toString() ?: UNKNOWN_DRAFT_COUNT, style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray4, modifier = Modifier.clickable(onClick = onDraftCountClick), @@ -457,3 +460,6 @@ private data class HeaderTypeItem( val label: String, val style: TextStyle, ) + +/** 임시저장 개수를 아직 모를 때 자리 표시 — `DraftDateFormatter` 의 미지정 표기와 같은 문자다. */ +private const val UNKNOWN_DRAFT_COUNT = "\u2013" diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt index 35acf6cae3..90faeb36e7 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.icon.RightArrowIcon @@ -33,8 +34,29 @@ import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R +/** + * 홈 탭 MEMORIES 카드. + * + * 시안(홈_수신자지정완료 57:8186)의 질문·답변은 **예시값**이라 문자열 리소스에 그대로 넣어 + * 두면 기록이 0건인 사용자에게도 남의 답변처럼 보인다 (#559). 실제 기록을 받아 표시한다. + * + * **0건 상태는 아직 시안에 없다** (Figma 카드 노드 `109:15028` 에 해당 변형 없음). + * 임의 문구를 넣으면 미확정 상태가 제품 동작으로 굳으므로, 표시할 기록이 없으면 + * 질문·답변 줄을 아예 그리지 않는다. 디자인 확정 시 이 자리에 변형을 붙이면 된다. + * + * @param question 최근 기록의 제목(데일리질문이면 질문 원문). null 이면 표시할 기록이 없다. + * @param answer 그 기록의 본문 미리보기. + * @param onReadAgainClick "그날의 기록 다시 읽기" 목적지. **아직 확정되지 않았다** — Figma 의 + * 이 버튼에 프로토타입 연결이 없고 #559 에도 확정 답변이 없어, 기본값은 아무 데도 보내지 + * 않는다. 목적지가 정해지면 호출부에서 이 인자만 채우면 된다. + */ @Composable -fun MemoriesCard(modifier: Modifier = Modifier) { +fun MemoriesCard( + modifier: Modifier = Modifier, + question: String? = null, + answer: String? = null, + onReadAgainClick: () -> Unit = {}, +) { val cardShape = RoundedCornerShape(8.dp) val buttonShape = RoundedCornerShape(20.dp) @@ -74,19 +96,29 @@ fun MemoriesCard(modifier: Modifier = Modifier) { .fillMaxWidth() .padding(16.dp), ) { - Text( - text = stringResource(R.string.mindrecord_memories_card_question), - style = AfternoteDesign.typography.bodySmallR, - color = AfternoteDesign.colors.gray8, - ) - Spacer(Modifier.height(1.dp)) - Text( - text = stringResource(R.string.mindrecord_memories_card_answer), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray6, - ) + if (question != null) { + Text( + text = question, + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray8, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (answer != null) { + Spacer(Modifier.height(1.dp)) + Text( + text = answer, + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } - Spacer(modifier = Modifier.height(16.dp)) + if (question != null || answer != null) { + Spacer(modifier = Modifier.height(16.dp)) + } Row( modifier = @@ -94,7 +126,7 @@ fun MemoriesCard(modifier: Modifier = Modifier) { .border(width = 1.dp, color = AfternoteDesign.colors.gray3, shape = buttonShape) .clip(buttonShape) .background(color = AfternoteDesign.colors.gray2) - .clickable(role = Role.Button, onClick = {}) + .clickable(role = Role.Button, onClick = onReadAgainClick) .padding(horizontal = 17.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -115,9 +147,21 @@ fun MemoriesCard(modifier: Modifier = Modifier) { } } -@Preview(showBackground = true) +@Preview(showBackground = true, name = "기록 있음") @Composable private fun MemoriesCardPreview() { + AfternoteTheme { + MemoriesCard( + question = "내 인생에서 가장 소중했던 순간은?", + answer = "아이가 태어났을 때...", + ) + } +} + +/** 기록 0건 — 시안에 변형이 없어 질문·답변 줄 없이 이미지와 버튼만 남는다 (#559). */ +@Preview(showBackground = true, name = "기록 0건") +@Composable +private fun MemoriesCardEmptyPreview() { AfternoteTheme { MemoriesCard() } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyModelCalendar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyModelCalendar.kt index f4f9bcd670..f653d32214 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyModelCalendar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyModelCalendar.kt @@ -125,26 +125,6 @@ private fun DayCell( } } - is DayContent.EmojiWithDot -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = content.emoji, - fontSize = 20.sp, - ) - Spacer(modifier = Modifier.height(1.dp)) - Box( - modifier = - Modifier - .size(4.dp) - .clip(CircleShape) - .background(AfternoteDesign.colors.gray8), - ) - } - } - is DayContent.EmojiOnly -> { Text( text = content.emoji, @@ -173,7 +153,7 @@ private fun defaultPreviewDays(): List = DayItem(DayOfWeek.MONDAY, DayContent.NumberOnly(10)), DayItem(DayOfWeek.TUESDAY, DayContent.NumberWithDot(2)), DayItem(DayOfWeek.WEDNESDAY, DayContent.NumberOnly(12)), - DayItem(DayOfWeek.THURSDAY, DayContent.EmojiWithDot("😊"), DayBackground.Green), + DayItem(DayOfWeek.THURSDAY, DayContent.EmojiOnly("😊"), DayBackground.Green), DayItem(DayOfWeek.FRIDAY, DayContent.EmojiOnly("😊"), DayBackground.Green), DayItem(DayOfWeek.SATURDAY, DayContent.NumberWithDot(2)), DayItem(DayOfWeek.SUNDAY, DayContent.EmojiOnly("🥹"), DayBackground.Pink), diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt index 8b41609e64..fe1af8b9d1 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt @@ -10,6 +10,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu @@ -121,13 +122,18 @@ fun WeeklyReportReviewCard( ) } + // 시안(node 700-35071)의 열린 메뉴는 항목 5개 높이에서 잘리고 오른쪽에 세로 + // 스크롤 표시가 있다 — 가시 항목 수보다 선택지가 많다는 표현이다. DropdownMenu + // 의 내용은 이미 세로 스크롤이라, 높이만 묶으면 나머지가 스크롤로 넘어간다 (#729). DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, containerColor = Color.White, + modifier = Modifier.heightIn(max = WeekMenuMaxHeight), ) { weekOptions.forEach { option -> DropdownMenuItem( + modifier = Modifier.height(WeekMenuItemHeight), text = { Text( text = weekLabel(option.monday), @@ -183,6 +189,19 @@ fun WeeklyReportReviewCard( } } +/** + * 주차 메뉴 한 항목의 높이. Material3 `DropdownMenuItem` 의 기본 최소 높이와 같은 값을 + * 고정으로 못 박아, 가시 항목 수([WeekMenuMaxHeight])가 항목 내용에 따라 흔들리지 않게 한다. + */ +private val WeekMenuItemHeight = 48.dp + +/** + * 열린 메뉴의 최대 높이 = 항목 5개 + `DropdownMenu` 내용의 위아래 여백(각 8dp). + * + * 선택지가 5개를 넘으면 여기서 잘리고 나머지는 세로 스크롤로 넘어간다 (#729). + */ +private val WeekMenuMaxHeight = WeekMenuItemHeight * 5 + 16.dp + @Composable private fun weekLabel(monday: LocalDate): String = stringResource( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt index c7df678447..937e6d7cf6 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt @@ -59,7 +59,7 @@ fun WriteTextField( onValueChange: ((String) -> Unit)? = null, onSaveDraftClick: () -> Unit = {}, onDraftCountClick: () -> Unit = {}, - draftCount: Int = 0, + draftCount: Int? = null, /** * 갤러리에서 고른 이미지를 서버에 업로드하고 영구 URL 을 반환하는 업로더 * (`POST /files/presigned-url` → S3 PUT). null 이면 업로드 없이 로컬 URI 를 그대로 삽입한다. diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt index a3158c0865..a7144a585a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt @@ -29,7 +29,7 @@ import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryIte @Composable fun MemorySpaceCardField( memories: List, - onMemoryClick: (Int) -> Unit, + onMemoryClick: (Long) -> Unit, modifier: Modifier = Modifier, ) { var touchPosition by remember { mutableStateOf(null) } @@ -149,10 +149,10 @@ private fun MemorySpaceCardFieldPreview() { MemorySpaceCardField( memories = listOf( - MemoryItem(1, "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", "내용 1", listOf("태그1")), - MemoryItem(2, "https://picsum.photos/400/600?random=2", "기억 2", "2024.11.12", "내용 2", listOf("태그2")), - MemoryItem(3, "https://picsum.photos/400/600?random=3", "기억 3", "2024.11.13", "내용 3", listOf("태그3")), - MemoryItem(4, "https://picsum.photos/400/600?random=4", "기억 4", "2024.11.14", "내용 4", listOf("태그4")), + MemoryItem(1L, "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", "내용 1", listOf("태그1")), + MemoryItem(2L, "https://picsum.photos/400/600?random=2", "기억 2", "2024.11.12", "내용 2", listOf("태그2")), + MemoryItem(3L, "https://picsum.photos/400/600?random=3", "기억 3", "2024.11.13", "내용 3", listOf("태그3")), + MemoryItem(4L, "https://picsum.photos/400/600?random=4", "기억 4", "2024.11.14", "내용 4", listOf("태그4")), ), onMemoryClick = {}, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt index 1333767d58..ba7fe94499 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -38,7 +39,10 @@ fun MemorySpaceHeader( modifier = modifier .fillMaxWidth() - // 여기서 주는 패딩이 '유일한' 상단 여백이 됩니다. + // 이 화면은 Scaffold 없이 전체 화면 Box 위에 직접 얹히므로 시스템 인셋이 적용되지 + // 않는다. statusBarsPadding 을 빼면 "돌아가기" 가 상태바 아래로 들어가 터치가 + // 상태바에 먹혀 실제로 눌리지 않는다 (#559) — 아래 24.dp 는 그 위에 얹는 여백이다. + .statusBarsPadding() .padding(top = 24.dp, start = 24.dp), horizontalArrangement = Arrangement.spacedBy(13.dp), ) { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt index e5403c53bb..531909deb7 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt @@ -192,7 +192,7 @@ private fun PressedOverlay( private fun MemorySpacePhotoCardPreview() { AfternoteTheme { MemorySpacePhotoCard( - memory = MemoryItem(1, "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", "미리보기", listOf("태그")), + memory = MemoryItem(1L, "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", "미리보기", listOf("태그")), transform = CardTransform( offsetX = 0.dp, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt index 3f3c0d270b..ca9f33b806 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt @@ -11,12 +11,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.model.MindRecordCategory import com.afternote.core.ui.AfternoteSectionHeader import com.afternote.core.ui.theme.AfternoteTheme @@ -26,6 +31,8 @@ import com.afternote.feature.mindrecord.presentation.component.MemoriesCard import com.afternote.feature.mindrecord.presentation.component.TodayQuestionCard import com.afternote.feature.mindrecord.presentation.component.hometab.RecordCategoryCard import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import com.afternote.feature.mindrecord.presentation.util.htmlToPlainText +import com.afternote.feature.mindrecord.presentation.viewmodel.MemoriesCardViewModel import com.afternote.core.ui.R as CoreUiR /** @@ -81,10 +88,43 @@ fun LazyListScope.homeTabMindRecordMemoriesSection(onMemoriesSectionClick: () -> } } +/** + * `:app` 은 이 섹션을 배치만 하고 내용은 mindrecord 가 소유한다 — 그래서 카드 데이터도 + * 여기서 [MemoriesCardViewModel] 로 직접 받는다. 홈 ViewModel 을 거치면 마인드레코드 + * 기록 조회가 `:app` 으로 새어 나온다. + */ @Composable -private fun HomeTabMindRecordMemoriesItem(onMemoriesSectionClick: () -> Unit) { +private fun HomeTabMindRecordMemoriesItem( + onMemoriesSectionClick: () -> Unit, + viewModel: MemoriesCardViewModel = hiltViewModel(), +) { val memoriesClickLabel = stringResource(mindrecord_home_tab_memories_section_click_label) val interactionSource = remember { MutableInteractionSource() } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // 기록을 쓰고 홈으로 돌아오면 카드가 그 기록을 가리켜야 한다. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.refreshOnReturn() + } + + MemoriesSectionContent( + onMemoriesSectionClick = onMemoriesSectionClick, + question = uiState.question, + // 본문은 에디터가 HTML 로 직렬화해 저장한다 — 카드에는 태그를 걷어 낸 미리보기만. + answer = uiState.answer?.htmlToPlainText()?.takeIf { it.isNotBlank() }, + clickLabel = memoriesClickLabel, + interactionSource = interactionSource, + ) +} + +@Composable +private fun MemoriesSectionContent( + onMemoriesSectionClick: () -> Unit, + clickLabel: String, + interactionSource: MutableInteractionSource, + question: String? = null, + answer: String? = null, +) { Column(modifier = Modifier.fillMaxWidth()) { Column( modifier = @@ -93,14 +133,20 @@ private fun HomeTabMindRecordMemoriesItem(onMemoriesSectionClick: () -> Unit) { .clickable( interactionSource = interactionSource, indication = null, - onClickLabel = memoriesClickLabel, + onClickLabel = clickLabel, role = Role.Button, onClick = onMemoriesSectionClick, ), ) { AfternoteSectionHeader(title = stringResource(mindrecord_home_tab_memories_section_title)) Spacer(modifier = Modifier.height(12.dp)) - MemoriesCard() + // "그날의 기록 다시 읽기" 목적지는 넘기지 않는다 — Figma 의 이 버튼에 프로토타입 + // 연결이 없고 #559 에도 확정 답변이 없다. 임의 목적지를 붙이면 미확정 탐색 동작이 + // 제품 동작으로 굳는다. 확정되면 여기서 `onReadAgainClick` 을 채운다. + MemoriesCard( + question = question, + answer = answer, + ) } } } @@ -128,6 +174,12 @@ private fun HomeTabMindRecordQuestionAndCategoriesPreview() { @Composable private fun HomeTabMindRecordMemoriesItemPreview() { AfternoteTheme { - HomeTabMindRecordMemoriesItem(onMemoriesSectionClick = {}) + MemoriesSectionContent( + onMemoriesSectionClick = {}, + clickLabel = "", + interactionSource = remember { MutableInteractionSource() }, + question = "내 인생에서 가장 소중했던 순간은?", + answer = "아이가 태어났을 때...", + ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt index 33c461f8d9..8d77a3812c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.mapper +import android.util.Log import com.afternote.feature.mindrecord.domain.model.Diary import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.presentation.model.DailyDiary @@ -19,14 +20,27 @@ private val DateFormatters: List = DateTimeFormatter.ISO_DATE_TIME, ) -fun DailyQuestionDomain.toUi(): DailyQuestion = - DailyQuestion( +/** + * 날짜를 못 정하면 `null` 을 돌린다 — 호출부가 그 항목을 목록에서 뺀다. + * + * [Diary.toUi] 와 같은 규칙이다. 오늘 날짜로 메우면 **틀린 날짜가 정상처럼 표시되고** + * 실패 신호가 어디에도 남지 않아, "날짜가 이상하다" 는 제보를 받아도 재현 지점을 찾을 수 + * 없다 (#751). + */ +fun DailyQuestionDomain.toUi(): DailyQuestion? { + val resolvedDate = + parseLocalDateOrNull(createdAt) ?: run { + Log.w(TAG, "데일리질문 날짜를 해석하지 못해 목록에서 제외한다: id=$dailyQuestionId") + return null + } + return DailyQuestion( id = dailyQuestionId, title = title, - date = parseLocalDate(createdAt), + date = resolvedDate, content = content, imageUrl = imageUrl, ) +} /** * 날짜를 못 정하면 `null` 을 돌린다 — 호출부가 그 항목을 목록에서 뺀다. @@ -45,7 +59,7 @@ fun Diary.toUi(): DailyDiary? { title = title, date = resolvedDate, content = content, - emotion = todayMood?.toEmoji(), + emotion = todayMood.toEmoji(), imageUrl = imageUrl, ) } @@ -57,8 +71,6 @@ fun TodayMood.toEmoji(): String = TodayMood.SAD -> "😢" } -private fun parseLocalDate(raw: String): LocalDate = parseLocalDateOrNull(raw) ?: LocalDate.now() - private fun parseLocalDateOrNull(raw: String?): LocalDate? { // "2026.05.22 금" 처럼 뒤에 요일이 붙어오는 케이스 대응 — 첫 공백 앞부분만 파싱. val datePart = raw?.substringBefore(' ')?.trim().orEmpty() @@ -68,3 +80,5 @@ private fun parseLocalDateOrNull(raw: String?): LocalDate? { } return null } + +private const val TAG = "MindRecordUiMapper" diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/DayContent.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/DayContent.kt index 6acb075b6b..0616cc70d0 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/DayContent.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/DayContent.kt @@ -2,6 +2,12 @@ package com.afternote.feature.mindrecord.presentation.model import java.time.DayOfWeek +/** + * 주간 캘린더 한 칸의 표현. + * + * 이모지와 점은 **배타적**이다 — 디자이너 정의(2026-04-06)가 "이모티콘 설정 시 이모티콘을, + * 미설정한 것들은 점으로" 이고, 시안 캘린더에도 둘이 함께 있는 칸이 없다 (#749). + */ sealed class DayContent { data class NumberOnly( val day: Int, @@ -11,10 +17,6 @@ sealed class DayContent { val day: Int, ) : DayContent() // 숫자 + 하단 점 - data class EmojiWithDot( - val emoji: String, - ) : DayContent() // 이모지 + 하단 점 - data class EmojiOnly( val emoji: String, ) : DayContent() // 이모지만 (dot 없음) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt index 8b7ef69863..945337ba19 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt @@ -1,8 +1,17 @@ package com.afternote.feature.mindrecord.presentation.model.memoryspace +/** + * 추억 공간(MEMORY SPACE) 카드 1장. + * + * 서버에 전용 계약이 없어 사용자의 실제 기록(일기·데일리질문 답변)에서 조립한다. + * + * @param id 원본 기록 ID. 종류가 섞이므로 [MemoryItem] 안에서만 유일하면 된다. + * @param imageUrl 기록에 첨부된 대표 이미지. 없는 기록도 있어 nullable — 카드는 플레이스홀더로 렌더한다. + * @param content 원본 HTML 본문. 표시 시점에 `htmlToPlainText()` 로 변환한다. + */ data class MemoryItem( - val id: Int, - val imageUrl: String, + val id: Long, + val imageUrl: String?, val title: String, val date: String, val content: String, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt index ad4e8b4e92..b46ba2d06c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt @@ -9,28 +9,39 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemoryDetailOverlay import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemorySpaceCardField import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemorySpaceGridBackground import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemorySpaceHeader import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceUiState import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceViewModel /** @@ -44,21 +55,47 @@ fun MemorySpaceScreen( modifier: Modifier = Modifier, viewModel: MemorySpaceViewModel = hiltViewModel(), ) { - val memories by viewModel.memories.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() MemorySpaceContent( onBackClick = onBackClick, modifier = modifier, - memories = memories, + memories = (uiState as? MemorySpaceUiState.Success)?.memories.orEmpty(), + statusText = uiState.statusText(), + // 전체 조회 실패만 재시도로 풀린다 — 로딩·0건은 다시 눌러 봐야 결과가 같다. + onRetryClick = viewModel::retry.takeIf { uiState is MemorySpaceUiState.Error }, ) } +/** + * 카드가 한 장도 없을 때 격자 한가운데에 띄울 문구. 카드가 있으면 null. + * + * 로딩·에러·0건이 모두 "텅 빈 격자" 하나로 수렴하던 자리라, 셋을 문구로 갈라 준다. + */ +@Composable +private fun MemorySpaceUiState.statusText(): String? = + when (this) { + MemorySpaceUiState.Loading -> { + stringResource(R.string.mindrecord_memory_space_loading) + } + + is MemorySpaceUiState.Error -> { + stringResource(messageRes) + } + + is MemorySpaceUiState.Success -> { + stringResource(R.string.mindrecord_memory_space_empty).takeIf { memories.isEmpty() } + } + } + @Composable private fun MemorySpaceContent( onBackClick: () -> Unit, memories: List, modifier: Modifier = Modifier, + statusText: String? = null, + onRetryClick: (() -> Unit)? = null, ) { - var selectedMemoryId: Int? by rememberSaveable { mutableStateOf(null) } + var selectedMemoryId: Long? by rememberSaveable { mutableStateOf(null) } val selectedMemory = selectedMemoryId?.let { id -> memories.firstOrNull { it.id == id } } LaunchedEffect(memories, selectedMemoryId) { @@ -110,6 +147,33 @@ private fun MemorySpaceContent( onMemoryClick = { selectedMemoryId = it }, ) + if (statusText != null) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .padding(horizontal = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = statusText, + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + textAlign = TextAlign.Center, + ) + if (onRetryClick != null) { + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = onRetryClick) { + Text( + text = stringResource(R.string.mindrecord_memory_space_retry), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.gray9, + ) + } + } + } + } + MemorySpaceHeader(onBackClick = onBackClick) } @@ -140,7 +204,7 @@ private fun MemorySpaceScreenPreview() { memories = listOf( MemoryItem( - 1, + 1L, "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", @@ -148,7 +212,7 @@ private fun MemorySpaceScreenPreview() { listOf("태그"), ), MemoryItem( - 2, + 2L, "https://picsum.photos/400/600?random=2", "기억 2", "2024.11.12", @@ -156,7 +220,7 @@ private fun MemorySpaceScreenPreview() { emptyList(), ), MemoryItem( - 3, + 3L, "https://picsum.photos/400/600?random=3", "기억 3", "2024.11.13", @@ -164,7 +228,7 @@ private fun MemorySpaceScreenPreview() { emptyList(), ), MemoryItem( - 4, + 4L, "https://picsum.photos/400/600?random=4", "기억 4", "2024.11.14", @@ -175,3 +239,31 @@ private fun MemorySpaceScreenPreview() { ) } } + +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5) +@Composable +private fun MemorySpaceScreenEmptyPreview() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = emptyList(), + statusText = "아직 담긴 기록이 없어요.\n일기나 데일리 질문에 답하면 이곳에 쌓입니다.", + ) + } +} + +/** 조회 실패 — 0건과 달리 재시도 버튼이 함께 그려진다. */ +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5) +@Composable +private fun MemorySpaceScreenErrorPreview() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = emptyList(), + statusText = "기록을 불러오지 못했습니다.", + onRetryClick = {}, + ) + } +} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt index 0c9f30c1d1..348e6a55d3 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt @@ -60,7 +60,10 @@ fun ReceiverMindRecordScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() var filterSheetVisible by remember { mutableStateOf(false) } - Scaffold(modifier = modifier) { paddingValues -> + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { ReceiverMindRecordUiState.Loading -> { @@ -115,6 +118,8 @@ private fun SuccessContent( Spacer(modifier = Modifier.height(8.dp)) PrimaryTabRow( selectedTabIndex = selectedIndex, + // 지정하지 않으면 M3 baseline surface(#FEF7FF)가 나와 시안 배경(#FAFAFA)과 어긋난다. + containerColor = Color.Transparent, divider = {}, indicator = { TabRowDefaults.PrimaryIndicator( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt index 45d6ae6f91..733b7c29b9 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt @@ -2,6 +2,7 @@ package com.afternote.feature.mindrecord.presentation.screen.sender import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height @@ -25,12 +26,14 @@ import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.component.DailyCalendar +import com.afternote.feature.mindrecord.presentation.component.DailyQuestionBanner import com.afternote.feature.mindrecord.presentation.component.DailyQuestionListCard import com.afternote.feature.mindrecord.presentation.component.MindRecordEmptyState import com.afternote.feature.mindrecord.presentation.model.DailyQuestion import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListViewModel +import com.afternote.feature.mindrecord.presentation.viewmodel.TodayQuestionUi import java.time.YearMonth @Composable @@ -54,6 +57,7 @@ fun DailyQuestionAnswerListScreen( DailyQuestionListContent( modifier = modifier, isListView = isListView, + todayQuestion = state.todayQuestion, answers = state.answers, onDelete = viewModel::delete, ) @@ -66,13 +70,27 @@ private fun DailyQuestionListContent( isListView: Boolean, answers: List, modifier: Modifier = Modifier, + todayQuestion: TodayQuestionUi? = null, onDelete: (Long) -> Unit = {}, ) { var yearMonth by remember { mutableStateOf(YearMonth.now()) } val onYearMonthChanged: (YearMonth) -> Unit = { yearMonth = it } + var questionExpanded by remember { mutableStateOf(true) } + // 답변이 0건이어도 오늘의 추천 질문은 보여야 한다 — 종전에는 빈 상태가 화면 전체를 + // 대체해 이 영역까지 함께 사라졌다 (#592). if (isListView && answers.isEmpty()) { - MindRecordEmptyState(modifier = modifier) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TodayRecommendedQuestion( + todayQuestion = todayQuestion, + expanded = questionExpanded, + onToggle = { questionExpanded = !questionExpanded }, + ) + MindRecordEmptyState() + } return } @@ -80,6 +98,15 @@ private fun DailyQuestionListContent( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + // Figma 2671:15631 — 답변 목록 위의 "오늘의 추천 질문" 영역. + item { + TodayRecommendedQuestion( + todayQuestion = todayQuestion, + expanded = questionExpanded, + onToggle = { questionExpanded = !questionExpanded }, + ) + } + // Figma 2671:16704 — 캘린더 형은 캘린더 아래에 카드 리스트가 바로 이어짐 (gap 24) if (!isListView) { val answeredDays = @@ -146,3 +173,25 @@ private fun DailyQuestionAnswerListScreenPreviewTrue() { ) } } + +/** + * 목록 상단의 "오늘의 추천 질문". + * + * 조회가 실패하면 `todayQuestion` 이 `null` 이라 영역을 그리지 않는다 — 목록 자체는 + * 정상이므로 보조 영역 하나 때문에 화면을 오류로 바꾸지 않는다. 실패를 어떻게 알릴지는 + * 시안이 정의하지 않아 표현을 새로 만들지 않았다 (#592). + */ +@Composable +private fun TodayRecommendedQuestion( + todayQuestion: TodayQuestionUi?, + expanded: Boolean, + onToggle: () -> Unit, +) { + if (todayQuestion == null) return + DailyQuestionBanner( + questionText = todayQuestion.content, + expanded = expanded, + onToggle = onToggle, + dayNumber = todayQuestion.day, + ) +} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt index 8044dc21e5..4f17879357 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -83,6 +84,7 @@ fun DailyQuestionWriteScreen( ) }, modifier = modifier, + containerColor = Color.Transparent, ) { paddingValues -> Column( modifier = @@ -122,6 +124,7 @@ fun DailyQuestionWriteScreen( onValueChange = viewModel::onAnswerChanged, onSaveDraftClick = { viewModel.submit(isDraft = true) }, onDraftCountClick = onDraftListClick, + draftCount = uiState.draftCount, onImagePicked = viewModel::uploadImage, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt index 049058e1b4..b3e286903f 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt @@ -103,6 +103,7 @@ fun DiaryWriteScreen( ) }, modifier = modifier, + containerColor = Color.Transparent, ) { paddingValues -> Column( modifier = @@ -264,6 +265,7 @@ fun DiaryWriteScreen( onValueChange = viewModel::onContentChanged, onSaveDraftClick = { viewModel.submit(isDraft = true) }, onDraftCountClick = onDraftListClick, + draftCount = uiState.draftCount, onImagePicked = viewModel::uploadImage, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt index 788f3ad7fa..941f73c809 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt @@ -30,6 +30,7 @@ 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.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview @@ -44,6 +45,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.mindrecord.presentation.util.htmlToPlainText import com.afternote.feature.mindrecord.presentation.viewmodel.DraftCategory +import com.afternote.feature.mindrecord.presentation.viewmodel.DraftDeleteOutcome import com.afternote.feature.mindrecord.presentation.viewmodel.DraftItem import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel @@ -71,13 +73,27 @@ fun DraftListScreen( var selectionMode by remember { mutableStateOf(false) } var selectedKeys by remember { mutableStateOf(setOf()) } - val deleteCompleted = (uiState as? DraftListUiState.Success)?.deleteCompleted == true - LaunchedEffect(deleteCompleted) { - if (deleteCompleted) { - selectedKeys = emptySet() - selectionMode = false - delay(DELETED_TOAST_DURATION_MS) - viewModel.consumeDeleteCompleted() + val deleteOutcome = (uiState as? DraftListUiState.Success)?.deleteOutcome + LaunchedEffect(deleteOutcome) { + when (deleteOutcome) { + null -> { + Unit + } + + DraftDeleteOutcome.AllDeleted -> { + selectedKeys = emptySet() + selectionMode = false + delay(DELETED_TOAST_DURATION_MS) + viewModel.consumeDeleteOutcome() + } + + is DraftDeleteOutcome.SomeFailed -> { + // 실패한 항목만 다시 선택된 상태로 남겨 그대로 재시도할 수 있게 한다. + selectedKeys = deleteOutcome.failedItems.mapTo(mutableSetOf()) { it.key() } + selectionMode = true + delay(DELETED_TOAST_DURATION_MS) + viewModel.consumeDeleteOutcome() + } } } @@ -115,6 +131,7 @@ fun DraftListScreen( ) }, modifier = modifier, + containerColor = Color.Transparent, ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { @@ -140,6 +157,7 @@ fun DraftListScreen( if (key in selectedKeys) selectedKeys - key else selectedKeys + key }, onDeleteAll = viewModel::deleteAll, + isDeleteSelectedEnabled = selectedKeys.isNotEmpty(), onDeleteSelected = { viewModel.delete(state.items.filter { it.key() in selectedKeys }) }, @@ -152,13 +170,31 @@ fun DraftListScreen( ) // Figma 2372:25211 — 삭제 완료 토스트 - if (state.deleteCompleted) { - DeletedToast( - modifier = - Modifier - .align(Alignment.TopCenter) - .padding(horizontal = 20.dp, vertical = 12.dp), - ) + when (val outcome = state.deleteOutcome) { + null -> { + Unit + } + + DraftDeleteOutcome.AllDeleted -> { + DeletedToast( + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) + } + + // 완료 토스트와 같은 자리에 실패를 알린다 — 지워지지 않은 항목이 목록에 + // 남아 있는데 "삭제되었습니다" 가 뜨면 사용자는 지워진 줄 안다. + is DraftDeleteOutcome.SomeFailed -> { + DraftDeleteFailedToast( + failedCount = outcome.failedItems.size, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) + } } } } @@ -175,6 +211,7 @@ private fun SuccessContent( onDeleteAll: () -> Unit, onDeleteSelected: () -> Unit, onItemClick: (DraftItem) -> Unit, + isDeleteSelectedEnabled: Boolean = true, ) { Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) { @@ -217,6 +254,7 @@ private fun SuccessContent( DeleteActionBar( onDeleteAll = onDeleteAll, onDeleteSelected = onDeleteSelected, + isDeleteSelectedEnabled = isDeleteSelectedEnabled, modifier = Modifier .align(Alignment.BottomCenter) @@ -291,6 +329,8 @@ private fun DeleteActionBar( onDeleteAll: () -> Unit, onDeleteSelected: () -> Unit, modifier: Modifier = Modifier, + /** 0개 선택 상태에서는 빈 목록으로 삭제를 부르지 않도록 막는다 (#442). */ + isDeleteSelectedEnabled: Boolean = true, ) { Row( modifier = @@ -323,14 +363,17 @@ private fun DeleteActionBar( modifier = Modifier .weight(1f) - .clickable(role = Role.Button, onClick = onDeleteSelected) - .padding(vertical = 12.dp), + .clickable( + role = Role.Button, + enabled = isDeleteSelectedEnabled, + onClick = onDeleteSelected, + ).padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text( text = stringResource(MindRecordR.string.mindrecord_draft_list_delete_selected), style = AfternoteDesign.typography.bodySmallB, - color = AfternoteDesign.colors.white, + color = if (isDeleteSelectedEnabled) AfternoteDesign.colors.white else AfternoteDesign.colors.gray6, ) } } @@ -355,6 +398,38 @@ private fun DeletedToast(modifier: Modifier = Modifier) { } } +/** + * 삭제 실패 안내. + * + * 완료 토스트와 같은 자리·같은 모양을 쓰되 문구만 다르다 — 실패 표시 시안이 따로 없어 + * 새 표현을 만들지 않았다. 확정되면 이 컴포저블만 교체하면 된다. + */ +@Composable +private fun DraftDeleteFailedToast( + failedCount: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(AfternoteDesign.colors.gray9) + .padding(vertical = 18.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = + stringResource( + MindRecordR.string.mindrecord_draft_list_delete_failed_toast, + failedCount, + ), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.white, + ) + } +} + private fun DraftItem.key(): String = "${category.name}-$id" @Composable diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt index ce76864003..5b58c1069c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt @@ -131,6 +131,7 @@ fun HomeScreen( ) } }, + containerColor = Color.Transparent, ) { paddingValues -> Column( modifier = @@ -140,6 +141,8 @@ fun HomeScreen( ) { PrimaryScrollableTabRow( selectedTabIndex = selectedIndex, + // 지정하지 않으면 M3 baseline surface(#FEF7FF)가 나와 시안 배경(#FAFAFA)과 어긋난다. + containerColor = Color.Transparent, edgePadding = 0.dp, divider = {}, indicator = { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt index 3f2a4202d2..e1ef754025 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt @@ -130,35 +130,51 @@ private fun recordedSummary( userName: String, recordedDays: Int, ): androidx.compose.ui.text.AnnotatedString { - val prefix = stringResource(R.string.mindrecord_weekly_report_recorded_prefix) - val middle = stringResource(R.string.mindrecord_weekly_report_recorded_middle) val daysText = stringResource(R.string.mindrecord_weekly_report_days_format, recordedDays) - val suffix = stringResource(R.string.mindrecord_weekly_report_recorded_suffix) + val sentence = stringResource(R.string.mindrecord_weekly_report_recorded, userName, daysText) + val highlights = recordedSummaryHighlights(sentence, userName, daysText) + val base = AfternoteDesign.typography.bodyLargeB return buildAnnotatedString { - withStyle(style = AfternoteDesign.typography.bodyLargeB.toSpanStyle()) { - append(prefix) - withStyle( - style = - AfternoteDesign.typography.bodyLargeB - .copy(color = AfternoteDesign.colors.b1) - .toSpanStyle(), - ) { - append(userName) - } - append(middle) - withStyle( - style = - AfternoteDesign.typography.bodyLargeB - .copy(color = AfternoteDesign.colors.b1) - .toSpanStyle(), - ) { - append(daysText) - } - append(suffix) + append(sentence) + addStyle(base.toSpanStyle(), 0, sentence.length) + highlights.forEach { range -> + addStyle( + base.copy(color = AfternoteDesign.colors.b1).toSpanStyle(), + range.first, + range.last + 1, + ) } } } +/** + * 완성된 문장에서 강조할 구간(이름·기록일수)을 찾는다. + * + * 문장을 조각으로 나눠 이어 붙이지 않고 한 리소스로 두기 위한 것이다 — 조각 방식은 리소스 + * 앞뒤 공백에 의존하는데, aapt2 가 그 공백을 지워 APK 에서 문구가 붙어 버렸다 (#732). + * + * 이름을 먼저 찾고 기록일수는 그 뒤에서 찾는다. 이름에 "3일" 같은 문자열이 들어 있어도 + * 강조 구간이 겹치지 않는다. 찾지 못하면 강조를 생략한다 — 문장은 그대로 보인다. + */ +internal fun recordedSummaryHighlights( + sentence: String, + userName: String, + daysText: String, +): List { + val ranges = mutableListOf() + val nameStart = sentence.indexOf(userName).takeIf { it >= 0 && userName.isNotEmpty() } + val searchFrom = + if (nameStart != null) { + ranges += nameStart until (nameStart + userName.length) + nameStart + userName.length + } else { + 0 + } + val daysStart = sentence.indexOf(daysText, startIndex = searchFrom).takeIf { it >= 0 && daysText.isNotEmpty() } + if (daysStart != null) ranges += daysStart until (daysStart + daysText.length) + return ranges +} + @Composable private fun SectionHeader(title: String) { Row( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListUiState.kt index cf0c1a8b56..065d05957c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListUiState.kt @@ -18,6 +18,8 @@ sealed interface DailyQuestionListUiState { data class TodayQuestionUi( val questionId: Long, + /** "Day N" 배너 표기용 — 서비스 시작일 기준 몇 번째 질문인지. */ + val day: Int, val content: String, val isAnswered: Boolean, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt index 9617e2bc10..76e7f28171 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt @@ -75,8 +75,9 @@ class DailyQuestionListViewModel val today = repository.getToday().getOrNull() val listResult = repository.getList() - // repository 가 runCatching 으로 감싸 CancellationException 까지 실패로 바꿔 돌려준다. // 새 로드가 이 Job 을 취소했다면 상태는 그쪽이 결정하므로 여기서 멈춘다. + // repository 는 `runCatchingCancellable` 로 취소를 다시 던지므로 대개 여기 오기 전에 + // 빠져나가지만, 조회가 끝난 뒤 취소가 들어온 경우를 위해 남겨 둔다. ensureActive() // 서버는 draftOnly 없이 조회하면 임시저장을 제외해 내려주지만, 파라미터를 무시하는 // 서버를 만나도 임시저장이 답변 목록에 새지 않도록 한 겹 더 거른다. @@ -132,11 +133,13 @@ class DailyQuestionListViewModel phase.today?.let { TodayQuestionUi( questionId = it.questionId, + day = it.day, content = it.content, isAnswered = it.isAnswered, ) }, - answers = phase.answers.map { it.toUi() }, + // 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 카드로 만들지 않는다 (#751). + answers = phase.answers.mapNotNull { it.toUi() }, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index 5e5462c85f..6349d2a4f6 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt @@ -14,6 +14,8 @@ data class DailyQuestionWriteUiState( val isQuestionLoading: Boolean = true, val questionLoadError: UiText? = null, val submitState: SubmitState = SubmitState.Idle, + /** 툴바 "임시저장 N" 표시값. `null` 은 아직 모름(조회 중·실패) (#769). */ + val draftCount: Int? = null, ) { /** * `questionId` 유무는 여기서 보지 않는다. 조건에 넣으면 오늘 질문 조회가 실패했을 때 diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index c9605a386f..f893d48f9d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -24,12 +24,23 @@ class DailyQuestionWriteViewModel constructor( private val repository: DailyQuestionRepository, private val photoUploadRepository: PhotoUploadRepository, + private val draftLoader: MindRecordDraftLoader, ) : ViewModel() { private val _uiState = MutableStateFlow(DailyQuestionWriteUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { loadTodayQuestion() + loadDraftCount() + } + + /** 툴바 카운트는 화면 장식이라 실패해도 화면을 막지 않고 '모름' 으로 남긴다. */ + private fun loadDraftCount() { + viewModelScope.launch { + draftLoader.count().onSuccess { count -> + _uiState.update { it.copy(draftCount = count) } + } + } } private fun loadTodayQuestion() { @@ -162,8 +173,19 @@ class DailyQuestionWriteViewModel ) } result - .onSuccess { - _uiState.update { it.copy(submitState = SubmitState.Succeeded) } + .onSuccess { savedId -> + _uiState.update { + it.copy( + submitState = SubmitState.Succeeded, + // 서버가 돌려준 "내 답변" 식별자를 그대로 든다 (#573). + // 임시저장 뒤 이어서 저장하면 목록을 다시 뒤지지 않고 이 값으로 PATCH 한다 — + // 종전에는 응답을 버려 `resumeDraft()` 가 목록을 재조회해 첫 draft 를 + // 추측으로 골랐다. + draftId = if (isDraft) savedId else null, + ) + } + // 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769). + if (isDraft) loadDraftCount() }.onFailure { e -> _uiState.update { it.copy( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt index ed450f12f3..b66c525fb1 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt @@ -97,8 +97,9 @@ class DiaryListViewModel ) } val listResult = repository.getList(yearMonth = yearMonth.toString(), draftOnly = null) - // repository 가 runCatching 으로 감싸 CancellationException 까지 실패로 바꿔 돌려준다. // 새 로드가 이 Job 을 취소했다면 상태는 그쪽이 결정하므로 여기서 멈춘다. + // repository 는 `runCatchingCancellable` 로 취소를 다시 던지므로 대개 여기 오기 전에 + // 빠져나가지만, 조회가 끝난 뒤 취소가 들어온 경우를 위해 남겨 둔다. ensureActive() listResult .onSuccess { result -> diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt index 532a74d1f9..6ac03e8303 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt @@ -21,6 +21,8 @@ data class DiaryWriteUiState( val draftLoaded: Boolean = false, val draftLoadError: UiText? = null, val submitState: SubmitState = SubmitState.Idle, + /** 툴바 "임시저장 N" 표시값. `null` 은 아직 모름(조회 중·실패) (#769). */ + val draftCount: Int? = null, ) { val canSubmit: Boolean get() = diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 9743c1e537..d6293dac3a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -39,6 +39,7 @@ class DiaryWriteViewModel private val repository: DiaryRepository, private val photoUploadRepository: PhotoUploadRepository, private val userRepository: UserRepository, + private val draftLoader: MindRecordDraftLoader, ) : ViewModel() { private val route = savedStateHandle.toRoute() private val editingDraftId: Long? = route.draftId @@ -48,9 +49,19 @@ class DiaryWriteViewModel init { loadReceivers() + loadDraftCount() editingDraftId?.let { loadDraft(it, route.draftYearMonth) } } + /** 툴바 카운트는 화면 장식이라 실패해도 화면을 막지 않고 '모름' 으로 남긴다. */ + private fun loadDraftCount() { + viewModelScope.launch { + draftLoader.count().onSuccess { count -> + _uiState.update { it.copy(draftCount = count) } + } + } + } + fun onTitleChanged(value: String) { _uiState.update { it.copy(title = value) } } @@ -117,13 +128,15 @@ class DiaryWriteViewModel isDraft = isDraft, todayMood = mood, imageUrl = state.imageUrl, - receiverIds = state.selectedReceiverIds.toList().takeIf { it.isNotEmpty() }, + receiverIds = state.selectedReceiverIds.toList(), ), ) } result .onSuccess { _uiState.update { it.copy(submitState = SubmitState.Succeeded) } + // 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769). + if (isDraft) loadDraftCount() }.onFailure { e -> _uiState.update { it.copy( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListUiState.kt index 15e9e07ee7..144514afbb 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListUiState.kt @@ -16,7 +16,8 @@ sealed interface DraftListUiState { data class Success( val items: List, val isDeleting: Boolean = false, - val deleteCompleted: Boolean = false, + /** 직전 삭제 요청의 결과. `null` 이면 알릴 것이 없다. */ + val deleteOutcome: DraftDeleteOutcome? = null, ) : DraftListUiState { val totalCount: Int get() = items.size } @@ -26,6 +27,22 @@ sealed interface DraftListUiState { ) : DraftListUiState } +/** + * 삭제 요청의 결과. + * + * 항목별 `delete()` 는 실패를 던지지 않고 `Result.failure` 로 감싼다. 그 결과를 보지 않으면 + * 서버가 거절한 항목이 목록에 남은 채 완료 토스트가 떠서, 사용자는 지워진 줄 안다 (#442). + */ +sealed interface DraftDeleteOutcome { + /** 요청한 항목이 모두 지워졌다. */ + data object AllDeleted : DraftDeleteOutcome + + /** 일부가 실패했다. 실패한 항목은 화면이 다시 선택 상태로 되돌려 재시도할 수 있게 한다. */ + data class SomeFailed( + val failedItems: List, + ) : DraftDeleteOutcome +} + enum class DraftCategory { All, DailyQuestion, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index 6f5c50e3fb..1bde87a91d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -16,22 +16,19 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.time.YearMonth import javax.inject.Inject /** * 임시저장 목록 ViewModel. * - * 두 카테고리 모두 백엔드가 제공하는 `draftOnly=true` 쿼리로 조회한다 — 전체를 받아 클라에서 거르면 - * 서버가 페이징을 붙였을 때 draft 가 뒤 페이지로 밀려 누락될 수 있다. - * - * 조회 범위는 일기가 이번 달 한정(`yearMonth`)인 반면 데일리질문은 전체 기간이다. 데일리질문 API 의 - * `date` 는 특정 하루만 받아 "이번 달" 을 표현할 수 없어 맞추지 못했다. + * 조회 범위는 [MindRecordDraftLoader] 가 정본으로 들고 있다 — 작성 툴바의 카운트가 같은 범위를 + * 봐야 목록 건수와 어긋나지 않는다 (#769). */ @HiltViewModel class DraftListViewModel @Inject constructor( + private val loader: MindRecordDraftLoader, private val diaryRepository: DiaryRepository, private val dailyQuestionRepository: DailyQuestionRepository, ) : ViewModel() { @@ -53,39 +50,57 @@ class DraftListViewModel _uiState.update { if (it is DraftListUiState.Success) it.copy(isDeleting = true) else it } - coroutineScope { - items - .map { item -> - async { - when (item.category) { - DraftCategory.Diary -> diaryRepository.delete(item.id) - - DraftCategory.DailyQuestion -> dailyQuestionRepository.delete(item.id) + // 항목별 결과를 항목과 짝지어 받는다 — 무엇이 실패했는지 알아야 다시 선택해 줄 수 있다. + val results = + coroutineScope { + items + .map { item -> async { item to deleteOne(item) } } + .awaitAll() + } + val failed = results.filter { (_, result) -> result.isFailure }.map { (item, _) -> item } - // All 은 필터 라벨용 — 실제 항목 카테고리로는 등장하지 않는다. - DraftCategory.All -> Result.success(Unit) - } - } - }.awaitAll() - } val refreshed = collectDrafts() _uiState.value = if (refreshed != null) { - DraftListUiState.Success(items = refreshed, deleteCompleted = true) + // 실패했지만 재조회에서도 사라진 항목은 알리지 않는다 — 이미 없는 것을 지우려다 + // 404 가 난 경우가 여기다. 사용자가 원한 결과는 이뤄졌고, 남아 있지도 않은 항목을 + // 다시 선택해 주면 "목록은 비었는데 1개 선택" 같은 상태가 된다. + val remainingKeys = refreshed.mapTo(mutableSetOf()) { it.category to it.id } + val actionableFailures = failed.filter { (it.category to it.id) in remainingKeys } + + DraftListUiState.Success( + items = refreshed, + deleteOutcome = + if (actionableFailures.isEmpty()) { + DraftDeleteOutcome.AllDeleted + } else { + DraftDeleteOutcome.SomeFailed(actionableFailures) + }, + ) } else { DraftListUiState.Error(UiText.Resource(R.string.mindrecord_error_generic)) } } } + private suspend fun deleteOne(item: DraftItem): Result = + when (item.category) { + DraftCategory.Diary -> diaryRepository.delete(item.id) + + DraftCategory.DailyQuestion -> dailyQuestionRepository.delete(item.id) + + // All 은 필터 라벨용 — 실제 항목 카테고리로는 등장하지 않는다. + DraftCategory.All -> Result.success(Unit) + } + fun deleteAll() { val current = _uiState.value as? DraftListUiState.Success ?: return delete(current.items) } - fun consumeDeleteCompleted() { + fun consumeDeleteOutcome() { _uiState.update { - if (it is DraftListUiState.Success) it.copy(deleteCompleted = false) else it + if (it is DraftListUiState.Success) it.copy(deleteOutcome = null) else it } } @@ -103,29 +118,12 @@ class DraftListViewModel } private suspend fun collectDrafts(): List? = - runCatching { - coroutineScope { - val yearMonth = YearMonth.now().toString() - // 조회 실패는 예외로 올려 바깥 runCatching 이 잡게 한다. - // getOrNull().orEmpty() 로 흡수하면 "실패" 와 "0건" 이 같은 빈 화면이 되어 - // Error 상태가 도달 불가능한 죽은 경로가 된다. - val draftDiariesDeferred = - async { - diaryRepository - .getList(yearMonth = yearMonth, draftOnly = true) - .getOrThrow() - .diaries - } - val draftDailyQuestionsDeferred = - async { - dailyQuestionRepository - .getList(draftOnly = true) - .getOrThrow() - } - + loader + .load() + .map { drafts -> val diaryItems = // 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 뺀다. - draftDiariesDeferred.await().mapNotNull { diary -> + drafts.diaries.mapNotNull { diary -> val ui = diary.toUi() ?: return@mapNotNull null DraftItem( id = ui.id, @@ -136,8 +134,9 @@ class DraftListViewModel ) } val dailyQuestionItems = - draftDailyQuestionsDeferred.await().map { question -> - val ui = question.toUi() + // 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 뺀다 (#751). + drafts.dailyQuestions.mapNotNull { question -> + val ui = question.toUi() ?: return@mapNotNull null DraftItem( id = ui.id, category = DraftCategory.DailyQuestion, @@ -146,8 +145,6 @@ class DraftListViewModel date = ui.date, ) } - (diaryItems + dailyQuestionItems).sortedByDescending { it.date } - } - }.getOrNull() + }.getOrNull() } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt new file mode 100644 index 0000000000..09c6a29033 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt @@ -0,0 +1,72 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +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 + +/** + * 홈 탭 MEMORIES 카드에 얹을 **가장 최근 데일리질문 답변** 한 건. + * + * 카드가 "질문 + 답변" 꼴이라 두 출처(일기·데일리질문) 중 형태가 맞는 쪽만 쓴다. + * 조회 실패는 화면에 올리지 않는다 — 홈의 보조 카드라 기록 0건과 같은 모습(질문·답변 줄이 + * 그려지지 않음)으로 떨어지는 편이 낫고, 같은 화면의 다른 실패 안내와 겹치면 소음이 된다. + */ +@HiltViewModel +class MemoriesCardViewModel + @Inject + constructor( + private val repository: DailyQuestionRepository, + ) : ViewModel() { + private val _uiState = MutableStateFlow(MemoriesCardUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var loadJob: Job? = null + + init { + load() + } + + /** 홈 복귀·기록 저장 후 최신 기록을 다시 집는다. */ + fun refreshOnReturn() { + // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. + // 컴포지션 쪽 플래그가 아니라 VM 이 들고 있는 Job 으로 판단해야 + // 프로세스 사망 후 복원(VM 재생성 + 플래그 복원)에서도 중복이 나지 않는다. + // `DiaryListViewModel.refreshOnReturn` 과 같은 규칙이다. + if (loadJob?.isActive == true) return + load() + } + + private fun load() { + // 앞선 요청이 늦게 도착해 최신 상태를 덮지 않도록 진행 중이던 것은 버린다. + loadJob?.cancel() + loadJob = + viewModelScope.launch { + repository + .getList() + .onSuccess { questions -> + // 첫 건이 곧 최신이다 — `GET /api/v1/daily-questions` 가 명세에서 + // "특정 날짜 혹은 전체 답변 목록을 최신순으로 조회합니다" 로 정렬을 계약한다. + // 같은 API 가 `draftOnly` 생략 시 제출 완료분만 주지만, 파라미터를 무시하는 + // 서버를 만나도 초안이 카드에 오르지 않도록 `!isDraft` 재확인은 남겨둔다. + val latest = questions.firstOrNull { !it.isDraft } + _uiState.update { + it.copy(question = latest?.title, answer = latest?.content) + } + } + } + } + } + +/** null 이면 표시할 기록이 없다 — 카드가 질문·답변 줄을 그리지 않는다 (0건 시안 미확정, #559). */ +data class MemoriesCardUiState( + val question: String? = null, + val answer: String? = null, +) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt new file mode 100644 index 0000000000..0109d642bf --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt @@ -0,0 +1,26 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.annotation.StringRes +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem + +sealed interface MemorySpaceUiState { + data object Loading : MemorySpaceUiState + + /** [memories] 가 비어 있으면 아직 기록이 없는 사용자다 — 화면이 빈 상태 안내를 띄운다. */ + data class Success( + val memories: List, + ) : MemorySpaceUiState + + /** + * 조회 실패. 표시 문구는 [messageRes] 하나로만 운반한다 — 예외 원문(`Throwable.message`)은 UI 에 + * 싣지 않는다. 오프라인이면 `Unable to resolve host "afternote.kro.kr"…` 같은 영어 원문이, + * 5xx 면 서버 내부 문구가 그대로 화면에 뜬다 (`ApiException` 도 `message` 는 로그 전용이고 + * 사용자 노출용으로 `serverMessage` 를 따로 둔다 — 같은 규칙을 `AfternoteDetailUiState.Error` 가 쓴다). + * + * 이 화면은 조회만 하므로 서버가 4xx 로 안내할 사용자 교정 사항 자체가 없다. 그래서 + * `serverMessage` 선별 없이 정적 리소스 하나로 고정한다. + */ + data class Error( + @param:StringRes val messageRes: Int, + ) : MemorySpaceUiState +} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt index 55250b3a39..5f1a2c67d4 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt @@ -1,63 +1,164 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter import javax.inject.Inject -/** 마인드레코드 MemorySpace 화면용. 더미 목록은 추후 Repository 연동으로 교체 가능. */ +/** + * 추억 공간(MEMORY SPACE) 화면. + * + * 서버에 "추억" 전용 계약이 없어 사용자의 실제 기록을 모아 카드로 만든다 — 일기와 + * 데일리질문 답변 두 출처를 합쳐 최신순으로 [MEMORY_CARD_LIMIT] 장까지 노출한다. + * 종전에는 `기억 1~4` / `https://mock.image/N` 더미를 방출했다 (#559). + * + * 일기 조회는 `yearMonth` 가 필수라 기간 전체를 한 번에 받을 수 없다. 카드가 몇 장뿐이라 + * 최근 [DIARY_MONTH_WINDOW] 개월만 조회한다 — 그보다 과거까지 훑으면 화면에 쓰지도 않을 + * 요청이 달마다 한 건씩 늘어난다. 데일리질문은 `date` 생략 시 전체 기간이 온다. + */ @HiltViewModel class MemorySpaceViewModel @Inject - constructor() : ViewModel() { - private val _memories = MutableStateFlow>(emptyList()) - val memories: StateFlow> = _memories.asStateFlow() + constructor( + private val diaryRepository: DiaryRepository, + private val dailyQuestionRepository: DailyQuestionRepository, + ) : ViewModel() { + private val _uiState = MutableStateFlow(MemorySpaceUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() init { - loadDummyMemories() + load() } - // TODO: [MOCK_CLEANUP] 백엔드 API 스펙 확정 시 Repository 연동으로 교체 및 삭제. - private fun loadDummyMemories() { - _memories.value = - listOf( - MemoryItem( - id = 1, - imageUrl = "https://mock.image/1", - title = "기억 1", - date = "2024.11.11", - content = - "이 순간은 나에게 특별한 의미가 있었습니다. 햇살이 창문을 통해 들어오는 고요한 오후, 나만의 생각에 잠길 수 있었던 시간.\n\n" + - "일상 속에서 찾은 작은 평화의 순간들이 모여 나의 삶을 더 풍요롭게 만들어갑니다.", - tags = listOf("평온", "일상", "감사"), - ), - MemoryItem( - id = 2, - imageUrl = "https://mock.image/2", - title = "기억 2", - date = "2024.11.12", - content = "두 번째 기억 내용.", - tags = listOf("추억"), - ), + fun retry() = load() + + private fun load() { + viewModelScope.launch { + _uiState.value = MemorySpaceUiState.Loading + runCatchingCancellable { collectMemories() } + .onSuccess { memories -> _uiState.value = MemorySpaceUiState.Success(memories) } + .onFailure { + _uiState.value = MemorySpaceUiState.Error(R.string.mindrecord_error_memory_space_failed) + } + } + } + + /** + * 두 출처를 병렬 조회해 최신순 카드로 만든다. + * + * 부분 실패는 카드가 한 장이라도 채워졌을 때만 삼킨다 — 카드 4장짜리 장식 + * 화면을 한 출처의 실패로 통째 비우면 잃는 것이 더 크다. 반면 **합친 결과가 + * 비었는데 실패한 출처가 있으면** 그 실패를 올린다 — 실패한 쪽에 기록이 있었을 수 + * 있어 0건으로 확정해 버리면 ‘아직 담긴 기록이 없어요’ 로 오인하고 재시도 경로까지 사라진다. + */ + private suspend fun collectMemories(): List = + coroutineScope { + val diaryDeferred = + recentMonths().map { month -> + async { diaryRepository.getList(yearMonth = month.toString()) } + } + val questionDeferred = async { dailyQuestionRepository.getList() } + + val diaryResults = diaryDeferred.awaitAll() + val questionResult = questionDeferred.await() + + val diaries = + diaryResults + .mapNotNull { it.getOrNull() } + .flatMap { it.diaries } + // 쓰다 만 초안은 전시하지 않는다. `GET /diary` 는 `draftOnly` 를 생략하면 + // 그 달 **전체**(임시저장 포함)를 내려주므로 클라가 걸러야 한다. + // 데일리질문(`GET /daily-questions`)은 생략 시 서버가 제출 완료만 주므로 + // 같은 필터가 필요 없다 — 두 API 의 기본값이 다르다. + .filterNot { it.isDraft } + val questions = questionResult.getOrNull().orEmpty() + + val memories = + (diaries.mapNotNull { it.toDatedMemory() } + questions.mapNotNull { it.toDatedMemory() }) + .sortedByDescending { it.date } + .take(MEMORY_CARD_LIMIT) + .map { it.item } + + if (memories.isEmpty()) { + val failure = + diaryResults.firstNotNullOfOrNull { it.exceptionOrNull() } + ?: questionResult.exceptionOrNull() + if (failure != null) throw failure + } + + memories + } + + private fun recentMonths(): List { + val thisMonth = YearMonth.now() + return (0 until DIARY_MONTH_WINDOW).map { thisMonth.minusMonths(it.toLong()) } + } + + /** 두 출처를 한 줄로 정렬하기 위한 운반 타입 — 표시용 날짜 문자열로는 비교가 안 된다. */ + private data class DatedMemory( + val date: LocalDate, + val item: MemoryItem, + ) + + /** 날짜를 못 정한 일기는 정렬 키가 없어 카드로 만들지 않는다 ([toUi] 가 null 을 돌린다). */ + private fun Diary.toDatedMemory(): DatedMemory? { + val ui = toUi() ?: return null + return DatedMemory( + date = ui.date, + item = MemoryItem( - id = 3, - imageUrl = "https://mock.image/3", - title = "기억 3", - date = "2024.11.13", - content = "세 번째 기억 내용.", - tags = listOf("여행", "힐링"), + id = ui.id, + imageUrl = ui.imageUrl, + title = ui.title, + date = ui.date.format(CARD_DATE_FORMATTER), + content = ui.content, + tags = listOfNotNull(ui.emotion), ), + ) + } + + /** 날짜를 못 정한 데일리질문도 같다 — 정렬 키가 없어 카드로 만들지 않는다 (#751). */ + private fun DailyQuestion.toDatedMemory(): DatedMemory? { + val ui = toUi() ?: return null + return DatedMemory( + date = ui.date, + item = MemoryItem( - id = 4, - imageUrl = "https://mock.image/4", - title = "기억 4", - date = "2024.11.14", - content = "네 번째 기억 내용.", - tags = listOf("가족"), + // 두 출처의 ID 공간이 겹친다 — 카드 선택이 엉뚱한 기록을 열지 않도록 + // 데일리질문은 음수로 접어 일기와 갈라 둔다 (서버 ID 는 양수). + id = -ui.id, + imageUrl = ui.imageUrl, + title = ui.title, + date = ui.date.format(CARD_DATE_FORMATTER), + content = ui.content, + tags = emptyList(), ), - ) + ) + } + + companion object { + /** 카드 배치(`MemorySpaceCardField`)가 4장까지만 자리를 잡는다. */ + private const val MEMORY_CARD_LIMIT = 4 + private const val DIARY_MONTH_WINDOW = 3 + + private val CARD_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt new file mode 100644 index 0000000000..2ec06fe2cb --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt @@ -0,0 +1,65 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.time.YearMonth +import javax.inject.Inject + +/** + * 임시저장의 **조회 범위 정본**. + * + * 임시저장 목록 화면과 작성 툴바의 카운트가 같은 범위를 봐야 한다 — 목록에 5건이 뜨는데 + * 툴바가 3을 보이면 어느 쪽이 맞는지 알 수 없다. 두 화면이 각자 조회하면 범위가 갈라지므로 + * 여기 한 곳에만 둔다 (#769). + * + * 일기는 `yearMonth` 가 필수라 **이번 달**만, 데일리질문은 `date` 를 생략해 **전체 기간**을 + * 받는다. 서버 계약이 그렇게 갈려 있어 범위가 비대칭인 것이지 의도한 구분은 아니다. + */ +class MindRecordDraftLoader + @Inject + constructor( + private val diaryRepository: DiaryRepository, + private val dailyQuestionRepository: DailyQuestionRepository, + ) { + /** + * 임시저장 원본을 모아 돌려준다. + * + * 한쪽이라도 조회에 실패하면 실패로 올린다 — 빈 목록으로 흡수하면 "실패" 와 "0건" 이 + * 같은 화면이 되어 복구 수단이 사라진다. + */ + suspend fun load(): Result = + runCatchingCancellable { + coroutineScope { + val diariesDeferred = + async { + diaryRepository + .getList(yearMonth = YearMonth.now().toString(), draftOnly = true) + .getOrThrow() + .diaries + } + val dailyQuestionsDeferred = + async { + dailyQuestionRepository.getList(draftOnly = true).getOrThrow() + } + Drafts( + diaries = diariesDeferred.await(), + dailyQuestions = dailyQuestionsDeferred.await(), + ) + } + } + + /** 툴바 카운트용 — 개수만 필요할 때. */ + suspend fun count(): Result = load().map { it.total } + + data class Drafts( + val diaries: List, + val dailyQuestions: List, + ) { + val total: Int get() = diaries.size + dailyQuestions.size + } + } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt index e0c628e9eb..6e8e050420 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt @@ -31,6 +31,8 @@ internal fun resolveDateInWeekOrNull( */ internal data class DailyRecordSummary( val isDiary: Boolean, + /** 그 날짜에 **기록일수로 셀 수 있는** 기록이 하나라도 있었는지 (#590). */ + val countsAsRecord: Boolean, val emotion: TodayMood?, ) @@ -56,6 +58,7 @@ internal fun aggregateWeekRecordsByDate( .mapValues { (_, days) -> DailyRecordSummary( isDiary = days.any { it.isDiary }, + countsAsRecord = days.any { it.countsAsRecord }, emotion = days .filter { it.emotion != null } @@ -65,10 +68,19 @@ internal fun aggregateWeekRecordsByDate( } /** - * 기록일수 = 일기가 있는 날 + 주간 범위 내 데일리질문 답변 날짜의 합집합(중복 제거). + * 기록일수 = `week[]` 에 기록이 있는 날 + 주간 범위 내 데일리질문 답변 날짜의 합집합(중복 제거). * * 두 출처를 모두 `LocalDate` 로 복원한 뒤 합쳐야 같은 날의 일기·데일리질문이 * `distinct()` 로 1일로 접힌다. + * + * **일기만 세지 않는다.** 종전에는 `isDiary` 인 날만 셌는데, `week[]` 는 일기 외에 + * `DAILY_QUESTION` 원소도 싣는다 (실서버 대조 2026-08-23, #590). 문구가 세는 것은 + * "일기를 쓴 날" 이 아니라 "마음을 기록한 날" 이다. + * + * 다만 **깊은 생각은 제외한다** — 기획에서 제거된 기능이라 서버가 계속 내려줘도 앱은 + * 없는 것으로 다룬다. 그 판단은 매퍼가 [WeeklyReportDay.countsAsRecord] 로 접어 둔다. + * + * (`isDiary` 는 계속 달력 점 표시에만 쓴다 — [aggregateWeekRecordsByDate].) */ internal fun countRecordedDays( monday: LocalDate, @@ -76,10 +88,10 @@ internal fun countRecordedDays( dailyQuestionDates: List, ): Int { val sunday = monday.plusDays(WEEK_LENGTH - 1L) - val diaryDates = + val recordDates = aggregateWeekRecordsByDate(monday, week) - .filterValues { it.isDiary } + .filterValues { it.countsAsRecord } .keys val questionDatesInWeek = dailyQuestionDates.filter { it in monday..sunday } - return (diaryDates + questionDatesInWeek).distinct().size + return (recordDates + questionDatesInWeek).distinct().size } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt index ae05e405cc..3de7bc5a33 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt @@ -1,7 +1,9 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.TodayMood @@ -29,7 +31,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.time.DayOfWeek import java.time.LocalDate import java.time.format.DateTimeFormatter import javax.inject.Inject @@ -42,7 +43,7 @@ class WeeklyReportViewModel private val userRepository: UserRepository, ) : ViewModel() { private val weekOptions: List = - buildWeekOptions(today = LocalDate.now(), count = WEEK_OPTION_COUNT) + buildWeekOptions(today = LocalDate.now()) private val internalState = MutableStateFlow(InternalState()) private var loadJob: Job? = null @@ -89,7 +90,7 @@ class WeeklyReportViewModel internalState.update { it.copy(loadPhase = LoadPhase.Loading) } } val result = - runCatching { + runCatchingCancellable { coroutineScope { val reportDeferred = async { @@ -101,8 +102,9 @@ class WeeklyReportViewModel reportDeferred.await() to profileDeferred.await() } } - // runCatching 이 CancellationException 까지 실패로 잡는다. // 새 로드가 이 Job 을 취소했다면 상태는 그쪽이 결정하므로 여기서 멈춘다. + // `runCatchingCancellable` 이 취소를 다시 던지므로 위에서 이미 빠져나가지만, + // `await()` 사이에 취소가 들어온 경우를 위해 남겨 둔다. ensureActive() result .onSuccess { (report, profile) -> @@ -129,16 +131,6 @@ class WeeklyReportViewModel } } - private fun buildWeekOptions( - today: LocalDate, - count: Int, - ): List { - val thisMonday = today.with(DayOfWeek.MONDAY) - return (0 until count).map { weeksAgo -> - WeekOption(monday = thisMonday.minusWeeks(weeksAgo.toLong())) - } - } - /** * 월~일 7칸을 만든다. * @@ -161,9 +153,13 @@ class WeeklyReportViewModel dayOfWeek = date.dayOfWeek, content = when { - emoji != null && isDiary -> DayContent.EmojiWithDot(emoji) + // 이모지와 점은 배타적이다 (#749). 감정을 고른 날은 이모지만 그린다 — + // 종전에는 `emoji != null && isDiary` 가 먼저 걸려, 일기를 쓰고 감정까지 + // 고른 가장 흔한 경우에 점이 함께 붙었다. emoji != null -> DayContent.EmojiOnly(emoji) + isDiary -> DayContent.NumberWithDot(date.dayOfMonth) + else -> DayContent.NumberOnly(date.dayOfMonth) }, background = @@ -223,13 +219,11 @@ class WeeklyReportViewModel weekDays = mapWeekDays(monday, report.week), emotionKeywords = mapEmotionKeywords(report.emotions), summaryText = report.summaryText, - dailyQuestions = report.dailyQuestions.map { it.toUi() }, + dailyQuestions = report.dailyQuestions.mapNotNull { it.toUi() }, ) } companion object { - private const val WEEK_OPTION_COUNT = 5 - private val API_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") private val RANGE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.") @@ -242,6 +236,8 @@ class WeeklyReportViewModel // 카드 측에서 키워드 개수(0~4)에 따라 슬롯(size·offset·color)을 결정하므로, // 여기선 percentage 내림차순으로 정렬해 최대 4건만 잘라 키워드·카운트만 노출한다. + private const val TAG = "WeeklyReportViewModel" + private const val MAX_EMOTION_KEYWORDS = 4 private fun mapEmotionKeywords(emotions: List): List = @@ -250,12 +246,24 @@ class WeeklyReportViewModel .take(MAX_EMOTION_KEYWORDS) .map { EmotionKeyword(keyword = it.keyword, count = it.percentage) } - private fun WeeklyReportDailyQuestion.toUi(): DailyQuestion = - DailyQuestion( + /** + * 날짜를 못 정하면 `null` 을 돌린다 — 호출부가 그 항목을 목록에서 뺀다. + * + * 오늘 날짜로 메우면 지난 주 답변이 오늘로 표시되고 실패 신호가 남지 않는다 (#751). + * 서버 명세에 `date` 형식이 정의돼 있지 않아 언제든 어긋날 수 있다. + */ + private fun WeeklyReportDailyQuestion.toUi(): DailyQuestion? { + val resolvedDate = + parseLocalDateOrNull(date) ?: run { + Log.w(TAG, "주간리포트 데일리질문 날짜를 해석하지 못해 목록에서 제외한다: raw=$date") + return null + } + return DailyQuestion( title = title, - date = parseLocalDate(date), + date = resolvedDate, content = content, ) + } // 서버는 "yyyy.MM.dd 요일" 또는 ISO 포맷으로 내려옴 — 둘 다 허용. private val DATE_FORMATTERS: List = @@ -264,8 +272,6 @@ class WeeklyReportViewModel DateTimeFormatter.ISO_DATE, ) - private fun parseLocalDate(raw: String): LocalDate = parseLocalDateOrNull(raw) ?: LocalDate.now() - private fun parseLocalDateOrNull(raw: String): LocalDate? { val datePart = raw.substringBefore(' ').trim() for (formatter in DATE_FORMATTERS) { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptions.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptions.kt new file mode 100644 index 0000000000..58ddaa8893 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptions.kt @@ -0,0 +1,32 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import java.time.DayOfWeek +import java.time.LocalDate + +/** + * 주차 드롭다운이 제공하는 과거 리포트 범위 (이번 주 포함 52주 = 1년). + * + * 종전 값 `5` 는 "한 화면에 보이는 항목 수" 를 그대로 선택지 개수로 쓴 것이라, 시안이 + * 표현한 추가 과거 리포트와 세로 스크롤을 쓸 수 없었다 (#729). **가시 항목 수는 메뉴 + * 높이가 정하고**(`WeeklyReportReviewCard`), 여기서는 선택 가능한 범위만 정한다. + * + * 서버에 가입일이 없어(`GET /users/me` 는 이름·이메일·전화·프로필만 준다) 계정 생성 주까지로 + * 좁힐 수 없다. 기록이 없는 주를 골라도 리포트는 0 건으로 정상 응답하므로 1년을 상한으로 뒀다. + */ +internal const val WEEK_OPTION_COUNT = 52 + +/** + * 이번 주 월요일부터 과거로 [count] 주를 최신순으로 만든다. + * + * 첫 원소가 언제나 이번 주라 진입 시 로드 대상([WeeklyReportViewModel.init])과 + * 실패 시 폴백 기준이 된다. + */ +internal fun buildWeekOptions( + today: LocalDate, + count: Int = WEEK_OPTION_COUNT, +): List { + val thisMonday = today.with(DayOfWeek.MONDAY) + return (0 until count).map { weeksAgo -> + WeekOption(monday = thisMonday.minusWeeks(weeksAgo.toLong())) + } +} diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 66dce6e8af..5907be05c9 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -12,9 +12,13 @@ MEMORY SPACE - 마우스를 움직여 공간을 탐색하세요 + 화면을 드래그해 공간을 둘러보세요 돌아가기 그날의 기록 다시 읽기 + 기록을 불러오는 중이에요… + 아직 담긴 기록이 없어요.\n일기나 데일리 질문을 남기면 이곳에 쌓입니다. + 기록을 불러오지 못했습니다. + 다시 시도하기 등록 @@ -64,7 +68,11 @@ 목 금 토 - 발송 날짜 + + 기록 날짜 오늘 하루, \n누구에게 가장 고마웠나요? @@ -81,9 +89,11 @@ 😊 좋음 %1$d월 %2$d주차 리포트 11월 2주차 리포트 - 이번 주, - 님은 - 의 마음을 기록하셨네요. + + 이번 주, %1$s 님은 %2$s의 마음을 기록하셨네요. %d일 이번 주는 차분히 마음을 정리한 한 주였어요. @@ -102,9 +112,11 @@ 아직 등록된 일기가 없어요. 일기를 작성해 나의 매일을 기록해 보아요. - - \"내 인생에서 가장 소중했던 순간은?\" - - 아이가 태어났을 때... + 임시 저장된 기록 @@ -119,6 +131,8 @@ 전체 삭제 선택 삭제 임시 저장된 기록이 삭제 되었습니다 + + %1$d개를 삭제하지 못했어요. 다시 시도해 주세요. 임시 저장된 항목이 없습니다. diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportWeekMenuTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportWeekMenuTest.kt new file mode 100644 index 0000000000..56bbd59f22 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportWeekMenuTest.kt @@ -0,0 +1,82 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.viewmodel.WeekOption +import com.afternote.feature.mindrecord.presentation.viewmodel.buildWeekOptions +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate + +/** + * 열린 주차 메뉴의 스크롤·최하단 선택 회귀 가드 (#729). + * + * 시안(node 700-35071)의 열린 메뉴는 항목 5개 높이에서 잘리고 오른쪽에 세로 스크롤 + * 표시가 있다. 선택지가 5개에서 끝나면 스크롤할 것이 없어 이 시연 자체가 불가능하다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WeeklyReportWeekMenuTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val today = LocalDate.of(2026, 8, 23) + private val options: List = buildWeekOptions(today = today) + + private fun label(monday: LocalDate): String = "${monday.monthValue}월 ${(monday.dayOfMonth - 1) / 7 + 1}주차 리포트" + + private fun openMenu(onWeekSelect: (LocalDate) -> Unit = {}) { + composeRule.setContent { + AfternoteTheme { + WeeklyReportReviewCard( + selectedMonday = options.first().monday, + weekOptions = options, + onWeekSelect = onWeekSelect, + ) + } + } + composeRule.onNodeWithText(label(options.first().monday)).performClick() + } + + @Test + fun `열린 메뉴에 5개를 넘는 선택지가 실린다`() { + openMenu() + + // 6번째 항목은 메뉴 높이 밖이라 스크롤해야 닿는다 — 존재 자체는 트리에 있다. + composeRule.onNodeWithText(label(options[5].monday)).performScrollTo().assertIsDisplayed() + } + + @Test + fun `스크롤해서 최하단 주차를 고르면 그 월요일이 전달된다`() { + var selected: LocalDate? = null + openMenu { selected = it } + + val bottom = options.last().monday + composeRule.onNodeWithText(label(bottom)).performScrollTo().performClick() + + assertEquals(bottom, selected) + assertEquals(LocalDate.of(2026, 8, 17).minusWeeks(51), selected) + } + + @Test + fun `이미 보고 있는 주를 다시 고르면 재조회하지 않는다`() { + var calls = 0 + openMenu { calls++ } + + // 같은 문구가 앵커와 메뉴 항목 두 곳에 있다 — 메뉴 쪽(마지막)을 누른다. + val nodes = composeRule.onAllNodesWithText(label(options.first().monday)) + nodes[nodes.fetchSemanticsNodes().lastIndex].performClick() + + assertEquals(0, calls) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt new file mode 100644 index 0000000000..e5f8f5d6b6 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt @@ -0,0 +1,63 @@ +package com.afternote.feature.mindrecord.presentation.mapper + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import com.afternote.feature.mindrecord.domain.model.DailyQuestion as DailyQuestionDomain + +/** + * 날짜 파싱 실패가 **오늘 날짜로 둔갑하지 않는지** 고정한다 (#751). + * + * 종전에는 `parseLocalDateOrNull(raw) ?: LocalDate.now()` 라, 서버가 형식을 바꾸면 지난 주 + * 답변이 오늘 날짜로 표시되고 실패 신호가 어디에도 남지 않았다. "날짜가 이상하다" 는 제보를 + * 받아도 재현 지점을 찾을 수 없는 종류다. + * + * `Log.w` 를 타므로 Robolectric 으로 돌린다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DailyQuestionDateFallbackTest { + @Test + fun `날짜를 해석할 수 없으면 오늘로 메우지 않고 항목을 버린다`() { + val ui = dailyQuestion(createdAt = "언젠가").toUi() + + assertNull(ui) + } + + @Test + fun `빈 날짜도 오늘로 메우지 않는다`() { + val ui = dailyQuestion(createdAt = "").toUi() + + assertNull(ui) + } + + @Test + fun `요일이 붙은 서버 형식은 그대로 해석한다`() { + val ui = dailyQuestion(createdAt = "2026.07.29 수").toUi() + + assertNotNull(ui) + assertEquals(LocalDate.of(2026, 7, 29), ui!!.date) + } + + @Test + fun `ISO 형식도 해석한다`() { + val ui = dailyQuestion(createdAt = "2026-07-29").toUi() + + assertEquals(LocalDate.of(2026, 7, 29), ui!!.date) + } + + private fun dailyQuestion(createdAt: String) = + DailyQuestionDomain( + dailyQuestionId = 1L, + title = "질문", + content = "답변", + createdAt = createdAt, + isDraft = false, + imageUrl = null, + ) +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt index e6ac127d37..67aec8d37d 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt @@ -24,7 +24,7 @@ class MindRecordUiMapperTest { @Test fun `date 가 없으면 createdAt 으로 폴백한다`() { - val ui = diary(date = null, createdAt = "2026.03.25 수").toUi() + val ui = diary(date = "", createdAt = "2026.03.25 수").toUi() assertEquals(LocalDate.of(2026, 3, 25), ui?.date) } @@ -32,15 +32,16 @@ class MindRecordUiMapperTest { @Test fun `ISO 날짜시각 createdAt 도 파싱된다`() { // Swagger DiaryResponse 예시가 `createdAt: "2026-03-25T20:13:42"` 형태다. - val ui = diary(date = null, createdAt = "2026-03-25T20:13:42").toUi() + val ui = diary(date = "", createdAt = "2026-03-25T20:13:42").toUi() assertEquals(LocalDate.of(2026, 3, 25), ui?.date) } @Test fun `두 날짜가 모두 없으면 오늘로 메우지 않고 항목을 뺀다`() { - // createdAt 은 이 PR 에서 기본값 ""를 얻었으므로 둘 다 비는 응답이 실제로 가능하다. - assertNull(diary(date = null, createdAt = "").toUi()) + // #789 이후 두 필드 모두 계약이라 빈 응답은 파싱 단계에서 걸러지지만, 표시 단계가 + // 여전히 날짜 없는 항목을 오늘로 메우지 않는다는 것은 그대로 고정해 둔다. + assertNull(diary(date = "", createdAt = "").toUi()) } @Test @@ -49,7 +50,7 @@ class MindRecordUiMapperTest { } private fun diary( - date: String?, + date: String, createdAt: String, ) = Diary( diaryId = 1L, diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportSummaryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportSummaryTest.kt new file mode 100644 index 0000000000..402504d101 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportSummaryTest.kt @@ -0,0 +1,62 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import com.afternote.feature.mindrecord.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 주간리포트 요약 문구의 공백을 **컴파일된 리소스**로 고정한다 (#732). + * + * 소스 XML 만 보면 통과하는 종류의 결함이다 — 조각 리소스에 앞뒤 공백을 두면 aapt2 가 그것을 + * 지워 APK 에서만 "이번 주,박서연님은3일의…" 로 붙는다. 그래서 Robolectric 으로 실제 리소스를 + * 읽어 확인한다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WeeklyReportSummaryTest { + private val context: android.content.Context = org.robolectric.RuntimeEnvironment.getApplication() + + @Test + fun `요약 문구는 시안대로 공백을 유지한다`() { + val daysText = context.getString(R.string.mindrecord_weekly_report_days_format, 3) + val sentence = context.getString(R.string.mindrecord_weekly_report_recorded, "박서연", daysText) + + assertEquals("이번 주, 박서연 님은 3일의 마음을 기록하셨네요.", sentence) + } + + @Test + fun `이름과 기록일수 구간을 각각 찾는다`() { + val sentence = "이번 주, 박서연 님은 3일의 마음을 기록하셨네요." + + val highlights = recordedSummaryHighlights(sentence, userName = "박서연", daysText = "3일") + + assertEquals(2, highlights.size) + assertEquals("박서연", sentence.substring(highlights[0].first, highlights[0].last + 1)) + assertEquals("3일", sentence.substring(highlights[1].first, highlights[1].last + 1)) + } + + @Test + fun `이름에 기록일수와 같은 문자열이 들어 있어도 구간이 겹치지 않는다`() { + val sentence = "이번 주, 3일 님은 3일의 마음을 기록하셨네요." + + val highlights = recordedSummaryHighlights(sentence, userName = "3일", daysText = "3일") + + assertEquals(2, highlights.size) + // 이름은 첫 번째 "3일", 기록일수는 그 뒤의 "3일" — 같은 구간을 두 번 잡지 않는다. + assertEquals(6, highlights[0].first) + assertEquals(12, highlights[1].first) + } + + @Test + fun `이름이 비어 있으면 기록일수만 강조한다`() { + val sentence = "이번 주, 님은 3일의 마음을 기록하셨네요." + + val highlights = recordedSummaryHighlights(sentence, userName = "", daysText = "3일") + + assertEquals(1, highlights.size) + assertEquals("3일", sentence.substring(highlights[0].first, highlights[0].last + 1)) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index e266863d89..c77902d7ad 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -4,8 +4,12 @@ import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -53,7 +57,7 @@ class DailyQuestionWriteViewModelTest { }, onGetList = { Result.success(listOf(draft(content = "서버에 남아 있던 옛 임시저장본"))) }, ) - val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository) + val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository, noopDraftLoader()) viewModel.onAnswerChanged("사용자가 방금 입력한 답변") viewModel.submit() @@ -72,7 +76,7 @@ class DailyQuestionWriteViewModelTest { onGetList = { Result.success(listOf(draft(content = "이어쓸 본문"))) }, ) - val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository) + val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository, noopDraftLoader()) assertEquals("이어쓸 본문", viewModel.uiState.value.answer) assertEquals(7L, viewModel.uiState.value.draftId) @@ -99,6 +103,7 @@ class DailyQuestionWriteViewModelTest { DailyQuestionWriteViewModel( repository, PhotoUploadRepository { _, _ -> Result.success("https://cdn/just-picked.jpg") }, + noopDraftLoader(), ) viewModel.onAnswerChanged("사용자가 방금 입력한 답변") @@ -118,7 +123,7 @@ class DailyQuestionWriteViewModelTest { }, ) - val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository) + val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository, noopDraftLoader()) assertEquals("https://cdn/old.jpg", viewModel.uiState.value.imageUrl) } @@ -130,7 +135,7 @@ class DailyQuestionWriteViewModelTest { onGetToday = { Result.failure(IllegalStateException("네트워크 실패")) }, onGetList = { Result.success(emptyList()) }, ) - val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository) + val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository, noopDraftLoader()) viewModel.onAnswerChanged("답변") viewModel.submit() @@ -176,15 +181,16 @@ private class FakeDailyQuestionRepository( override suspend fun getToday(): Result = onGetToday() - override suspend fun create(payload: DailyQuestionCreatePayload): Result { + override suspend fun create(payload: DailyQuestionCreatePayload): Result { createCallCount += 1 - return Result.success(Unit) + // 서버가 돌려주는 "내 답변" 식별자 (#573). + return Result.success(CREATED_ANSWER_ID) } override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = error("update 는 이 시나리오에서 호출되면 안 됨") + ): Result = error("update 는 이 시나리오에서 호출되면 안 됨") override suspend fun delete(id: Long): Result = error("delete 는 이 시나리오에서 호출되면 안 됨") } @@ -195,3 +201,46 @@ private object NoopPhotoUploadRepository : PhotoUploadRepository { directory: String, ): Result = error("upload 는 이 시나리오에서 호출되면 안 됨") } + +private const val CREATED_ANSWER_ID = 19L + +/** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ +private fun noopDraftLoader() = + MindRecordDraftLoader( + diaryRepository = EmptyDraftDiaryRepository, + dailyQuestionRepository = EmptyDraftDailyQuestionRepository, + ) + +private object EmptyDraftDiaryRepository : DiaryRepository { + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + + override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} + +private object EmptyDraftDailyQuestionRepository : DailyQuestionRepository { + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> = Result.success(emptyList()) + + override suspend fun getToday(): Result = error("호출되면 안 됨") + + override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt new file mode 100644 index 0000000000..7f66c2bb0e --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -0,0 +1,166 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * 삭제 실패가 완료로 보고되지 않는지 고정한다 (#442). + * + * 항목별 `delete()` 는 실패를 던지지 않고 `Result.failure` 로 감싸므로, 결과를 검사하지 않으면 + * 서버가 거절한 항목이 목록에 남은 채 완료 토스트가 뜬다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DraftListDeleteTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `삭제에 실패하고 항목이 남아 있으면 완료가 아니라 실패로 알린다`() { + val stubborn = dailyQuestion(id = 1L) + val repository = + FakeDailyQuestionDraftRepository( + drafts = listOf(stubborn), + // 서버가 거절해 항목이 그대로 남는 상황. + deleteResult = Result.failure(IllegalStateException("서버 거절")), + removesOnDelete = false, + ) + val viewModel = viewModel(repository) + val target = (viewModel.uiState.value as DraftListUiState.Success).items + + viewModel.delete(target) + + val state = viewModel.uiState.value as DraftListUiState.Success + val outcome = state.deleteOutcome + assertTrue("실패했는데 완료로 보고됨: $outcome", outcome is DraftDeleteOutcome.SomeFailed) + assertEquals(1, (outcome as DraftDeleteOutcome.SomeFailed).failedItems.size) + assertEquals(1, state.items.size) + } + + @Test + fun `삭제가 실패했어도 항목이 사라졌으면 완료로 본다`() { + val repository = + FakeDailyQuestionDraftRepository( + drafts = listOf(dailyQuestion(id = 1L)), + // 이미 없는 항목을 지우려다 404 — 사용자가 원한 결과는 이뤄졌다. + deleteResult = Result.failure(IllegalStateException("404")), + removesOnDelete = true, + ) + val viewModel = viewModel(repository) + val target = (viewModel.uiState.value as DraftListUiState.Success).items + + viewModel.delete(target) + + val state = viewModel.uiState.value as DraftListUiState.Success + assertEquals(DraftDeleteOutcome.AllDeleted, state.deleteOutcome) + assertTrue(state.items.isEmpty()) + } + + @Test + fun `모두 성공하면 완료로 알린다`() { + val repository = + FakeDailyQuestionDraftRepository( + drafts = listOf(dailyQuestion(id = 1L)), + deleteResult = Result.success(Unit), + removesOnDelete = true, + ) + val viewModel = viewModel(repository) + val target = (viewModel.uiState.value as DraftListUiState.Success).items + + viewModel.delete(target) + + val state = viewModel.uiState.value as DraftListUiState.Success + assertEquals(DraftDeleteOutcome.AllDeleted, state.deleteOutcome) + assertTrue(state.items.isEmpty()) + } + + private fun viewModel(dailyQuestionRepository: DailyQuestionRepository): DraftListViewModel = + DraftListViewModel( + // #769 가 목록 조회를 loader 로 뽑아낸 뒤 생성자가 3개로 늘었다. 같은 두 + // 저장소를 넘겨 종전과 같은 조회 경로를 그대로 태운다. + loader = + MindRecordDraftLoader( + diaryRepository = EmptyDiaryDraftRepository, + dailyQuestionRepository = dailyQuestionRepository, + ), + diaryRepository = EmptyDiaryDraftRepository, + dailyQuestionRepository = dailyQuestionRepository, + ) + + private fun dailyQuestion(id: Long) = + DailyQuestion( + dailyQuestionId = id, + title = "질문", + content = "임시저장 본문", + createdAt = "2026-08-23T10:00:00", + isDraft = true, + imageUrl = null, + ) +} + +private class FakeDailyQuestionDraftRepository( + drafts: List, + private val deleteResult: Result, + private val removesOnDelete: Boolean, +) : DailyQuestionRepository { + private val remaining = drafts.toMutableList() + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> = Result.success(remaining.toList()) + + override suspend fun getToday(): Result = error("호출되면 안 됨") + + override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result { + if (removesOnDelete) remaining.removeAll { it.dailyQuestionId == id } + return deleteResult + } +} + +private object EmptyDiaryDraftRepository : DiaryRepository { + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + + override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt index 902634d84e..7100c5ce37 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt @@ -90,7 +90,8 @@ class WeeklyReportRecordedDaysTest { } @Test - fun `isDiary 가 false 인 원소는 일기 기록일로 세지 않는다`() { + fun `일기가 아닌 기록도 기록일로 센다`() { + // #590 회귀 — week[] 는 일기 외 종류도 싣는다. isDiary 로 거르면 그 날이 사라진다. val monday = LocalDate.of(2026, 7, 27) val recordedDays = @@ -100,9 +101,83 @@ class WeeklyReportRecordedDaysTest { dailyQuestionDates = emptyList(), ) + assertEquals(1, recordedDays) + } + + @Test + fun `깊은 생각만 있는 날은 세지 않는다`() { + // 기획에서 제거된 기능이라 서버가 week[] 에 계속 실어 보내도 앱은 없는 것으로 다룬다. + val monday = LocalDate.of(2026, 7, 27) + + val recordedDays = + countRecordedDays( + monday = monday, + week = listOf(diaryDay(day = 28, isDiary = false, countsAsRecord = false)), + dailyQuestionDates = emptyList(), + ) + assertEquals(0, recordedDays) } + @Test + fun `같은 날에 깊은 생각과 일기가 함께 있으면 센다`() { + // 제외 대상은 "깊은 생각뿐인 날" 이지 그 날짜 자체가 아니다. + val monday = LocalDate.of(2026, 7, 27) + + val recordedDays = + countRecordedDays( + monday = monday, + week = + listOf( + diaryDay(day = 28, isDiary = false, countsAsRecord = false), + diaryDay(day = 28), + ), + dailyQuestionDates = emptyList(), + ) + + assertEquals(1, recordedDays) + } + + @Test + fun `일기가 아닌 기록도 종류를 가리지 않고 날짜 단위로 센다`() { + // 문구가 세는 것은 "일기를 쓴 날" 이 아니라 "마음을 기록한 날" 이다. + val monday = LocalDate.of(2026, 7, 27) + + val recordedDays = + countRecordedDays( + monday = monday, + week = listOf(diaryDay(day = 28, isDiary = false), diaryDay(day = 30)), + dailyQuestionDates = emptyList(), + ) + + assertEquals(2, recordedDays) + } + + @Test + fun `week 의 비일기 기록과 같은 날 데일리질문은 1일로 접힌다`() { + // 두 출처가 같은 날을 가리키면 중복 제거된다 — 종류를 안 가려도 이중 계산은 없다. + val monday = LocalDate.of(2026, 7, 27) + + val recordedDays = + countRecordedDays( + monday = monday, + week = listOf(diaryDay(day = 28, isDiary = false)), + dailyQuestionDates = listOf(LocalDate.of(2026, 7, 28)), + ) + + assertEquals(1, recordedDays) + } + + @Test + fun `종류를 가리지 않아도 달력 점은 여전히 일기만 찍는다`() { + // isDiary 는 집계에서 빠졌을 뿐 표시 규칙에는 그대로 남는다. + val monday = LocalDate.of(2026, 7, 27) + + val byDate = aggregateWeekRecordsByDate(monday, listOf(diaryDay(day = 28, isDiary = false))) + + assertEquals(false, byDate.getValue(LocalDate.of(2026, 7, 28)).isDiary) + } + @Test fun `빈 week 응답은 데일리질문만으로 집계된다`() { val monday = LocalDate.of(2026, 7, 27) @@ -135,8 +210,8 @@ class WeeklyReportRecordedDaysTest { val monday = LocalDate.of(2026, 7, 27) val week = listOf( - WeeklyReportDay(diaryId = 1, day = 28, isDiary = false, emotion = null), - WeeklyReportDay(diaryId = 2, day = 28, isDiary = true, emotion = TodayMood.SAD), + WeeklyReportDay(diaryId = 1, day = 28, isDiary = false, countsAsRecord = true, emotion = null), + WeeklyReportDay(diaryId = 2, day = 28, isDiary = true, countsAsRecord = true, emotion = TodayMood.SAD), ) val byDate = aggregateWeekRecordsByDate(monday, week) @@ -152,8 +227,8 @@ class WeeklyReportRecordedDaysTest { // 명세에 week[] 순서 의미도 같은 날짜 복수 원소의 병합 규칙도 없다 (Afternote-BE#131). // 순서에 의존하면 서버가 정렬만 바꿔도 화면 이모지가 바뀐다 — diaryId 최대값으로 고정한다. val monday = LocalDate.of(2026, 7, 27) - val older = WeeklyReportDay(diaryId = 1, day = 28, isDiary = true, emotion = TodayMood.HAPPY) - val newer = WeeklyReportDay(diaryId = 2, day = 28, isDiary = true, emotion = TodayMood.SAD) + val older = WeeklyReportDay(diaryId = 1, day = 28, isDiary = true, countsAsRecord = true, emotion = TodayMood.HAPPY) + val newer = WeeklyReportDay(diaryId = 2, day = 28, isDiary = true, countsAsRecord = true, emotion = TodayMood.SAD) val ascending = aggregateWeekRecordsByDate(monday, listOf(older, newer)) val descending = aggregateWeekRecordsByDate(monday, listOf(newer, older)) @@ -167,8 +242,8 @@ class WeeklyReportRecordedDaysTest { val monday = LocalDate.of(2026, 7, 27) val week = listOf( - WeeklyReportDay(diaryId = 9, day = 28, isDiary = true, emotion = null), - WeeklyReportDay(diaryId = 1, day = 28, isDiary = false, emotion = TodayMood.HAPPY), + WeeklyReportDay(diaryId = 9, day = 28, isDiary = true, countsAsRecord = true, emotion = null), + WeeklyReportDay(diaryId = 1, day = 28, isDiary = false, countsAsRecord = true, emotion = TodayMood.HAPPY), ) val byDate = aggregateWeekRecordsByDate(monday, week) @@ -188,11 +263,13 @@ class WeeklyReportRecordedDaysTest { private fun diaryDay( day: Int, isDiary: Boolean = true, + countsAsRecord: Boolean = true, ): WeeklyReportDay = WeeklyReportDay( diaryId = day.toLong(), day = day, isDiary = isDiary, + countsAsRecord = countsAsRecord, emotion = TodayMood.HAPPY, ) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptionsTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptionsTest.kt new file mode 100644 index 0000000000..edb88c2bce --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekOptionsTest.kt @@ -0,0 +1,52 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate + +/** + * 주차 선택지 범위 회귀 가드 (#729). + * + * 종전에는 "한 화면에 보이는 항목 수" 5 를 그대로 선택지 개수로 써서, 시안이 표현한 + * 추가 과거 리포트와 세로 스크롤을 쓸 수 없었다. 가시 항목 수와 선택 가능한 범위를 + * 분리했다는 사실을 여기서 고정한다. + */ +class WeeklyReportWeekOptionsTest { + @Test + fun `선택지는 한 화면 가시 항목 수보다 많다`() { + // 5 로 되돌리면 여기서 먼저 깨진다 — 스크롤할 항목이 없어지는 지점이다. + assertTrue("선택지가 가시 항목 수(5)를 넘어야 스크롤이 생긴다", WEEK_OPTION_COUNT > 5) + assertEquals(52, WEEK_OPTION_COUNT) + } + + @Test + fun `이번 주 월요일부터 과거로 최신순으로 만든다`() { + // 2026-08-23 은 일요일 — 그 주 월요일은 2026-08-17 이다. + val options = buildWeekOptions(today = LocalDate.of(2026, 8, 23)) + + assertEquals(WEEK_OPTION_COUNT, options.size) + assertEquals(LocalDate.of(2026, 8, 17), options.first().monday) + assertTrue(options.all { it.monday.dayOfWeek == DayOfWeek.MONDAY }) + assertEquals( + options.map { it.monday }.sortedDescending(), + options.map { it.monday }, + ) + } + + @Test + fun `최하단 선택지는 1년 전 주다`() { + val options = buildWeekOptions(today = LocalDate.of(2026, 8, 23)) + + assertEquals(LocalDate.of(2026, 8, 17).minusWeeks(51), options.last().monday) + } + + @Test + fun `월요일에 들어와도 그 주가 첫 선택지다`() { + // `with(DayOfWeek.MONDAY)` 는 그 주의 월요일이라 월요일 당일이면 자기 자신이다. + val options = buildWeekOptions(today = LocalDate.of(2026, 8, 17)) + + assertEquals(LocalDate.of(2026, 8, 17), options.first().monday) + } +} diff --git a/feature/receiver/data/build.gradle.kts b/feature/receiver/data/build.gradle.kts index 7dfe8be94c..5777c4f4fb 100644 --- a/feature/receiver/data/build.gradle.kts +++ b/feature/receiver/data/build.gradle.kts @@ -8,8 +8,13 @@ android { dependencies { implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.paging.runtime) implementation(projects.core.common) implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.feature.receiver.domain) + // 수신 상세가 애프터노트 도메인 모델·공유 매퍼(MappingUtils 등)·author 서비스(내려받기)를 + // 참조한다. 공유 지점의 receiver 몫 분리는 후속 판단. + implementation(projects.feature.afternote.domain) + implementation(projects.feature.afternote.data) } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteReceiverRepositoryModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/AfternoteReceiverRepositoryModule.kt similarity index 64% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteReceiverRepositoryModule.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/AfternoteReceiverRepositoryModule.kt index 29e10fd8d0..79a1b7ad56 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteReceiverRepositoryModule.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/AfternoteReceiverRepositoryModule.kt @@ -1,9 +1,9 @@ -package com.afternote.feature.afternote.data.di +package com.afternote.feature.receiver.data.di -import com.afternote.feature.afternote.data.repositoryimpl.receiver.IdentityVerificationRepositoryImpl -import com.afternote.feature.afternote.data.repositoryimpl.receiver.ReceiverRepositoryImpl -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository +import com.afternote.feature.receiver.data.repositoryimpl.IdentityVerificationRepositoryImpl +import com.afternote.feature.receiver.data.repositoryimpl.ReceiverRepositoryImpl import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository +import com.afternote.feature.receiver.domain.repository.ReceiverRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStore.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStore.kt similarity index 84% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStore.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStore.kt index a1af1b0c7b..5c3842d55f 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStore.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStore.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.di +package com.afternote.feature.receiver.data.di import javax.inject.Qualifier diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStoreModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStoreModule.kt new file mode 100644 index 0000000000..868244b318 --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStoreModule.kt @@ -0,0 +1,25 @@ +package com.afternote.feature.receiver.data.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object IdentityVerificationDataStoreModule { + @Provides + @Singleton + @IdentityVerificationDataStore + fun provideIdentityVerificationDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 — 바꾸면 기존 사용자의 본인인증 상태가 끊긴다 (#912 필수 주의). + // SESSION 인 이유 — 인증 기록이 이 로컬 boolean 뿐이라 계정 전환을 감지하지 못하므로, + // 로그아웃 시 지워서 다음 계정이 이전 사용자의 본인인증 완료 상태를 승계하지 못하게 + // 한다. 같은 사람의 재로그인 시 재인증은 감수한다 (#912). + registry.store(name = "afternote_identity_verification", scope = StoreScope.SESSION) +} diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStore.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthCodeDataStore.kt similarity index 83% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStore.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthCodeDataStore.kt index a6f063da4b..047c9cea21 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStore.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthCodeDataStore.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.di +package com.afternote.feature.receiver.data.di import javax.inject.Qualifier diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthCodeDataStoreModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthCodeDataStoreModule.kt new file mode 100644 index 0000000000..092fbdf583 --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthCodeDataStoreModule.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.receiver.data.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object ReceiverAuthCodeDataStoreModule { + @Provides + @Singleton + @ReceiverAuthCodeDataStore + fun provideReceiverAuthCodeDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 (#912 필수 주의). 구 SharedPreferences XML 과 같은 이름이지만 + // 자동 마이그레이션하지 않는다는 기존 결정이 있다 — 마이그레이션을 붙이지 말 것. + registry.store(name = "afternote_receiver_auth", scope = StoreScope.SESSION) +} diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthInterceptorModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthInterceptorModule.kt similarity index 79% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthInterceptorModule.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthInterceptorModule.kt index 81d3438419..73613b0e4a 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthInterceptorModule.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthInterceptorModule.kt @@ -1,7 +1,7 @@ -package com.afternote.feature.afternote.data.di +package com.afternote.feature.receiver.data.di import com.afternote.core.network.interceptor.FeatureNetworkInterceptor -import com.afternote.feature.afternote.data.network.ReceiverAuthInterceptor +import com.afternote.feature.receiver.data.network.ReceiverAuthInterceptor import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthRepositoryModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthRepositoryModule.kt similarity index 75% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthRepositoryModule.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthRepositoryModule.kt index 711e7bbec4..98be78c430 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthRepositoryModule.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverAuthRepositoryModule.kt @@ -1,6 +1,6 @@ -package com.afternote.feature.afternote.data.di +package com.afternote.feature.receiver.data.di -import com.afternote.feature.afternote.data.repositoryimpl.receiver.ReceiverAuthRepositoryImpl +import com.afternote.feature.receiver.data.repositoryimpl.ReceiverAuthRepositoryImpl import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import dagger.Binds import dagger.Module diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverDeliveryDocumentUploadRepositoryModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverDeliveryDocumentUploadRepositoryModule.kt similarity index 77% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverDeliveryDocumentUploadRepositoryModule.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverDeliveryDocumentUploadRepositoryModule.kt index a0782c0d93..a621b067d8 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverDeliveryDocumentUploadRepositoryModule.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverDeliveryDocumentUploadRepositoryModule.kt @@ -1,6 +1,6 @@ -package com.afternote.feature.afternote.data.di +package com.afternote.feature.receiver.data.di -import com.afternote.feature.afternote.data.repositoryimpl.receiver.ReceiverDeliveryDocumentUploadRepositoryImpl +import com.afternote.feature.receiver.data.repositoryimpl.ReceiverDeliveryDocumentUploadRepositoryImpl import com.afternote.feature.receiver.domain.repository.ReceiverDeliveryDocumentUploadRepository import dagger.Binds import dagger.Module diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverServiceModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverServiceModule.kt new file mode 100644 index 0000000000..01322fc63f --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/ReceiverServiceModule.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.receiver.data.di + +import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService +import com.afternote.feature.receiver.data.service.ReceiverAuthApiService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReceiverServiceModule { + @Provides + @Singleton + fun provideReceiverAfternoteApiService(retrofit: Retrofit): ReceiverAfternoteApiService = retrofit.create() + + @Provides + @Singleton + fun provideReceiverAuthApiService(retrofit: Retrofit): ReceiverAuthApiService = retrofit.create() +} diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAfternoteDto.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAfternoteDto.kt similarity index 94% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAfternoteDto.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAfternoteDto.kt index 34b39efb12..24b8293fd9 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAfternoteDto.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAfternoteDto.kt @@ -1,5 +1,6 @@ -package com.afternote.feature.afternote.data.dto +package com.afternote.feature.receiver.data.dto +import com.afternote.feature.afternote.data.dto.LeaveMessageBlockDto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAuthDto.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt similarity index 99% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAuthDto.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt index 4911f60df1..87539dd9e5 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAuthDto.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.dto +package com.afternote.feature.receiver.data.dto import com.afternote.feature.afternote.data.mapper.formatDateFromServer import com.afternote.feature.receiver.domain.model.DeliveryVerification diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodeDataSource.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/local/ReceiverAuthCodeDataSource.kt similarity index 78% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodeDataSource.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/local/ReceiverAuthCodeDataSource.kt index 1837bf7da2..b3dc111da1 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodeDataSource.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/local/ReceiverAuthCodeDataSource.kt @@ -1,11 +1,11 @@ -package com.afternote.feature.afternote.data.local +package com.afternote.feature.receiver.data.local import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey -import com.afternote.feature.afternote.data.di.ReceiverAuthCodeDataStore +import com.afternote.feature.receiver.data.di.ReceiverAuthCodeDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -19,7 +19,7 @@ private object ReceiverAuthCodeKeys { /** * 수신 인증 코드 로컬 저장 (DataStore Preferences). - * 도메인 [com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository]는 구현체에서만 이 클래스를 사용합니다. + * 도메인 [com.afternote.feature.receiver.domain.repository.ReceiverRepository]는 구현체에서만 이 클래스를 사용합니다. */ @Singleton class ReceiverAuthCodeDataSource @@ -49,10 +49,4 @@ class ReceiverAuthCodeDataSource } } } - - suspend fun clearCode() { - dataStore.edit { preferences -> - preferences.remove(ReceiverAuthCodeKeys.AUTH_CODE) - } - } } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt similarity index 57% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt index 2030ed7b97..c084ced792 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt @@ -1,9 +1,11 @@ -package com.afternote.feature.afternote.data.mapper +package com.afternote.feature.receiver.data.mapper -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteDto -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteListDto -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult +import com.afternote.feature.afternote.data.mapper.afternoteTypeFromServerCategory +import com.afternote.feature.afternote.data.mapper.formatDateFromServer +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDto +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult /** 서버가 모르는 카테고리를 보내면 [AfterNoteListItem.type] 은 null 이다. */ fun ReceivedAfternoteDto.toDomain(): AfterNoteListItem = diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/ReceivedAfternoteDetailMapper.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapper.kt similarity index 66% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/ReceivedAfternoteDetailMapper.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapper.kt index b896295c4c..e1562d300b 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/ReceivedAfternoteDetailMapper.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapper.kt @@ -1,16 +1,16 @@ -package com.afternote.feature.afternote.data.mapper.response +package com.afternote.feature.receiver.data.mapper.response -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteDetailDto -import com.afternote.feature.afternote.data.dto.ReceivedCredentialsDto -import com.afternote.feature.afternote.data.dto.ReceivedPlaylistDto -import com.afternote.feature.afternote.data.dto.ReceivedSongDto import com.afternote.feature.afternote.data.mapper.categoryToAfternoteType import com.afternote.feature.afternote.data.mapper.formatDateFromServer import com.afternote.feature.afternote.data.mapper.toLeaveMessageBlocks -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAccountCredentials -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedPlaylistDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedPlaylistSong +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDetailDto +import com.afternote.feature.receiver.data.dto.ReceivedCredentialsDto +import com.afternote.feature.receiver.data.dto.ReceivedPlaylistDto +import com.afternote.feature.receiver.data.dto.ReceivedSongDto +import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedPlaylistDetail +import com.afternote.feature.receiver.domain.model.ReceivedPlaylistSong fun ReceivedAfternoteDetailDto.toDomain(): ReceivedAfternoteDetail = ReceivedAfternoteDetail( diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/network/ReceiverAuthInterceptor.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/network/ReceiverAuthInterceptor.kt similarity index 91% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/network/ReceiverAuthInterceptor.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/network/ReceiverAuthInterceptor.kt index 9e282fcdb1..ed0131960a 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/network/ReceiverAuthInterceptor.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/network/ReceiverAuthInterceptor.kt @@ -1,6 +1,6 @@ -package com.afternote.feature.afternote.data.network +package com.afternote.feature.receiver.data.network -import com.afternote.feature.afternote.data.local.ReceiverAuthCodeDataSource +import com.afternote.feature.receiver.data.local.ReceiverAuthCodeDataSource import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import okhttp3.Interceptor diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/paging/ReceiverAfternotePagingSource.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSource.kt similarity index 78% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/paging/ReceiverAfternotePagingSource.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSource.kt index 4e9efd3791..782ce6276e 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/paging/ReceiverAfternotePagingSource.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSource.kt @@ -1,19 +1,19 @@ -package com.afternote.feature.afternote.data.paging +package com.afternote.feature.receiver.data.paging import androidx.paging.PagingSource import androidx.paging.PagingState import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData -import com.afternote.feature.afternote.data.mapper.toReceiverDomainList -import com.afternote.feature.afternote.data.service.ReceiverAfternoteApiService -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem +import com.afternote.feature.receiver.data.mapper.toReceiverDomainList +import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService +import com.afternote.feature.receiver.domain.model.AfterNoteListItem /** * 서버는 페이지네이션을 지원하지 않으므로 응답 전체를 한 페이지로 감싸서 반환한다. * Paging 3 API(LoadState/refresh/cachedIn) 통일을 위한 단일 페이지 구현이며, * 서버가 page/size를 도입하면 nextKey/getRefreshKey만 채워 넣으면 된다. * - * `X-Auth-Code` 헤더는 [com.afternote.feature.afternote.data.network.ReceiverAuthInterceptor]가 부착한다. + * `X-Auth-Code` 헤더는 [com.afternote.feature.receiver.data.network.ReceiverAuthInterceptor]가 부착한다. */ internal class ReceiverAfternotePagingSource( private val api: ReceiverAfternoteApiService, diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/IdentityVerificationRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImpl.kt similarity index 95% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/IdentityVerificationRepositoryImpl.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImpl.kt index 094c445b38..35f09a43ae 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/IdentityVerificationRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImpl.kt @@ -1,11 +1,11 @@ -package com.afternote.feature.afternote.data.repositoryimpl.receiver +package com.afternote.feature.receiver.data.repositoryimpl import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import com.afternote.feature.afternote.data.di.IdentityVerificationDataStore +import com.afternote.feature.receiver.data.di.IdentityVerificationDataStore import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt similarity index 84% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImpl.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt index 7887b43043..5a21460bca 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt @@ -1,19 +1,19 @@ -package com.afternote.feature.afternote.data.repositoryimpl.receiver +package com.afternote.feature.receiver.data.repositoryimpl import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus -import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequestDto -import com.afternote.feature.afternote.data.dto.toDomain -import com.afternote.feature.afternote.data.service.ReceiverAuthApiService -import com.afternote.feature.afternote.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException -import com.afternote.feature.afternote.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.data.dto.DeliveryVerificationRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthCodeEmailSendRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.toDomain +import com.afternote.feature.receiver.data.service.ReceiverAuthApiService +import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException +import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverDeliveryDocumentUploadRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverDeliveryDocumentUploadRepositoryImpl.kt similarity index 97% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverDeliveryDocumentUploadRepositoryImpl.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverDeliveryDocumentUploadRepositoryImpl.kt index 155dd83e08..014d8ab495 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverDeliveryDocumentUploadRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverDeliveryDocumentUploadRepositoryImpl.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.repositoryimpl.receiver +package com.afternote.feature.receiver.data.repositoryimpl import com.afternote.core.common.di.IoDispatcher import com.afternote.core.common.result.runCatchingCancellable diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImpl.kt similarity index 74% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImpl.kt index 1fa7c2680e..51c60f7072 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImpl.kt @@ -1,22 +1,22 @@ -package com.afternote.feature.afternote.data.repositoryimpl.receiver +package com.afternote.feature.receiver.data.repositoryimpl import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData -import com.afternote.feature.afternote.data.local.ReceiverAuthCodeDataSource -import com.afternote.feature.afternote.data.mapper.response.toDomain -import com.afternote.feature.afternote.data.mapper.toDomainResult -import com.afternote.feature.afternote.data.paging.ReceiverAfternotePagingSource -import com.afternote.feature.afternote.data.service.ReceiverAfternoteApiService -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository +import com.afternote.feature.receiver.data.local.ReceiverAuthCodeDataSource +import com.afternote.feature.receiver.data.mapper.response.toDomain +import com.afternote.feature.receiver.data.mapper.toDomainResult +import com.afternote.feature.receiver.data.paging.ReceiverAfternotePagingSource +import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import com.afternote.feature.receiver.domain.repository.ReceiverRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import javax.inject.Inject @@ -45,10 +45,6 @@ class ReceiverRepositoryImpl authCodeDataSource.saveCode(code) } - override suspend fun clearAuthCode() { - authCodeDataSource.clearCode() - } - override fun getPagedReceivedAfternotes(): Flow> = Pager( config = PagingConfig(pageSize = PAGE_SIZE), diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAfternoteApiService.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/service/ReceiverAfternoteApiService.kt similarity index 68% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAfternoteApiService.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/service/ReceiverAfternoteApiService.kt index e36c2f5bdc..5c778e3ef7 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAfternoteApiService.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/service/ReceiverAfternoteApiService.kt @@ -1,15 +1,15 @@ -package com.afternote.feature.afternote.data.service +package com.afternote.feature.receiver.data.service import com.afternote.core.network.model.BaseResponse -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteDetailDto -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteListDto +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDetailDto +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto import retrofit2.http.GET import retrofit2.http.Path interface ReceiverAfternoteApiService { /** * 인증번호로 수신한 애프터노트 목록 조회. 서버는 페이지네이션 없이 전체 목록과 totalCount만 반환한다. - * `X-Auth-Code` 헤더는 [com.afternote.feature.afternote.data.network.ReceiverAuthInterceptor]가 자동 부착한다. + * `X-Auth-Code` 헤더는 [com.afternote.feature.receiver.data.network.ReceiverAuthInterceptor]가 자동 부착한다. */ @GET("receiver-auth/after-notes") suspend fun getReceiverAfternotes(): BaseResponse diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAuthApiService.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/service/ReceiverAuthApiService.kt similarity index 67% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAuthApiService.kt rename to feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/service/ReceiverAuthApiService.kt index 1a8d0a3120..f399f2b978 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAuthApiService.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/service/ReceiverAuthApiService.kt @@ -1,16 +1,16 @@ -package com.afternote.feature.afternote.data.service +package com.afternote.feature.receiver.data.service import com.afternote.core.network.model.BaseResponse -import com.afternote.feature.afternote.data.dto.DeliveryVerificationDto -import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyDto -import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverMessageDto +import com.afternote.feature.receiver.data.dto.DeliveryVerificationDto +import com.afternote.feature.receiver.data.dto.DeliveryVerificationRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthCodeEmailSendRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyDto +import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverMessageDto import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -20,7 +20,7 @@ import retrofit2.http.POST * * 인증 코드 취득 전 단계인 `verify`·`email/auth-code`·`email/verify` 를 제외한 모든 endpoint 는 * `X-Auth-Code` 헤더가 필요하며 - * [com.afternote.feature.afternote.data.network.ReceiverAuthInterceptor] 가 + * [com.afternote.feature.receiver.data.network.ReceiverAuthInterceptor] 가 * 저장된 인증 코드를 자동 부착한다 (저장된 코드가 없으면 헤더 없이 통과). */ interface ReceiverAuthApiService { diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/DeliveryVerificationContractTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/DeliveryVerificationContractTest.kt similarity index 98% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/DeliveryVerificationContractTest.kt rename to feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/DeliveryVerificationContractTest.kt index 1e790a21f1..b27ed524bb 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/DeliveryVerificationContractTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/DeliveryVerificationContractTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.dto +package com.afternote.feature.receiver.data.dto import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverAuthDtoMapperTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt similarity index 98% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverAuthDtoMapperTest.kt rename to feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt index 092da9eb2f..c5a78c331f 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverAuthDtoMapperTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.dto +package com.afternote.feature.receiver.data.dto import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import org.junit.Assert.assertEquals diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverEmailAuthContractTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverEmailAuthContractTest.kt similarity index 98% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverEmailAuthContractTest.kt rename to feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverEmailAuthContractTest.kt index e9809080c6..c4dd7e0c5e 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverEmailAuthContractTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverEmailAuthContractTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.dto +package com.afternote.feature.receiver.data.dto import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.model.requireData diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverMessageContractTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverMessageContractTest.kt similarity index 97% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverMessageContractTest.kt rename to feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverMessageContractTest.kt index 5e419299c4..8025c47165 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverMessageContractTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverMessageContractTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.dto +package com.afternote.feature.receiver.data.dto import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.model.requireData diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt similarity index 90% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt rename to feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt index 3b046f77e4..f2e4036d6e 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt @@ -1,15 +1,15 @@ -package com.afternote.feature.afternote.data.mapper +package com.afternote.feature.receiver.data.mapper -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteDto -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteListDto import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDto +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test /** * [ReceivedAfternoteDto.toDomain] / [toReceiverDomainList] 회귀 가드. - * 수신자 목록 DTO→[com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem] 매핑. + * 수신자 목록 DTO→[com.afternote.feature.receiver.domain.model.AfterNoteListItem] 매핑. * 서버 카테고리를 [AfternoteType] 으로 바꾸는 규칙과 null 가드(category·createdAt)를 검증한다. */ class ReceiverAfternoteListItemDtoToDomainTest { diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt similarity index 86% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt rename to feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt index 8dab13fe4b..830dc821ad 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt @@ -1,11 +1,11 @@ -package com.afternote.feature.afternote.data.mapper.response +package com.afternote.feature.receiver.data.mapper.response -import com.afternote.feature.afternote.data.dto.ReceivedAfternoteDetailDto -import com.afternote.feature.afternote.data.dto.ReceivedCredentialsDto -import com.afternote.feature.afternote.data.dto.ReceivedMemorialVideoDto -import com.afternote.feature.afternote.data.dto.ReceivedPlaylistDto -import com.afternote.feature.afternote.data.dto.ReceivedSongDto import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDetailDto +import com.afternote.feature.receiver.data.dto.ReceivedCredentialsDto +import com.afternote.feature.receiver.data.dto.ReceivedMemorialVideoDto +import com.afternote.feature.receiver.data.dto.ReceivedPlaylistDto +import com.afternote.feature.receiver.data.dto.ReceivedSongDto import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImplEmailAuthTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt similarity index 88% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImplEmailAuthTest.kt rename to feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt index 8c80ea1ac0..e3db8f0141 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImplEmailAuthTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt @@ -1,19 +1,19 @@ -package com.afternote.feature.afternote.data.repositoryimpl.receiver +package com.afternote.feature.receiver.data.repositoryimpl import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.BaseResponse -import com.afternote.feature.afternote.data.dto.DeliveryVerificationDto -import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyDto -import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyDto -import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequestDto -import com.afternote.feature.afternote.data.dto.ReceiverMessageDto -import com.afternote.feature.afternote.data.service.ReceiverAuthApiService -import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.data.dto.DeliveryVerificationDto +import com.afternote.feature.receiver.data.dto.DeliveryVerificationRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthCodeEmailSendRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyDto +import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverMessageDto +import com.afternote.feature.receiver.data.service.ReceiverAuthApiService +import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking diff --git a/feature/receiver/domain/build.gradle.kts b/feature/receiver/domain/build.gradle.kts index e5215fb862..90b07fac3d 100644 --- a/feature/receiver/domain/build.gradle.kts +++ b/feature/receiver/domain/build.gradle.kts @@ -7,5 +7,8 @@ android { } dependencies { implementation(projects.core.domain) + // 수신 모델·계약이 애프터노트 도메인 타입(AfternoteType·LeaveMessageBlock)을 참조한다. + implementation(projects.feature.afternote.domain) implementation(libs.coroutines.core) + implementation(libs.androidx.paging.common) } diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverDeliveryVerificationException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt similarity index 93% rename from feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverDeliveryVerificationException.kt rename to feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt index 27bbd344de..5ac4aa923e 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverDeliveryVerificationException.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.domain.error +package com.afternote.feature.receiver.domain.error /** * 수신자가 사망진단서·가족관계증명서를 올려 전달 자격 심사를 신청하는 diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverEmailAuthException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt similarity index 94% rename from feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverEmailAuthException.kt rename to feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt index 976483947b..83dbcf7fac 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverEmailAuthException.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.domain.error +package com.afternote.feature.receiver.domain.error /** * 수신자 본인 확인 이메일 인증(`receiver-auth/email` 계열) API 가 거절한 실패. diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverMasterKeyException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt similarity index 92% rename from feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverMasterKeyException.kt rename to feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt index 5ea983913a..4a904815fc 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverMasterKeyException.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.domain.error +package com.afternote.feature.receiver.domain.error /** * 마스터 키 검증(`receiver-auth/verify`) API 가 거절한 실패. diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverServerRejectionException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt similarity index 97% rename from feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverServerRejectionException.kt rename to feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt index f40a14c6da..838bcf9494 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverServerRejectionException.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.domain.error +package com.afternote.feature.receiver.domain.error /** * 수신자 흐름 API 가 응답을 내려주며 거절한 실패의 공통 상위 타입. diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/receiver/ReceiverAfternoteModels.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAfternoteModels.kt similarity index 95% rename from feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/receiver/ReceiverAfternoteModels.kt rename to feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAfternoteModels.kt index 1c0bff75e4..870b780ebb 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/receiver/ReceiverAfternoteModels.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAfternoteModels.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.domain.model.receiver +package com.afternote.feature.receiver.domain.model import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverRepository.kt similarity index 75% rename from feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt rename to feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverRepository.kt index 06f7385244..e1013fae0a 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverRepository.kt @@ -1,10 +1,10 @@ -package com.afternote.feature.afternote.domain.repository.receiver +package com.afternote.feature.receiver.domain.repository import androidx.paging.PagingData -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle import com.afternote.feature.receiver.domain.model.SenderMessageInfo import kotlinx.coroutines.flow.Flow @@ -21,12 +21,9 @@ interface ReceiverRepository { /** 단발 조회; UI 스레드에서는 코루틴 안에서 호출하세요. */ suspend fun currentAuthCode(): String? - /** 사용자가 입력·검증한 코드를 저장합니다. */ + /** 사용자가 입력·검증한 코드를 저장합니다. 제거는 로그아웃 일괄 정리(SESSION scope, #912)가 담당합니다. */ suspend fun saveAuthCode(code: String) - /** 로그아웃·계정 전환·초기화 시 저장 코드를 제거합니다. */ - suspend fun clearAuthCode() - /** * 수신 애프터노트 스트림. 서버는 페이지네이션 미지원이므로 단일 페이지로 받지만, * Paging 3 API(LoadState/refresh/cachedIn) 통일과 추후 페이지네이션 도입을 위해 PagingData로 노출한다. diff --git a/feature/receiver/presentation/build.gradle.kts b/feature/receiver/presentation/build.gradle.kts index 813807ee5d..b9a74a15d8 100644 --- a/feature/receiver/presentation/build.gradle.kts +++ b/feature/receiver/presentation/build.gradle.kts @@ -1,14 +1,23 @@ plugins { id("afternote.android.library.compose") id("afternote.android.hilt") + kotlin("plugin.serialization") + alias(libs.plugins.compose.screenshot) } android { namespace = "com.afternote.feature.receiver.presentation" + experimentalProperties["android.experimental.enableScreenshotTest"] = true } dependencies { implementation(projects.feature.receiver.domain) + // 수신자 홈이 각 기능의 수신 Repository·모델을 조합한다. afternote 의존(도메인 모델·아이콘 매핑 + // getIconResForType)은 수신자 흐름 잔여분이 afternote 에서 이 모듈로 합류(#615)하면 재검토. + implementation(projects.feature.afternote.domain) + implementation(projects.feature.afternote.presentation) + implementation(projects.feature.mindrecord.domain) + implementation(projects.feature.timeletter.domain) implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.core.common) @@ -19,4 +28,14 @@ dependencies { implementation(libs.hilt.navigation) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.coil.compose) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + testImplementation(libs.coroutines.test) + testImplementation(libs.robolectric) + + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeEntry.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteHomeEntry.kt similarity index 94% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeEntry.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteHomeEntry.kt index 4b3144501c..e74544adf9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeEntry.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteHomeEntry.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.home +package com.afternote.feature.receiver.presentation.afternotelist import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteHomeViewModel.kt similarity index 92% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteHomeViewModel.kt index 5d7959c21e..8bee2c8c8a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteHomeViewModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.home +package com.afternote.feature.receiver.presentation.afternotelist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -7,10 +7,10 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.shared.body.infinite.content.list.item.ListItemUiModel import com.afternote.feature.afternote.presentation.shared.util.getIconResForService +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.repository.ReceiverRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreen.kt similarity index 85% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreen.kt index fb92df0084..a505db334c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -13,9 +13,9 @@ import com.afternote.core.ui.scaffold.FlowStepScaffold import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.ReceiverVerifyStep +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS +import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep /** * 열람 신청 완료 화면(design 9) — `submitDeliveryVerification` 성공 직후 노출 (이슈 #215). diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationFlowViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModel.kt similarity index 89% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationFlowViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModel.kt index 3717b6f784..c1686c237c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationFlowViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModel.kt @@ -1,11 +1,11 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -24,7 +24,7 @@ import javax.inject.Inject * * 후속(2차 PR 이후) 으로 옮길 책임: * - `authCode`·`ReceiverIdentity` 등 누적 흐름 상태 보유 - * - [com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistry.attachIdentity] 호출 시점 위임 + * - [com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry.attachIdentity] 호출 시점 위임 * - [IdentityVerificationRepository] 자체의 흡수·싱글톤 제거 */ @HiltViewModel diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt similarity index 95% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt index 1e440edc58..c90cb8d32a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import android.content.ContentResolver import android.net.Uri @@ -48,11 +48,11 @@ import com.afternote.core.ui.scaffold.FlowStepScaffold import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.DocumentSlotCard -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.DocumentSourceBottomSheet -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.ReceiverVerifyStep +import com.afternote.feature.receiver.presentation.deliveryverification.component.DocumentSlotCard +import com.afternote.feature.receiver.presentation.deliveryverification.component.DocumentSourceBottomSheet +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS +import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep /** * 증빙 서류 업로드 화면(designs 6·7·8) — 사망진단서 + 가족관계증명서 첨부 후 열람 신청 제출 (이슈 #215). diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt similarity index 96% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt index 7f3cafc44e..050cce6154 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt @@ -1,8 +1,8 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.annotation.StringRes import androidx.compose.runtime.Immutable -import com.afternote.feature.afternote.domain.error.ReceiverServerRejectionException +import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException /** * 화면이 표시할 에러 문구를 VM → UI 로 실어 나르는 상자 (payload = 운반되는 내용물). diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModel.kt similarity index 98% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModel.kt index 50008e09f5..5af6303f82 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUriReader.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUriReader.kt similarity index 95% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUriReader.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUriReader.kt index f5f9753af8..abbee39225 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUriReader.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUriReader.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import android.content.ContentResolver import android.net.Uri diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt similarity index 94% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt index fda1d617b6..6dcf40c387 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -28,9 +28,9 @@ import com.afternote.core.ui.scaffold.FlowStepScaffold import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.ReceiverVerifyStep +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS +import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep /** * 본인 확인 이메일 인증(designs 3·4) — 이메일 + 인증번호 입력 화면 (이슈 #215). diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreen.kt similarity index 85% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreen.kt index 3bb47e7ff7..600d7e088b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -13,9 +13,9 @@ import com.afternote.core.ui.scaffold.FlowStepScaffold import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.ReceiverVerifyStep +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS +import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep /** * 수신자 본인 확인 안내(design 2) — 진행 인디케이터 1/3 + 안내 문구 + "인증 시작하기" CTA. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationUiState.kt similarity index 89% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationUiState.kt index 3436d9214f..753c788b5d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.runtime.Immutable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt similarity index 97% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt index be3fd16e0b..2769ea233b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt @@ -1,13 +1,13 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.error.ReceiverServerRejectionException import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.afternote.presentation.reporting.shouldReportInReceiverFlow +import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt similarity index 92% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt index 1447b5b97d..e77c1fbb59 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -23,9 +23,9 @@ import com.afternote.core.ui.scaffold.FlowStepScaffold import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.ReceiverVerifyStep +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING +import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS +import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep /** * 마스터 키 입력 화면(design 5) — 진행 인디케이터 2/3 + 단일 입력 + "다음" CTA (이슈 #215). diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyUiState.kt similarity index 80% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyUiState.kt index f47d06d570..0bfae59839 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.runtime.Immutable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt similarity index 94% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt index 758f409b80..974c9dbecc 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt @@ -1,15 +1,15 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistry import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.afternote.presentation.reporting.shouldReportInReceiverFlow import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCard.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCard.kt similarity index 96% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCard.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCard.kt index ffdf9cd65d..9225987224 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCard.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCard.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification.component +package com.afternote.feature.receiver.presentation.deliveryverification.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -25,7 +25,7 @@ import com.afternote.core.ui.AfternoteFieldContainer import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentSlotState +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentSlotState /** * 증빙 서류 슬롯(designs 6·7·8) — 라벨 + 흰색 입력 박스 + 우측 검정 동그라미 첨부 버튼 (이슈 #215). diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheet.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheet.kt similarity index 97% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheet.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheet.kt index c086fca3d2..3b7b6af5e8 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheet.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheet.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification.component +package com.afternote.feature.receiver.presentation.deliveryverification.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/ReceiverVerifySteps.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/ReceiverVerifySteps.kt similarity index 87% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/ReceiverVerifySteps.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/ReceiverVerifySteps.kt index d28475ef4c..029875e28e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/ReceiverVerifySteps.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/ReceiverVerifySteps.kt @@ -1,8 +1,8 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification.component +package com.afternote.feature.receiver.presentation.deliveryverification.component import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.component.ReceiverVerifyStep.IDENTITY +import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep.IDENTITY /** * 수신자 인증 흐름(designs 2·3·4·5·6·7·8·9)의 단계 상수. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreen.kt similarity index 98% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreen.kt index a53ff71913..12d69b2f20 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreen.kt similarity index 98% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreen.kt index a2188d21b7..68b8f38ba7 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import android.content.Intent import android.content.pm.PackageManager @@ -59,7 +59,7 @@ import com.afternote.feature.afternote.presentation.shared.model.MessageBlockUiM * ReceivedAfternoteDetailSuccessMapper.kt 의 매퍼가 만든 [ReceivedMemorialDetailContent] 를 * Route 가 풀어 파라미터로 전달한다. * - * 페어 sub-screen: [com.afternote.feature.afternote.presentation.receiver.playlist.MemorialPlaylistScreen] + * 페어 sub-screen: [com.afternote.feature.receiver.presentation.playlist.MemorialPlaylistScreen] * (추억 플레이리스트 카드 클릭 → 전체보기 진입). */ @Composable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailRoute.kt similarity index 98% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailRoute.kt index e7f2cac7ad..4d9b8bba91 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailRoute.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailSuccessMapper.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt similarity index 94% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailSuccessMapper.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt index 969be38401..16765c3bb7 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailSuccessMapper.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt @@ -1,9 +1,9 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import com.afternote.core.model.AlbumCover import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail import com.afternote.feature.afternote.presentation.shared.model.toMessageBlockUiModels +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail internal fun ReceivedAfternoteDetail.toReceivedDetailContentUiModel(): ReceivedDetailContentUiModel = when (type) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailUiState.kt similarity index 85% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailUiState.kt index a2a58e17ff..bc64d55e49 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.annotation.StringRes diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailViewModel.kt similarity index 95% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailViewModel.kt index 4c6bd78738..3dd689c6db 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailViewModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle @@ -6,11 +6,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedDetailContentUiModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedDetailContentUiModel.kt similarity index 97% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedDetailContentUiModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedDetailContentUiModel.kt index e405f72cc0..0e2bd816fa 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedDetailContentUiModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedDetailContentUiModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.compose.runtime.Immutable import com.afternote.core.model.AlbumCover diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreen.kt similarity index 99% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreen.kt index 2ff9383894..030363bdca 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeActions.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeActions.kt similarity index 89% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeActions.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeActions.kt index e3de7756fa..60fd1444b2 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeActions.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeActions.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver +package com.afternote.feature.receiver.presentation.home /** * 수신자 홈에서 외부 라우팅으로 빠지는 액션 묶음. diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeEntry.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEntry.kt similarity index 92% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeEntry.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEntry.kt index 4394731f94..bddeb4a8d7 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeEntry.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEntry.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver +package com.afternote.feature.receiver.presentation.home import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeEvent.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEvent.kt similarity index 85% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeEvent.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEvent.kt index fb5ab99a16..2dd80b2eec 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeEvent.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEvent.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver +package com.afternote.feature.receiver.presentation.home /** 수신자 홈 화면 사용자 이벤트. */ sealed interface ReceiverHomeEvent { diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreen.kt similarity index 89% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreen.kt index f72ec0f5b8..35304b8af2 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver +package com.afternote.feature.receiver.presentation.home import android.widget.Toast import androidx.compose.foundation.layout.Arrangement @@ -26,16 +26,6 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.R -import com.afternote.afternote_fe.screen.receiver.component.AfternoteSection -import com.afternote.afternote_fe.screen.receiver.component.MindRecordSection -import com.afternote.afternote_fe.screen.receiver.component.SenderMessageHeroCard -import com.afternote.afternote_fe.screen.receiver.component.TimeLetterSection -import com.afternote.afternote_fe.screen.receiver.model.AfternoteSourceIcon -import com.afternote.afternote_fe.screen.receiver.model.MindRecordSummary -import com.afternote.afternote_fe.screen.receiver.model.ReceiverDownloadState -import com.afternote.afternote_fe.screen.receiver.model.ReceiverHomeUiState -import com.afternote.afternote_fe.screen.receiver.model.SenderMessage import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.popup.Popup @@ -43,6 +33,16 @@ import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.HomeTopBar +import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.receiver.presentation.home.component.AfternoteSection +import com.afternote.feature.receiver.presentation.home.component.MindRecordSection +import com.afternote.feature.receiver.presentation.home.component.SenderMessageHeroCard +import com.afternote.feature.receiver.presentation.home.component.TimeLetterSection +import com.afternote.feature.receiver.presentation.home.model.AfternoteSourceIcon +import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary +import com.afternote.feature.receiver.presentation.home.model.ReceiverDownloadState +import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState +import com.afternote.feature.receiver.presentation.home.model.SenderMessage import com.afternote.feature.afternote.presentation.R as AfternoteFeatureR /** @@ -198,14 +198,14 @@ private fun ErrorState( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = stringResource(R.string.home_tab_error_message), + text = stringResource(R.string.receiver_home_error_message), style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray7, ) Spacer(modifier = Modifier.height(16.dp)) TextButton(onClick = onRetry) { Text( - text = stringResource(R.string.home_tab_retry), + text = stringResource(R.string.receiver_home_retry), style = AfternoteDesign.typography.captionLargeB, color = AfternoteDesign.colors.gray9, ) diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModel.kt similarity index 86% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModel.kt index f77b57b63e..73b46b0195 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModel.kt @@ -1,22 +1,22 @@ -package com.afternote.afternote_fe.screen.receiver +package com.afternote.feature.receiver.presentation.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.afternote_fe.R -import com.afternote.afternote_fe.reporting.HomeFailureStage -import com.afternote.afternote_fe.reporting.recordHomeFailure -import com.afternote.afternote_fe.screen.receiver.model.AfternoteSourceIcon -import com.afternote.afternote_fe.screen.receiver.model.MindRecordSummary -import com.afternote.afternote_fe.screen.receiver.model.ReceiverDownloadState -import com.afternote.afternote_fe.screen.receiver.model.ReceiverHomeUiState -import com.afternote.afternote_fe.screen.receiver.model.SenderMessage import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.shared.util.getIconResForType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.receiver.presentation.home.model.AfternoteSourceIcon +import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary +import com.afternote.feature.receiver.presentation.home.model.ReceiverDownloadState +import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState +import com.afternote.feature.receiver.presentation.home.model.SenderMessage +import com.afternote.feature.receiver.presentation.reporting.ReceiverFailureStage +import com.afternote.feature.receiver.presentation.reporting.recordReceiverFailure import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async @@ -94,13 +94,13 @@ class ReceiverHomeViewModel if (firstFailure != null) { // 모든 호출이 실패한 경우만 Error. 일부 실패는 fallback 으로 진행. if (failedSources.size == HOME_REQUEST_COUNT) { - errorReporter.recordHomeFailure(HomeFailureStage.RECEIVER_HOME_LOAD, firstFailure) + errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVER_HOME_LOAD, firstFailure) _uiState.value = ReceiverHomeUiState.Error(firstFailure) return@coroutineScope } // 일부 실패는 0·빈 값으로 덮여 화면에도 콘솔에도 흔적이 남지 않던 구간 — 여기서만 기록한다. - errorReporter.recordHomeFailure( - stage = HomeFailureStage.RECEIVER_HOME_PARTIAL_LOAD, + errorReporter.recordReceiverFailure( + stage = ReceiverFailureStage.RECEIVER_HOME_PARTIAL_LOAD, throwable = firstFailure, failedSources = failedSources, ) @@ -146,13 +146,13 @@ class ReceiverHomeViewModel .saveReceivedExportToFile(bundle) .onSuccess { updateDownload(ReceiverDownloadState.Done) } .onFailure { e -> - errorReporter.recordHomeFailure(HomeFailureStage.RECEIVED_EXPORT_SAVE, e) + errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_SAVE, e) updateDownload( ReceiverDownloadState.Failed(R.string.receiver_home_download_all_save_failed), ) } }.onFailure { e -> - errorReporter.recordHomeFailure(HomeFailureStage.RECEIVED_EXPORT_DOWNLOAD, e) + errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_DOWNLOAD, e) updateDownload( ReceiverDownloadState.Failed(R.string.receiver_home_download_all_failed), ) diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/AfternoteSection.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSection.kt similarity index 96% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/AfternoteSection.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSection.kt index 73a7b47c87..903620a00f 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/AfternoteSection.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSection.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,10 +22,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.R -import com.afternote.afternote_fe.screen.receiver.model.AfternoteSourceIcon import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.receiver.presentation.home.model.AfternoteSourceIcon import com.afternote.feature.afternote.presentation.R as AfternoteFeatureR private const val MAX_VISIBLE_ICONS = 4 diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/HomeSectionCard.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/HomeSectionCard.kt similarity index 96% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/HomeSectionCard.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/HomeSectionCard.kt index f0032d576d..0988f205ef 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/HomeSectionCard.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/HomeSectionCard.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -19,8 +19,8 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.R import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.receiver.presentation.R /** * 마음의 기록·타임레터·애프터노트 카드의 공통 컨테이너. diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/MindRecordSection.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSection.kt similarity index 93% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/MindRecordSection.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSection.kt index 8baaa402d8..07dca23183 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/MindRecordSection.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSection.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -11,9 +11,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.R -import com.afternote.afternote_fe.screen.receiver.model.MindRecordSummary import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary import com.afternote.core.ui.R as CoreUiR /** diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCard.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCard.kt similarity index 98% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCard.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCard.kt index 4add08420b..dd9c4f131b 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCard.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCard.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/SectionGoButton.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButton.kt similarity index 97% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/SectionGoButton.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButton.kt index 3baaabe181..82dc73ac6a 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/SectionGoButton.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButton.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCard.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCard.kt similarity index 96% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCard.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCard.kt index 6b281ef8ce..484c4d97e1 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCard.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCard.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -19,9 +19,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.R import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.receiver.presentation.R /** * 고인이 남긴 한 마디 + 작성일 + 본문을 보여주는 그라데이션 카드. diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSection.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSection.kt similarity index 91% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSection.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSection.kt index 3af4bfd029..98f94ad6bd 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSection.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSection.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -6,8 +6,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.R import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.receiver.presentation.R /** 타임레터 섹션 — 단순 카드. */ @Composable diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/model/ReceiverHomeUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/model/ReceiverHomeUiState.kt similarity index 96% rename from app/src/main/java/com/afternote/afternote_fe/screen/receiver/model/ReceiverHomeUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/model/ReceiverHomeUiState.kt index 3097728a3a..c42a27cf69 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/model/ReceiverHomeUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/model/ReceiverHomeUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.model +package com.afternote.feature.receiver.presentation.home.model import androidx.annotation.StringRes import androidx.compose.runtime.Immutable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavActions.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavActions.kt similarity index 92% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavActions.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavActions.kt index 30015f9f9b..92c043eabb 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavActions.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavActions.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.navigation +package com.afternote.feature.receiver.presentation.navigation /** * NavHost 루트에서 수신자 서브그래프로 넘기는 그래프 내부 네비게이션 명령. @@ -28,7 +28,7 @@ interface ReceiverNavActions { /** * 발신자 상세의 "열람 신청하기" → nested 열람 신청 흐름 그래프 - * ([com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute.DeliveryVerificationFlowRoute]) + * ([com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute.DeliveryVerificationFlowRoute]) * 진입. 본인 확인 캐시 분기는 흐름 내부(IntroRoute 의 LaunchedEffect) 에서 자동 처리되므로 호출자는 * senderId 만 전달. */ diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavGraph.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraph.kt similarity index 85% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavGraph.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraph.kt index 872d9d207c..2d72151623 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavGraph.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraph.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.navigation +package com.afternote.feature.receiver.presentation.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -13,21 +13,21 @@ import androidx.navigation.toRoute import com.afternote.core.ui.Route import com.afternote.feature.afternote.presentation.author.navigation.DetailLoadErrorContent import com.afternote.feature.afternote.presentation.author.navigation.DetailLoadingContent -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DeliveryVerificationCompleteScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DeliveryVerificationFlowViewModel -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentUploadScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.IdentityVerificationEmailScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.IdentityVerificationIntroScreen -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.MasterKeyScreen -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.home.ReceiverAfternoteHomeEntry -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute -import com.afternote.feature.afternote.presentation.receiver.playlist.MemorialPlaylistScreen -import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistUiState -import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistViewModel -import com.afternote.feature.afternote.presentation.receiver.recordsbox.ReceivedRecordsScreen -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistrationScreen -import com.afternote.feature.afternote.presentation.receiver.senderdetail.SenderDetailScreen +import com.afternote.feature.receiver.presentation.afternotelist.ReceiverAfternoteHomeEntry +import com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationCompleteScreen +import com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadScreen +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationEmailScreen +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationIntroScreen +import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyScreen +import com.afternote.feature.receiver.presentation.detail.ReceivedAfternoteDetailRoute +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import com.afternote.feature.receiver.presentation.playlist.MemorialPlaylistScreen +import com.afternote.feature.receiver.presentation.playlist.ReceiverMemorialPlaylistUiState +import com.afternote.feature.receiver.presentation.playlist.ReceiverMemorialPlaylistViewModel +import com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsScreen +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistrationScreen +import com.afternote.feature.receiver.presentation.senderdetail.SenderDetailScreen /** * 수신자 흐름 네비게이션 그래프. 앱 모듈의 NavHost에 직접 연결되며 [Route.Receiver]를 diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavGraphTheme.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraphTheme.kt similarity index 90% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavGraphTheme.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraphTheme.kt index 1d9d694861..cb610a07a4 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceiverNavGraphTheme.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraphTheme.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.navigation +package com.afternote.feature.receiver.presentation.navigation import androidx.compose.runtime.Composable import androidx.navigation.NavBackStackEntry diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/model/ReceiverRoute.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt similarity index 91% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/model/ReceiverRoute.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt index f5096e3ea3..dcf69ee9b5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/model/ReceiverRoute.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.navigation.model +package com.afternote.feature.receiver.presentation.navigation.model import kotlinx.serialization.Serializable @@ -34,7 +34,7 @@ sealed interface ReceiverRoute { * 발신자 상세(11·12) — 카드 클릭 진입. 열람 신청 상태에 따라 3가지 표시: * 신청 기록 없음(열람 불가) / PENDING(승인 대기) / APPROVED(열람 가능). * - * `senderId` 는 [com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistry] + * `senderId` 는 [com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry] * 의 로컬 식별자 (typed-safe routes 규약: SavedStateHandle 키 `senderId` 와 일치). */ @Serializable @@ -46,7 +46,7 @@ sealed interface ReceiverRoute { * 열람 신청 흐름 — 본인 확인(2·3·4) + 마스터 키(5) + 서류 업로드(6·7·8) + 완료(9) 의 *nested graph 진입점*. * * 흐름 전체 동안 유지되는 `senderId` 는 본 라우트에만 보유 — 자식 라우트들은 parent backStackEntry 의 - * [com.afternote.feature.afternote.presentation.receiver.deliveryverification.DeliveryVerificationFlowViewModel] + * [com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel] * 에서 receive. 자식 라우트에서 senderId 를 nav arg 로 중복 박지 않는다. * * 발신자 상세 "열람 신청하기" 진입점. @@ -105,7 +105,7 @@ sealed interface ReceiverRoute { /** * 수신 추억 플레이리스트 — 추억 상세 - * ([com.afternote.feature.afternote.presentation.receiver.detail.MemorialReceivedDetailScreen]) 의 + * ([com.afternote.feature.receiver.presentation.detail.MemorialReceivedDetailScreen]) 의 * "추억 플레이리스트" 카드 클릭 진입. */ @Serializable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistUiState.kt similarity index 88% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistUiState.kt index 76770e43a7..de087d83e3 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.playlist +package com.afternote.feature.receiver.presentation.playlist import androidx.annotation.StringRes import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistViewModel.kt similarity index 93% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistViewModel.kt index 54ff85ff58..9fb54b9d0c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistViewModel.kt @@ -1,16 +1,16 @@ -package com.afternote.feature.afternote.presentation.receiver.playlist +package com.afternote.feature.receiver.presentation.playlist import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlayListScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverPlayListScreen.kt similarity index 97% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlayListScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverPlayListScreen.kt index 1f16c6c2eb..1f2fdc15cb 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlayListScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverPlayListScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.playlist +package com.afternote.feature.receiver.presentation.playlist import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreen.kt similarity index 98% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreen.kt index f07ffa9a05..d334c9a4d3 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.compose.foundation.Image import androidx.compose.foundation.clickable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModel.kt similarity index 90% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModel.kt index bae736065b..9ca286d72f 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderEntry.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderEntry.kt similarity index 94% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderEntry.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderEntry.kt index d9571f36af..85f0f1a3ff 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderEntry.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderEntry.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.compose.runtime.Immutable import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreen.kt similarity index 98% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreen.kt index 3f5faacd65..73e66afeb4 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationUiState.kt similarity index 64% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationUiState.kt index 95b26e0f4e..64cad55c2d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.compose.runtime.Immutable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationViewModel.kt similarity index 96% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationViewModel.kt index 59e4d5f03f..2984de7afa 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationViewModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistry.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistry.kt similarity index 97% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistry.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistry.kt index b4da2c9ff1..0236e1bbec 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistry.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistry.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceiverIdentity diff --git a/app/src/main/java/com/afternote/afternote_fe/reporting/HomeFailureReporting.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReporting.kt similarity index 58% rename from app/src/main/java/com/afternote/afternote_fe/reporting/HomeFailureReporting.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReporting.kt index 18f6ca172a..ec3845af70 100644 --- a/app/src/main/java/com/afternote/afternote_fe/reporting/HomeFailureReporting.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReporting.kt @@ -1,19 +1,23 @@ -package com.afternote.afternote_fe.reporting +package com.afternote.feature.receiver.presentation.reporting import com.afternote.core.common.reporting.ErrorReporter /** - * 홈(발신자·수신자) 화면에서 실패가 발생한 지점. + * 수신자 흐름에서 실패가 발생한 지점. * * [reportingName] 은 리포팅 콘솔의 필터 값이다. 바꾸면 그 시점 이후 기록이 기존 이슈 그룹과 * 끊겨 추이를 잃으므로, 단계 자체가 사라지지 않는 한 유지한다. + * + * 흐름마다 전용 enum 과 `<흐름>_stage` 키를 따로 둔다 — 키를 합치면 콘솔에서 한 키에 서로 다른 + * 값 목록이 섞인다. 아래 단계들은 수신자 홈이 app 에 있던 시절 `home_stage` 키로 기록되던 것을 + * 화면 이전(#546)과 함께 이 키로 옮긴 것이다. 내려받기 두 단계는 afternote 쪽 요약 화면이 + * `afternote_stage` 키로도 기록하던 것을 #615 이전과 함께 여기로 수렴시켰다(이중 기록 해소). + * reportingName 은 그대로라 단계별 식별은 유지되고, 키 축 추이만 새로 시작한다(#544 도입 + * 직후라 손실 구간은 며칠 분). */ -enum class HomeFailureStage( +enum class ReceiverFailureStage( val reportingName: String, ) { - /** 발신자 홈 요약(이름·수신인 지정 여부·기록 수) 로드. */ - AUTHOR_SUMMARY_LOAD("author_summary_load"), - /** 수신자 홈 로드 — 네 요청이 모두 실패해 화면이 에러로 떨어진 경우. */ RECEIVER_HOME_LOAD("receiver_home_load"), @@ -32,14 +36,14 @@ enum class HomeFailureStage( } /** - * 홈 흐름의 handled 실패를 공통 키 규격으로 기록한다. + * 수신자 흐름의 handled 실패를 공통 키 규격으로 기록한다. * * @param failedSources 한 화면이 여러 요청을 모아 그리는 탓에 일부만 깨진 경우, 어떤 항목이 * 비었는지 남긴다. 요청별로 따로 기록하면 한 번의 네트워크 단절이 보관 한도(최근 8건) 를 * 혼자 채워 버리므로, 한 건에 목록으로 묶는다. 비어 있으면 이 키는 붙지 않는다. */ -fun ErrorReporter.recordHomeFailure( - stage: HomeFailureStage, +fun ErrorReporter.recordReceiverFailure( + stage: ReceiverFailureStage, throwable: Throwable, failedSources: List = emptyList(), ) { @@ -47,14 +51,14 @@ fun ErrorReporter.recordHomeFailure( throwable = throwable, attributes = buildMap { - put(KEY_HOME_STAGE, stage.reportingName) + put(KEY_RECEIVER_STAGE, stage.reportingName) if (failedSources.isNotEmpty()) { - put(KEY_HOME_FAILED_SOURCES, failedSources.joinToString(separator = ",")) + put(KEY_RECEIVER_FAILED_SOURCES, failedSources.joinToString(separator = ",")) } }, ) } -private const val KEY_HOME_STAGE = "home_stage" +private const val KEY_RECEIVER_STAGE = "receiver_stage" -private const val KEY_HOME_FAILED_SOURCES = "home_failed_sources" +private const val KEY_RECEIVER_FAILED_SOURCES = "receiver_failed_sources" diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreen.kt similarity index 99% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreen.kt index b8eb813db4..08dd104177 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.senderdetail +package com.afternote.feature.receiver.presentation.senderdetail import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailUiState.kt similarity index 96% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailUiState.kt index 84c0cd99f3..d7ca078d30 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.senderdetail +package com.afternote.feature.receiver.presentation.senderdetail import androidx.compose.runtime.Immutable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt similarity index 93% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt index e9a5eb32e0..e9f487e18e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt @@ -1,19 +1,19 @@ -package com.afternote.feature.afternote.presentation.receiver.senderdetail +package com.afternote.feature.receiver.presentation.senderdetail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository -import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderEntry -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistry import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import com.afternote.feature.receiver.presentation.recordsbox.SenderEntry +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ContentSection.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ContentSection.kt similarity index 98% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ContentSection.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ContentSection.kt index d3399d7774..aa1bb28f4e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ContentSection.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ContentSection.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/HeroCard.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/HeroCard.kt similarity index 97% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/HeroCard.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/HeroCard.kt index 6486227c36..2eb69d0a7e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/HeroCard.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/HeroCard.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverAfternoteScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverAfternoteScreen.kt similarity index 99% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverAfternoteScreen.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverAfternoteScreen.kt index 618d848d87..ae150213b5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverAfternoteScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverAfternoteScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary import android.widget.Toast import androidx.compose.foundation.layout.Spacer diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllEvent.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllEvent.kt similarity index 80% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllEvent.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllEvent.kt index 2f7f1a62a7..740c4aafec 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllEvent.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllEvent.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary /** * 모든 기록 내려받기 UI 이벤트. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllUiState.kt similarity index 80% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllUiState.kt index 054747da36..6c73ab6a58 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary import androidx.annotation.StringRes diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllViewModel.kt similarity index 84% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllViewModel.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllViewModel.kt index 9390409ee1..2cbb7b74ff 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverDownloadAllViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverDownloadAllViewModel.kt @@ -1,12 +1,12 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage -import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.reporting.ReceiverFailureStage +import com.afternote.feature.receiver.presentation.reporting.recordReceiverFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,7 +51,7 @@ class ReceiverDownloadAllViewModel it.copy(isLoading = false, downloadSuccess = true) } }.onFailure { e -> - errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVED_EXPORT_SAVE, e) + errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_SAVE, e) _uiState.update { it.copy( isLoading = false, @@ -60,7 +60,7 @@ class ReceiverDownloadAllViewModel } } }.onFailure { e -> - errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVED_EXPORT_DOWNLOAD, e) + errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_DOWNLOAD, e) _uiState.update { it.copy( isLoading = false, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverSummaryUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverSummaryUiState.kt similarity index 85% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverSummaryUiState.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverSummaryUiState.kt index fec6d2d052..f16db091c2 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/ReceiverSummaryUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/ReceiverSummaryUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary /** * 수신자 홈 화면에 표시할 요약 정보. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/TopHeader.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/TopHeader.kt similarity index 94% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/TopHeader.kt rename to feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/TopHeader.kt index b84abb9f69..aa760a1a49 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/TopHeader.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/summary/TopHeader.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size diff --git a/feature/receiver/presentation/src/main/res/values/strings.xml b/feature/receiver/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000000..5df3cbc3da --- /dev/null +++ b/feature/receiver/presentation/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + 故 %1$s님이 남기신 기록 + %1$s님의 한 마디 + TOTAL + 데일리 질문 + 일기 + +%1$d + 마음의 기록 + 고인의 일상적인 생각과 감정, 일기들입니다. + 마음의 기록 확인하러 가기 + 타임레터 + 고인이 특별한 날에\n수신자님에게 작성한 편지입니다. + 라이프 이벤트 레터 확인하러 가기 + 애프터노트 + 고인이 사후 정리하고자 하는 데이터입니다. + 애프터노트 확인하러 가기 + 기록 수를 불러오지 못했습니다. + 모든 기록 내려받기 + 모든 기록을 내려받으시겠습니까? + 모든 기록 내려받기에 실패했습니다. + 파일 저장에 실패했습니다. + + 정보를 불러오지 못했습니다. + 다시 시도 + diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTest.kt similarity index 83% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTest.kt index db37e75c7a..312a468466 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTest.kt similarity index 96% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTest.kt index c3e1f83075..c30c370c9c 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTest.kt similarity index 95% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTest.kt index d37141ba8b..36a9f21cde 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.SnackbarHostState diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTest.kt similarity index 84% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTest.kt index 4611b6b6d6..f14e09b91a 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTest.kt similarity index 93% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTest.kt index bf181d7bd7..6b9731ab14 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.SnackbarHostState diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCardScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTest.kt similarity index 84% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCardScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTest.kt index 293871f9e0..c26f760017 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCardScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification.component +package com.afternote.feature.receiver.presentation.deliveryverification.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.DocumentSlotState +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentSlotState import com.android.tools.screenshot.PreviewTest @PreviewTest diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheetScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheetScreenshotTest.kt similarity index 82% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheetScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheetScreenshotTest.kt index ea8e17da65..f588014aed 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheetScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheetScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification.component +package com.afternote.feature.receiver.presentation.deliveryverification.component import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreenScreenshotTest.kt similarity index 84% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreenScreenshotTest.kt index 08a3b9bed8..86ffaa6a68 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTest.kt similarity index 96% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTest.kt index 947b3d2c72..690f9bd758 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt similarity index 85% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt index 1bc57cad08..2a86aae0e2 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.detail +package com.afternote.feature.receiver.presentation.detail import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTest.kt similarity index 86% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTest.kt index 8f0b65914a..f5808e71ce 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTest.kt @@ -1,12 +1,12 @@ -package com.afternote.afternote_fe.screen.receiver +package com.afternote.feature.receiver.presentation.home import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import com.afternote.afternote_fe.screen.receiver.model.AfternoteSourceIcon -import com.afternote.afternote_fe.screen.receiver.model.MindRecordSummary -import com.afternote.afternote_fe.screen.receiver.model.ReceiverHomeUiState -import com.afternote.afternote_fe.screen.receiver.model.SenderMessage import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.receiver.presentation.home.model.AfternoteSourceIcon +import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary +import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState +import com.afternote.feature.receiver.presentation.home.model.SenderMessage import com.android.tools.screenshot.PreviewTest /** diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/AfternoteSectionScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTest.kt similarity index 85% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/AfternoteSectionScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTest.kt index 1d7b943aeb..7c592fdc3d 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/AfternoteSectionScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTest.kt @@ -1,12 +1,12 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.screen.receiver.model.AfternoteSourceIcon import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.receiver.presentation.home.model.AfternoteSourceIcon import com.android.tools.screenshot.PreviewTest @PreviewTest diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/MindRecordSectionScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTest.kt similarity index 82% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/MindRecordSectionScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTest.kt index 4a422bf1e7..45c785571c 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/MindRecordSectionScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTest.kt @@ -1,12 +1,12 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.afternote.afternote_fe.screen.receiver.model.MindRecordSummary import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary import com.android.tools.screenshot.PreviewTest @PreviewTest diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCardScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTest.kt similarity index 93% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCardScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTest.kt index a95068275c..08685d3a1e 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCardScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/SectionGoButtonScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTest.kt similarity index 92% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/SectionGoButtonScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTest.kt index 5cef795949..f00d5ddc4f 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/SectionGoButtonScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCardScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTest.kt similarity index 93% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCardScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTest.kt index ee94ade74c..b7ebed0ec1 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCardScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSectionScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTest.kt similarity index 89% rename from app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSectionScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTest.kt index b0ca73ada6..f76049e39a 100644 --- a/app/src/screenshotTest/kotlin/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSectionScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.afternote_fe.screen.receiver.component +package com.afternote.feature.receiver.presentation.home.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/playlist/MemorialPlaylistScreenScreenshotTest.kt similarity index 91% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/playlist/MemorialPlaylistScreenScreenshotTest.kt index e6c82323f3..6bd6be7def 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/playlist/MemorialPlaylistScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.playlist +package com.afternote.feature.receiver.presentation.playlist import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTest.kt similarity index 93% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTest.kt index 93cc2d2993..24dc04ad7a 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTest.kt similarity index 92% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTest.kt index 5a464055bc..6174c60d90 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.recordsbox +package com.afternote.feature.receiver.presentation.recordsbox import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTest.kt similarity index 96% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTest.kt index 25c53784ac..bb95fdc51e 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.senderdetail +package com.afternote.feature.receiver.presentation.senderdetail import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/HeroCardScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/summary/HeroCardScreenshotTest.kt similarity index 82% rename from feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/HeroCardScreenshotTest.kt rename to feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/summary/HeroCardScreenshotTest.kt index 0553808a4e..51aaf1eefa 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/receiver/summary/HeroCardScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/summary/HeroCardScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.summary +package com.afternote.feature.receiver.presentation.summary import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheetScreenshotTestKt/documentSourceBottomSheetScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheetScreenshotTestKt/documentSourceBottomSheetScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/deliveryverification/component/DocumentSourceBottomSheetScreenshotTestKt/documentSourceBottomSheetScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSourceBottomSheetScreenshotTestKt/documentSourceBottomSheetScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png diff --git a/app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/afternote/afternote_fe/screen/receiver/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/summary/HeroCardScreenshotTestKt/heroCardScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/summary/HeroCardScreenshotTestKt/heroCardScreenshot_748aa731_0.png similarity index 100% rename from feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/summary/HeroCardScreenshotTestKt/heroCardScreenshot_748aa731_0.png rename to feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/summary/HeroCardScreenshotTestKt/heroCardScreenshot_748aa731_0.png diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteListMappingTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteListMappingTest.kt similarity index 95% rename from feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteListMappingTest.kt rename to feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteListMappingTest.kt index 392234f8c1..8d5994da06 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteListMappingTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/afternotelist/ReceiverAfternoteListMappingTest.kt @@ -1,9 +1,9 @@ -package com.afternote.feature.afternote.presentation.receiver.home +package com.afternote.feature.receiver.presentation.afternotelist import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem import com.afternote.feature.afternote.presentation.shared.model.AfternoteService import com.afternote.feature.afternote.presentation.shared.util.getIconResForType +import com.afternote.feature.receiver.domain.model.AfterNoteListItem import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Test diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModelTest.kt similarity index 98% rename from feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadViewModelTest.kt rename to feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModelTest.kt index a0b82c7f1d..3f52efab94 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModelTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.afternote.presentation.R diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/ErrorPayloadTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt similarity index 90% rename from feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/ErrorPayloadTest.kt rename to feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt index 2cbdbb411e..bc536de6fb 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/ErrorPayloadTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt @@ -1,8 +1,8 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification -import com.afternote.feature.afternote.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException -import com.afternote.feature.afternote.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException +import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException import org.junit.Assert.assertEquals import org.junit.Test import java.io.IOException diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt similarity index 91% rename from feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModelTest.kt rename to feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt index b3b4ade093..9acf374e0b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt @@ -1,20 +1,20 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification +package com.afternote.feature.receiver.presentation.deliveryverification import androidx.paging.PagingData import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistry +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.DeliveryVerification +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -166,10 +166,6 @@ private class RecordingReceiverRepository : ReceiverRepository { storedAuthCode.value = code } - override suspend fun clearAuthCode() { - storedAuthCode.value = null - } - override fun getPagedReceivedAfternotes(): Flow> = flowOf(PagingData.empty()) override suspend fun getReceivedAfterNotes(): Result = error("호출되면 안 됨") diff --git a/app/src/test/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt similarity index 93% rename from app/src/test/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModelTest.kt rename to feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt index fb2d9ad183..d9277aa632 100644 --- a/app/src/test/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt @@ -1,15 +1,15 @@ -package com.afternote.afternote_fe.screen.receiver +package com.afternote.feature.receiver.presentation.home -import com.afternote.afternote_fe.screen.receiver.model.ReceiverHomeUiState import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.receiver.domain.model.AfterNotesListResult +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle import com.afternote.feature.receiver.domain.model.SenderMessageInfo +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository @@ -115,8 +115,8 @@ class ReceiverHomeViewModelTest { val reported = fixture.reporter.failures.single() assertEquals(afternoteFailure.javaClass.name, reported.throwable.message) assertEquals(afternoteFailure.javaClass.name, reported.attributes["error_type"]) - assertEquals("receiver_home_partial_load", reported.attributes["home_stage"]) - assertEquals("afternotes,mind_records", reported.attributes["home_failed_sources"]) + assertEquals("receiver_home_partial_load", reported.attributes["receiver_stage"]) + assertEquals("afternotes,mind_records", reported.attributes["receiver_failed_sources"]) } @Test @@ -139,8 +139,8 @@ class ReceiverHomeViewModelTest { val reported = fixture.reporter.failures.single() assertEquals(firstFailure.javaClass.name, reported.throwable.message) assertEquals(firstFailure.javaClass.name, reported.attributes["error_type"]) - assertEquals("receiver_home_load", reported.attributes["home_stage"]) - assertNull(reported.attributes["home_failed_sources"]) + assertEquals("receiver_home_load", reported.attributes["receiver_stage"]) + assertNull(reported.attributes["receiver_failed_sources"]) } } @@ -169,8 +169,6 @@ private class FakeReceiverRepository : ReceiverRepository { override suspend fun saveAuthCode(code: String) = unexpected("saveAuthCode") - override suspend fun clearAuthCode() = unexpected("clearAuthCode") - override fun getPagedReceivedAfternotes() = unexpected("getPagedReceivedAfternotes") override suspend fun getReceivedAfterNotes(): Result = afterNotes diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistViewModelTest.kt similarity index 89% rename from feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt rename to feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistViewModelTest.kt index f7a5734760..c5e196580a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/playlist/ReceiverMemorialPlaylistViewModelTest.kt @@ -1,15 +1,15 @@ -package com.afternote.feature.afternote.presentation.receiver.playlist +package com.afternote.feature.receiver.presentation.playlist import androidx.lifecycle.SavedStateHandle import androidx.paging.PagingData import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem -import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult -import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail -import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle -import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle import com.afternote.feature.receiver.domain.model.SenderMessageInfo +import com.afternote.feature.receiver.domain.repository.ReceiverRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -112,8 +112,6 @@ private class FakeReceiverRepository( override suspend fun saveAuthCode(code: String) = Unit - override suspend fun clearAuthCode() = Unit - override fun getPagedReceivedAfternotes(): Flow> = emptyFlow() override suspend fun getReceivedAfterNotes(): Result { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1011da0c2c..ed7c3cb3f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,13 @@ firebaseCrashlytics = "3.0.7" firebaseBom = "34.16.0" konsist = "0.17.3" kover = "0.9.9" +# 빌드·테스트 클래스패스 전용 보안 하한 — GHSA-574f-3g2m-x479(critical) 등 3건이 1.84 에서 패치(#921). +# 루트 build.gradle.kts 의 buildscript 블록은 카탈로그 accessor 를 못 써 같은 값을 리터럴로 둔다. +bouncycastle = "1.84" +netty = "4.1.137.Final" +commonsLang3 = "3.18.0" +jose4j = "0.9.6" +jdom2 = "2.0.6.1" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index 9db9f77153..933a1467bd 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -14,4 +14,13 @@ kotlin { dependencies { testImplementation(libs.konsist) testImplementation(libs.junit) + + // konsist 0.17.3 이 kotlin-compiler-embeddable 2.0.21 경유로 coroutines 1.9.0 을 끌어와, 카탈로그 + // 선언(1.11.0)과 어긋난 채 해석된다(#974). 이 모듈은 아키텍처 테스트 전용이라 산출물에 영향이 없지만 + // 감사가 보는 testRuntimeClasspath 를 카탈로그 하나의 의도로 맞춘다. + constraints { + testImplementation(libs.coroutines.core) { + because("카탈로그 선언 버전으로 정렬 — konsist 전이 1.9.0 과 불일치(#974)") + } + } } diff --git a/konsist/src/test/kotlin/com/afternote/konsist/LayerDependencyKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/LayerDependencyKonsistTest.kt index 0e2672f566..477f9a6bc3 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/LayerDependencyKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/LayerDependencyKonsistTest.kt @@ -13,7 +13,7 @@ import org.junit.Test * * domain 패키지(`com.afternote..domain..`) 파일의 import 만 검사한다. * - 허용: `core:model`(`com.afternote.core.model.*`), 같은 domain 레이어(`com.afternote..domain..`; cross-domain 포함) - * - 금지: data·presentation 레이어, 비-`core:model` 코어(network/ui/common/datastore/di/startup) + * - 금지: data·presentation 레이어, 비-`core:model` 코어(network/ui/common/datastore/di) * * 외부 빌드 그래프가 아니라 소스 import 를 스캔하므로, Gradle 사이클 탐지가 못 잡는 * `domain → core:network` 같은 위반까지 막는다. diff --git a/scripts/repository-quality.sh b/scripts/repository-quality.sh index 9ff94facdd..7d364be16d 100755 --- a/scripts/repository-quality.sh +++ b/scripts/repository-quality.sh @@ -100,6 +100,36 @@ if [ "$shell_script_count" -eq 0 ]; then quality_failed=1 fi +# Firebase App Distribution 업로드 경로 2종은 같은 앱에 올린다. concurrency group 이 갈리면 +# 두 러너가 동시에 업로드해 어느 빌드가 테스터에게 남을지 완료 순서로 갈린다. 주석만으로는 +# 한쪽만 고치는 재발을 막지 못하므로 group 문자열 동일성을 기계로 강제한다 (#995). +upload_workflow_files=() + +for upload_workflow_candidate in \ + .github/workflows/release-distribution.yml \ + .github/workflows/firebase-wif-canary.yml; do + if [ -f "$upload_workflow_candidate" ]; then + upload_workflow_files+=("$upload_workflow_candidate") + fi +done + +if [ "${#upload_workflow_files[@]}" -gt 0 ]; then + upload_group_count=$( + for upload_workflow_file in "${upload_workflow_files[@]}"; do + awk ' + /^concurrency:/ { inside = 1; next } + inside && /^ group:/ { print $2; exit } + inside && /^[^[:space:]]/ { exit } + ' "$upload_workflow_file" + done | sort -u | wc -l + ) + + if [ "$upload_group_count" -ne 1 ]; then + echo "Repository Quality: Firebase App Distribution upload workflows disagree on concurrency group" >&2 + quality_failed=1 + fi +fi + merge_marker_output=$(git grep -nI -E \ '^(<<<<<<<|=======|>>>>>>>)( |$)' \ -- \ diff --git a/scripts/test-repository-quality.sh b/scripts/test-repository-quality.sh index 3428dd6035..5c3b66e428 100755 --- a/scripts/test-repository-quality.sh +++ b/scripts/test-repository-quality.sh @@ -116,6 +116,40 @@ expect_fixture_failure \ "$shell_repository" \ 'Repository Quality: bash syntax failed: invalid.sh' +upload_group_repository=$(create_fixture_repository upload-concurrency-group) +printf '%s\n' \ + 'name: Release Distribution Fixture' \ + 'on:' \ + ' push:' \ + ' branches: [main]' \ + 'concurrency:' \ + ' group: release-distribution' \ + 'jobs:' \ + ' distribute:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo "distribute"' \ + > "${upload_group_repository}/.github/workflows/release-distribution.yml" +printf '%s\n' \ + 'name: Firebase WIF Canary Fixture' \ + 'on:' \ + ' workflow_dispatch:' \ + 'concurrency:' \ + ' group: firebase-wif-canary' \ + 'jobs:' \ + ' canary:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo "canary"' \ + > "${upload_group_repository}/.github/workflows/firebase-wif-canary.yml" +git -C "$upload_group_repository" add -- \ + .github/workflows/release-distribution.yml \ + .github/workflows/firebase-wif-canary.yml +expect_fixture_failure \ + upload-concurrency-group \ + "$upload_group_repository" \ + 'Repository Quality: Firebase App Distribution upload workflows disagree on concurrency group' + marker_repository=$(create_fixture_repository merge-marker) printf '%s\n' \ '<<<<<<< HEAD' \ diff --git a/settings.gradle.kts b/settings.gradle.kts index c6df5163b8..e7e234aafc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,11 +33,9 @@ include(":app") include(":core:common") include(":core:data") include(":core:datastore") -include(":core:di") include(":core:domain") include(":core:model") include(":core:network") -include(":core:startup") include(":core:ui") // Feature Modules @@ -45,6 +43,8 @@ include(":feature:afternote:data") include(":feature:afternote:domain") include(":feature:afternote:presentation") +include(":feature:home:presentation") + include(":feature:mindrecord:data") include(":feature:mindrecord:domain") include(":feature:mindrecord:presentation")