diff --git a/.claude/skills/docs-refactor/SKILL.md b/.claude/skills/docs-refactor/SKILL.md new file mode 100644 index 00000000..48937334 --- /dev/null +++ b/.claude/skills/docs-refactor/SKILL.md @@ -0,0 +1,10 @@ +--- +name: docs-refactor +description: Use this skill for documentation refactoring, architecture rule changes, canonical docs cleanup, AGENTS.md updates, and reference cleanup in the Twix iOS repository. +--- + +# docs-refactor + +Canonical instructions live at `.pi/skills/docs-refactor/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.claude/skills/final-review/SKILL.md b/.claude/skills/final-review/SKILL.md new file mode 100644 index 00000000..44deb2f8 --- /dev/null +++ b/.claude/skills/final-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: final-review +description: Use this skill before opening a PR to run final review, Fastlane CI verification, commit preparation, approved commit execution, and PR draft generation. +--- + +# final-review + +Canonical instructions live at `.pi/skills/final-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.claude/skills/fix-review/SKILL.md b/.claude/skills/fix-review/SKILL.md new file mode 100644 index 00000000..f83bb0f5 --- /dev/null +++ b/.claude/skills/fix-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: fix-review +description: Use this skill to apply explicitly approved review-twix findings with minimal diffs, without broad implementation or reinterpretation. +--- + +# fix-review + +Canonical instructions live at `.pi/skills/fix-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.claude/skills/review-twix/SKILL.md b/.claude/skills/review-twix/SKILL.md new file mode 100644 index 00000000..43a2b006 --- /dev/null +++ b/.claude/skills/review-twix/SKILL.md @@ -0,0 +1,10 @@ +--- +name: review-twix +description: Use this skill for Twix iOS code, diff, PR, and architecture compliance review. Default behavior is report-only unless edits are explicitly requested. +--- + +# review-twix + +Canonical instructions live at `.pi/skills/review-twix/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/docs-refactor/SKILL.md b/.codex/skills/docs-refactor/SKILL.md new file mode 100644 index 00000000..48937334 --- /dev/null +++ b/.codex/skills/docs-refactor/SKILL.md @@ -0,0 +1,10 @@ +--- +name: docs-refactor +description: Use this skill for documentation refactoring, architecture rule changes, canonical docs cleanup, AGENTS.md updates, and reference cleanup in the Twix iOS repository. +--- + +# docs-refactor + +Canonical instructions live at `.pi/skills/docs-refactor/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/final-review/SKILL.md b/.codex/skills/final-review/SKILL.md new file mode 100644 index 00000000..44deb2f8 --- /dev/null +++ b/.codex/skills/final-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: final-review +description: Use this skill before opening a PR to run final review, Fastlane CI verification, commit preparation, approved commit execution, and PR draft generation. +--- + +# final-review + +Canonical instructions live at `.pi/skills/final-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/fix-review/SKILL.md b/.codex/skills/fix-review/SKILL.md new file mode 100644 index 00000000..f83bb0f5 --- /dev/null +++ b/.codex/skills/fix-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: fix-review +description: Use this skill to apply explicitly approved review-twix findings with minimal diffs, without broad implementation or reinterpretation. +--- + +# fix-review + +Canonical instructions live at `.pi/skills/fix-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/review-twix/SKILL.md b/.codex/skills/review-twix/SKILL.md new file mode 100644 index 00000000..43a2b006 --- /dev/null +++ b/.codex/skills/review-twix/SKILL.md @@ -0,0 +1,10 @@ +--- +name: review-twix +description: Use this skill for Twix iOS code, diff, PR, and architecture compliance review. Default behavior is report-only unless edits are explicitly requested. +--- + +# review-twix + +Canonical instructions live at `.pi/skills/review-twix/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.gitignore b/.gitignore index 83a9476e..cb424128 100644 --- a/.gitignore +++ b/.gitignore @@ -176,4 +176,12 @@ Icon Network Trash Folder Temporary Items .apdisk -src/SupportingFiles/Booket/GoogleService-Info.plist \ No newline at end of file +src/SupportingFiles/Booket/GoogleService-Info.plist +# Performance traces and local probe workspace (large, generated) +.perf/ + +# CodeGraph local index (generated, per-working-tree SQLite data) +.codegraph/ + +# Claude Code scheduled-task runtime lock (per-machine, not shared) +.claude/scheduled_tasks.lock diff --git a/.pi/extensions/twix-gate.ts b/.pi/extensions/twix-gate.ts new file mode 100644 index 00000000..81862178 --- /dev/null +++ b/.pi/extensions/twix-gate.ts @@ -0,0 +1,341 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import path from "node:path"; + +type RiskLevel = "safe" | "moderate" | "sensitive" | "commit" | "blocked"; + +type Decision = + | { level: "safe" } + | { + level: "moderate" | "sensitive" | "commit"; + title: string; + message: string; + cacheKey?: string; + } + | { level: "blocked"; reason: string }; + +const EXTRA_SENSITIVE_PATHS = [ + "AGENTS.md", + "CLAUDE.md", + "docs/Reference/ProjectRules.md", + "docs/Reference/Checklists.md", + "docs/Architecture/Overview.md", + ".pi/skills/", + ".pi/extensions/", + "Projects/Domain/Auth/Interface/Sources/TokenManager.swift", + "Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift", + "Projects/Core/Storage/Sources/KeychainTokenStorage.swift", + "Projects/Domain/Auth/Sources/AuthInterceptor.swift", + "fastlane/Fastfile", + "fastlane/README.md", + "Tuist/", + "Tuist.swift", + "Workspace.swift", +]; + +export default function (pi: ExtensionAPI) { + const sessionApproved = new Set(); + let executionApproved = false; + + pi.on("tool_call", async (event, ctx) => { + let decision: Decision = { level: "safe" }; + + if (event.toolName === "read") { + return undefined; + } + + if (event.toolName === "bash") { + decision = classifyBash(getCommand(event.input)); + } else if (event.toolName === "write" || event.toolName === "edit") { + decision = classifyFileTool(event.toolName, getPath(event.input)); + } + + const result = await enforceDecision(decision, ctx, sessionApproved, executionApproved); + if (result.executionApproved) executionApproved = true; + return result.block; + }); +} + +function getCommand(input: unknown): string { + if (typeof input === "object" && input !== null && "command" in input) { + const command = (input as { command?: unknown }).command; + return typeof command === "string" ? command.trim() : ""; + } + return ""; +} + +function getPath(input: unknown): string { + if (typeof input === "object" && input !== null && "path" in input) { + const filePath = (input as { path?: unknown }).path; + return typeof filePath === "string" ? filePath : ""; + } + return ""; +} + +function normalizePath(filePath: string): string { + const normalized = path.normalize(filePath).replace(/\\/g, "/"); + return normalized.replace(/^\.\//, ""); +} + +function isSecretPath(filePath: string): boolean { + const p = normalizePath(filePath).toLowerCase(); + const base = path.posix.basename(p); + + return ( + base === ".env" || + base.startsWith(".env.") || + /(^|\/)(secrets?|credentials?)(\/|\.|$)/i.test(p) || + /(^|\/)(provisioning|signing|certificates?|keychains?)(\/|\.|$)/i.test(p) || + /\.(p12|mobileprovision|cer|cert|key|pem)$/i.test(p) + ); +} + +function isExtraSensitivePath(filePath: string): boolean { + const p = normalizePath(filePath); + return EXTRA_SENSITIVE_PATHS.some((sensitive) => { + const normalizedSensitive = normalizePath(sensitive); + return normalizedSensitive.endsWith("/") + ? p === normalizedSensitive.slice(0, -1) || p.startsWith(normalizedSensitive) + : p === normalizedSensitive; + }); +} + +function sensitiveReason(filePath: string): string { + const p = normalizePath(filePath); + + if (p === "AGENTS.md" || p === "CLAUDE.md") return "agent baseline / Claude entry point"; + if (p.startsWith("docs/")) return "canonical project documentation"; + if (p.startsWith(".pi/skills/")) return "Pi skill definition"; + if (p.startsWith(".pi/extensions/")) return "Pi extension definition"; + if (p.includes("TokenManager") || p.includes("TokenStorage") || p.includes("KeychainTokenStorage")) { + return "token storage/auth boundary"; + } + if (p.includes("AuthInterceptor")) return "authorization header / token refresh infrastructure"; + if (p.startsWith("fastlane/") || p === "fastlane/Fastfile") return "Fastlane verification/deploy configuration"; + if (p.startsWith("Tuist/") || p === "Tuist.swift" || p === "Workspace.swift") return "Tuist project configuration"; + return "extra sensitive project path"; +} + +function hasShellControl(command: string): boolean { + return /(;|&&|\|\||\||`|\$\(|\bsh\s+-c\b|\bbash\s+-c\b)/.test(command); +} + +function isReadOnlyCommand(command: string): boolean { + const c = command.trim(); + return ( + /^(pwd)\s*$/.test(c) || + /^(ls)(\s+[^;&|`$()]*)?$/.test(c) || + /^(find)(\s+[^;&|`$()]*)?$/.test(c) || + /^(grep)(\s+[^;&|`$()]*)?$/.test(c) || + /^(rg)(\s+[^;&|`$()]*)?$/.test(c) || + /^git\s+status(\s+--short)?\s*$/.test(c) || + /^git\s+diff\s*$/.test(c) || + /^git\s+diff\s+--stat\s*$/.test(c) || + /^git\s+diff\s+--cached\s+--stat\s*$/.test(c) || + /^git\s+branch\s+--show-current\s*$/.test(c) + ); +} + +function classifyBash(command: string): Decision { + if (!command) return { level: "safe" }; + if (isReadOnlyCommand(command)) return { level: "safe" }; + + if (/^git\s+push\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks git push. Push only outside Pi or after extension policy is changed." }; + } + if (/^git\s+reset\s+--hard\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks git reset --hard. Use a non-destructive review/revert plan instead." }; + } + if (/^git\s+clean\s+(-[A-Za-z]*f[A-Za-z]*d|-[A-Za-z]*d[A-Za-z]*f)\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks git clean -fd because it deletes untracked files." }; + } + if (/\brm\s+[^\n]*(-[A-Za-z]*r[A-Za-z]*f|-[A-Za-z]*f[A-Za-z]*r|--recursive)\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks recursive rm. Use targeted deletion after explicit approval." }; + } + if (/^sudo\b/.test(command) || /\bsudo\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks sudo commands in this repository." }; + } + if (/^chmod\s+(-R\s+)?(777|[-+][^\s]*w)/.test(command) || /^chown\s+-R\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks broad chmod/chown changes." }; + } + if (/^xcodebuild\b/.test(command)) { + return { + level: "blocked", + reason: "twix-gate blocks direct xcodebuild unless scheme/destination/configuration were explicitly provided. Use bundle exec fastlane ios ci_pr for normal verification.", + }; + } + + if (/^git\s+add\s+(\.|-A|--all)\s*$/.test(command)) { + return sensitive("Sensitive git add confirmation", command, "This stages broad file sets. Session allow is unavailable. Confirm only after reviewing git status and diff."); + } + if (/^git\s+add\b/.test(command)) { + return moderate("Git add confirmation", command, "This stages files for commit. Confirm the file set is intended.", `bash:${command}`); + } + if (/^git\s+commit\b/.test(command)) { + return { + level: "commit", + title: "Git commit confirmation", + message: `Command: ${command}\n\nCommit approval is separate from plan/execution approval. This records repository history. Confirm only if the user approved both file set and commit message.`, + }; + } + if (/^(bundle\s+exec\s+)?fastlane\s+ios\s+ci_pr\s*$/.test(command)) { + return moderate( + "Run CI/PR verification?", + command, + "This runs Fastlane CI/PR verification and may take time.", + `bash:${command}`, + ); + } + if (/^tuist\s+clean\s*$/.test(command)) { + return moderate("Tuist clean confirmation", command, "This clears Tuist cache/generated state. Continue?", "bash:tuist clean"); + } + if (/^rm\s+/.test(command)) { + return moderate("File deletion confirmation", command, "This deletes files. Confirm the target is correct."); + } + + if (hasShellControl(command)) { + return moderate("Complex bash command confirmation", command, "This command uses shell control operators. Confirm it is safe to run."); + } + + return { level: "safe" }; +} + +function classifyFileTool(toolName: string, filePath: string): Decision { + const p = normalizePath(filePath); + if (!p) return { level: "blocked", reason: `twix-gate blocked ${toolName}: missing path.` }; + + if (isSecretPath(p)) { + return { level: "blocked", reason: `twix-gate blocks edits to secrets/env/credentials/provisioning/signing/keychain paths: ${p}` }; + } + + if (isExtraSensitivePath(p)) { + return { + level: "sensitive", + title: "Sensitive file edit confirmation", + message: `Tool: ${toolName}\nPath: ${p}\nReason: ${sensitiveReason(p)}\n\nSession allow is unavailable for sensitive paths. Confirm only if the user explicitly approved this sensitive edit.`, + }; + } + + return { + level: "moderate", + title: "File edit confirmation", + message: `Tool: ${toolName}\nPath: ${p}\n\nThis modifies a repository file. Session approval lasts only for this Pi session.`, + cacheKey: `${toolName}:${p}`, + }; +} + +function moderate(title: string, command: string, detail: string, cacheKey?: string): Decision { + return { + level: "moderate", + title, + message: `Command: ${command}\n\n${detail}\n\nSession approval lasts only for this Pi session.`, + cacheKey, + }; +} + +function sensitive(title: string, command: string, detail: string): Decision { + return { + level: "sensitive", + title, + message: `Command: ${command}\n\n${detail}`, + }; +} + +async function enforceDecision( + decision: Decision, + ctx: ExtensionContext, + sessionApproved: Set, + executionApproved: boolean, +): Promise<{ block?: { block: true; reason?: string }; executionApproved?: boolean }> { + if (decision.level === "safe") return {}; + if (decision.level === "blocked") return { block: { block: true, reason: decision.reason } }; + + if (!executionApproved) { + const execution = await askExecutionCheckpoint(decision, ctx); + if (execution.block) return { block: execution.block }; + executionApproved = execution.executionApproved ?? executionApproved; + } + + if (decision.level === "moderate" && decision.cacheKey && sessionApproved.has(decision.cacheKey)) { + return { executionApproved }; + } + + if (!ctx.hasUI) { + return { block: { block: true, reason: `twix-gate requires confirmation but UI is unavailable: ${decision.title}` } }; + } + + if (decision.level === "moderate") { + const choice = await selectOrConfirm( + ctx, + decision.title, + `${decision.message}\n\nAllow this action?`, + ["Allow once", "Allow this action/path/command for this session", "Deny"], + ); + + if (choice === "Deny") { + return { block: { block: true, reason: `Blocked by twix-gate: ${decision.title}` }, executionApproved }; + } + if (choice === "Allow this action/path/command for this session" && decision.cacheKey) { + sessionApproved.add(decision.cacheKey); + } + return { executionApproved }; + } + + const title = decision.level === "commit" ? "⚠️ Git commit confirmation" : `⚠️ ${decision.title}`; + const extra = decision.level === "commit" + ? "\n\nCommit approval is separate from plan/execution approval. Session allow is unavailable for commits." + : "\n\nSession allow is unavailable for sensitive actions."; + const choice = await selectOrConfirm(ctx, title, `${decision.message}${extra}\n\nAllow this action?`, ["Allow once", "Deny"]); + + if (choice !== "Allow once") { + return { block: { block: true, reason: `Blocked by twix-gate: ${decision.title}` }, executionApproved }; + } + + return { executionApproved }; +} + +async function askExecutionCheckpoint( + decision: Exclude, + ctx: ExtensionContext, +): Promise<{ block?: { block: true; reason?: string }; executionApproved?: boolean }> { + if (!ctx.hasUI) { + return { block: { block: true, reason: "twix-gate requires execution approval but UI is unavailable." } }; + } + + const choice = await selectOrConfirm( + ctx, + "Start non-read-only execution?", + [ + "The agent is about to start a non-read-only action.", + "This checkpoint is separate from plan approval.", + "It does not override sensitive, commit, or blocked checks.", + "Action:", + decision.message, + ].join("\n\n"), + ["Allow once", "Start execution for this session", "Deny"], + ); + + if (choice === "Deny") { + return { block: { block: true, reason: "Blocked by twix-gate: execution checkpoint denied." } }; + } + + return { executionApproved: choice === "Start execution for this session" }; +} + +async function selectOrConfirm( + ctx: ExtensionContext, + title: string, + message: string, + choices: string[], +): Promise { + const ui = ctx.ui as ExtensionContext["ui"] & { + select?: (prompt: string, options: string[]) => Promise; + }; + + if (typeof ui.select === "function") { + const choice = await ui.select(`${title}\n\n${message}`, choices); + return choice ?? "Deny"; + } + + const ok = await ctx.ui.confirm(title, `${message}\n\n${choices.includes("Allow once") ? "Allow once?" : "Allow?"}`); + return ok ? "Allow once" : "Deny"; +} diff --git a/.pi/skills/docs-refactor/SKILL.md b/.pi/skills/docs-refactor/SKILL.md new file mode 100644 index 00000000..ab407e0a --- /dev/null +++ b/.pi/skills/docs-refactor/SKILL.md @@ -0,0 +1,100 @@ +--- +name: docs-refactor +description: Use this skill for documentation refactoring, architecture rule changes, canonical docs cleanup, AGENTS.md updates, and reference cleanup in the Twix iOS repository. +--- + +# docs-refactor + +## 목적 + +프로젝트 문서를 최소 diff로 정리하고, 아키텍처 규칙 변경/정리/이동을 안전하게 수행합니다. + +이 skill은 기존 임시 MODE 1 문서 정리 흐름을 대체합니다. + +## 사용 시점 + +- 문서 중복 제거 +- canonical docs 정리 +- AGENTS.md 업데이트 +- architecture rule 변경 검토 +- stale reference / deleted-file reference 정리 +- compatibility redirect 제거 또는 이동 계획 +- 문서 간 모순 해소 + +## 기준 문서 + +우선순위: + +1. `AGENTS.md` +2. `docs/Architecture/Overview.md` +3. `docs/Reference/ProjectRules.md` +4. `docs/Reference/Checklists.md` +5. `docs/Reference/FileOrganization.md` +6. `docs/Reference/NamingConventions.md` +7. `docs/Guides/NavigationStack.md` +8. `docs/Guides/NetworkGuide.md` +9. `docs/QuickStart.md`는 튜토리얼로만 취급 + +`CLAUDE.md`는 Claude Code 진입점이며 아키텍처 source of truth가 아닙니다. + +## 절차 + +1. 요청 범위를 확인합니다. +2. 관련 문서를 읽습니다. +3. 필요한 경우 `rg`로 참조를 확인합니다. +4. 문서 변경의 기술적 타당성을 먼저 검증합니다. +5. broad documentation edit 전에는 계획을 먼저 제시합니다. +6. 승인 후 최소 diff로 수정합니다. +7. 수정 후 다음을 검증합니다. + - broken links + - stale references + - duplicate docs + - deleted-file references + - unresolved contradictions + - AGENTS.md와 canonical docs 간 불일치 + +## 금지 사항 + +- 스타일만을 위한 대규모 rewrite 금지 +- 아키텍처 규칙 invent 금지 +- source code 수정 금지, 단 사용자가 명시적으로 요청한 경우는 예외 +- implementation skill 생성 금지 +- TypeScript extension 생성 금지 +- `CLAUDE.md`를 아키텍처 source of truth로 취급 금지 + +## 검증 정책 + +- Setup/generation: `tuist install`, `tuist generate`, 필요 시 `tuist clean` +- CI/PR verification: `bundle exec fastlane ios ci_pr` +- Bundler 사용 불가 시: `fastlane ios ci_pr` +- direct `xcodebuild`는 scheme / destination / configuration이 명시적으로 문서화되었거나 제공된 경우에만 사용합니다. +- 검증을 실행할 수 없으면 검증 한계를 보고합니다. + +## 출력 형식 + +한국어로 보고합니다. + +```text +상태: + +검토한 문서: +- + +타당성 판단: +- + +수정 파일: +- + +변경 요약: +- + +삭제/이동/병합한 규칙: +- + +남은 결정 사항: +- + +review-twix에 전달할 영향: +- +``` diff --git a/.pi/skills/final-review/SKILL.md b/.pi/skills/final-review/SKILL.md new file mode 100644 index 00000000..af84d112 --- /dev/null +++ b/.pi/skills/final-review/SKILL.md @@ -0,0 +1,304 @@ +--- +name: final-review +description: Use this skill before opening a PR to run final review, Fastlane CI verification, commit preparation, approved commit execution, and PR draft generation. +--- + +# final-review + +## 목적 + +Twix iOS 저장소에서 PR 생성 전 또는 집중 구현/리팩터링 완료 후 사용하는 pre-PR finalization skill입니다. + +이 skill은 구현 skill이 아닙니다. 기본적으로 scope 점검, 최종 리뷰, 검증, 실패 분석, 커밋 준비/실행, PR 초안 생성을 수행합니다. 명시 요청 없이는 코드를 수정하거나 리팩터링하지 않으며, PR을 열거나 push하지 않습니다. + +## 기준 문서 + +항상 다음 기준을 따릅니다. + +1. `AGENTS.md` +2. `docs/Reference/Checklists.md` +3. `docs/Reference/ProjectRules.md` +4. `.pi/skills/review-twix/SKILL.md`가 있으면 review standard로 사용 + +`review-twix`의 세부 규칙을 이 skill에 중복하지 않습니다. 최종 리뷰 단계에서는 `review-twix` 기준을 적용합니다. + +## 금지 사항 + +- push 금지 +- PR 생성/open 금지 +- force push 금지 +- 명시 요청 없는 amend / rebase / reset / stash 금지 +- 명시 요청 없는 파일 수정 금지 +- 명시 요청 없는 리팩터링 금지 +- 명시 요청 없는 문서 수정 금지 +- 테스트 생성 금지 +- 구현 작업 생성 금지 +- TypeScript extension 생성 금지 +- 실패한 검증 숨김 금지 +- secrets, env files, credentials, 예상하지 못한 generated files 커밋 금지 +- tool 또는 generation source를 식별하는 commit metadata 추가 금지 + +## 검증 정책 + +우선 검증 명령: + +```bash +bundle exec fastlane ios ci_pr +``` + +Bundler를 사용할 수 없는 경우에만 fallback: + +```bash +fastlane ios ci_pr +``` + +- `tuist build`는 표준 검증 명령으로 사용하지 않습니다. +- direct `xcodebuild` scheme / destination / configuration을 invent하지 않습니다. +- direct `xcodebuild`는 명시적으로 문서화되었거나 제공된 경우에만 사용합니다. +- Fastlane을 로컬에서 실행할 수 없으면 정확한 검증 한계를 보고하고, 검증이 통과했다고 말하지 않습니다. + +## 커밋 정책 + +커밋 전 반드시 확인합니다. + +```bash +git branch --show-current +git status --short +git diff --stat +git diff --cached --stat +``` + +- 관련 없는 파일을 커밋에 포함하지 않습니다. +- 파일을 blind stage하지 않습니다. +- unstaged changes가 있으면 staging 전에 요약합니다. +- 이미 staged changes가 있으면 사용자의 staging 의도를 보존합니다. +- staged/unstaged 변경이 여러 commit scope를 암시하면 중단하고 확인합니다. +- review에서 blocking issue가 있으면 커밋하지 않습니다. +- Fastlane 검증 실패 시 커밋하지 않습니다. +- 예상하지 못한 파일, secret, env, credential, generated file 변경이 있으면 커밋하지 않습니다. +- 사용자 승인이 없으면 커밋하지 않습니다. 단, 사용자가 요청에서 명시적으로 커밋까지 지시한 경우는 예외입니다. + +## 커밋 메시지 정책 + +형식: + +```text +: - # +``` + +허용 type: + +- `feat` +- `fix` +- `refactor` +- `docs` +- `test` +- `chore` + +규칙: + +- summary는 간결한 한국어로 작성합니다. +- 사용자가 명시적으로 요청하지 않으면 commit body를 추가하지 않습니다. +- footer, co-author metadata, tool attribution, generation/source marker를 추가하지 않습니다. +- issue number를 invent하지 않습니다. +- 현재 branch name에서 numeric issue suffix를 추출합니다. +- 예: `feat/#302/TWI-86` → commit suffix는 `#302` +- `TWI-86` 같은 Linear issue key는 workflow/integration 용도로 존재할 수 있지만 commit suffix를 대체하지 않습니다. +- 여러 issue-like identifier가 있으면 numeric `#` segment를 우선합니다. +- numeric issue number가 없으면 커밋 전에 사용자에게 issue number를 요청합니다. +- 여러 unrelated changes가 있으면 commit split을 권장합니다. + +Type 선택 기준: + +- `feat`: 사용자에게 보이는 새 기능 +- `fix`: 버그 수정 +- `refactor`: 동작 보존 구조 변경 +- `docs`: 문서만 변경 +- `test`: 테스트만 변경 +- `chore`: 유지보수/config/tooling 변경 + +## Phase 1 — Scope and branch status + +- `AGENTS.md`를 읽습니다. +- 현재 branch name을 확인합니다. +- `git status --short`로 상태를 확인합니다. +- changed files와 diff summary를 확인합니다. +- staged / unstaged / mixed 상태를 구분합니다. +- 다음 정보를 바탕으로 의도된 PR/change scope를 추론합니다. + - branch name + - changed files + - diff summary + - 필요한 경우 existing commit messages +- diff가 하나의 coherent PR scope인지 점검합니다. +- 여러 unrelated scope가 있으면 split commit 또는 split PR을 권장합니다. +- branch name에서 numeric issue suffix를 추출합니다. +- 예: `feat/#302/TWI-86`이면 `#302`를 commit issue suffix로 사용합니다. +- `TWI-86` 같은 Linear key는 workflow/integration metadata로 취급하며 commit suffix로 사용하지 않습니다. +- 다음 경우 중단하고 사용자에게 확인합니다. + - 관련 없거나 위험한 파일이 있음 + - numeric issue number를 찾을 수 없음 + - branch naming이 모호함 + - staged/unstaged 변경이 여러 commit scope를 암시함 + +## Phase 2 — Final review + +- `review-twix` 기준을 적용합니다. +- 사용자가 더 넓은 리뷰를 요청하지 않는 한 changed files / diff만 리뷰합니다. +- 다음 리스크를 확인합니다. + - architecture fit + - TCA / MFA boundaries + - Interface/Sources split + - navigation pattern + - network/client rules + - TokenManager / TokenStorage rules + - dependency direction + - public API growth + - docs impact + - unexpected generated / secrets / env files +- blocking / non-blocking issue를 구분해 보고합니다. +- 명시 요청 없이는 수정하지 않습니다. + +## Phase 3 — Verification + +- 우선 `bundle exec fastlane ios ci_pr`를 실행합니다. +- Bundler를 사용할 수 없는 경우 `fastlane ios ci_pr`를 실행합니다. +- pass/fail을 보고합니다. +- 실행할 수 없으면 정확한 한계를 보고합니다. +- undocumented `xcodebuild` 명령으로 대체하지 않습니다. + +## Phase 3B — Verification failure analysis + +검증이 실패하면 자동으로 커밋 단계로 진행하지 않습니다. + +- failure output을 분석합니다. +- 실패 유형을 분류합니다. + - compile + - lint + - test + - dependency + - signing/provisioning + - script/tooling + - unknown +- 관련 가능성이 높은 파일 또는 모듈을 식별합니다. +- 원인을 다음 중 하나로 구분합니다. + - current diff caused + - environment/tooling + - pre-existing failure + - unknown +- 최소 다음 조치를 제안합니다. +- 명시 요청 없이는 파일을 수정하지 않습니다. +- 실패한 검증을 숨기지 않습니다. +- 적절한 경우 retry command를 포함합니다. + +## Phase 4 — Commit preparation + +- 다음을 확인합니다. + - `git status --short` + - `git diff --stat` + - staged file이 있으면 `git diff --cached --stat` +- 이미 staged files가 있으면 사용자의 staging 의도를 보존합니다. +- 파일을 blind stage하지 않습니다. +- 커밋 대상 파일을 요약합니다. +- 관련 없는 파일을 확인합니다. +- secrets, env files, credentials, 예상하지 못한 generated files를 확인합니다. +- 다음 형식으로 커밋 메시지를 제안합니다. + +```text +: - # +``` + +- 필요 시 split commit을 권장합니다. +- 이미 명시적으로 커밋 권한을 받은 경우가 아니면 커밋 전 승인을 요청합니다. + +## Phase 5 — Commit + +- 승인된 파일만 stage합니다. +- 승인된 메시지로 commit합니다. +- 명시 요청이 없으면 commit body를 추가하지 않습니다. +- footer, 외부 authorship, tool metadata를 추가하지 않습니다. +- amend, rebase, reset, stash, force push, push, PR open은 명시 요청 없이는 수행하지 않습니다. +- 커밋 후 다음을 보고합니다. + - commit hash + - committed files + - verification status + - remaining uncommitted files + +## Phase 6 — PR draft + +PR 제목과 설명 초안을 생성합니다. PR을 열거나 push하지 않습니다. + +PR 초안은 다음을 바탕으로 작성합니다. + +- branch name +- committed 또는 pending diff +- review result +- verification result +- known risks + +PR 제목: + +- 저장소 convention이 명확히 영어를 요구하지 않는 한 간결한 한국어로 작성합니다. + +PR 설명에는 다음을 포함합니다. + +- 요약 +- 변경 사항 +- 검증 결과 +- 리뷰 포인트 +- 리스크 / 후속 작업 +- 관련 이슈 + +관련 이슈: + +- numeric issue suffix가 있으면 `#302`처럼 사용합니다. +- `TWI-86` 같은 Linear key는 branch/workflow 맥락상 유용할 때만 언급하며 numeric issue reference를 대체하지 않습니다. + +## 최종 보고 형식 + +한국어로 작성합니다. + +```text +변경 범위: +- + +브랜치 / 이슈 번호: +- + +Scope 점검 결과: +- + +최종 리뷰 결과: +- + +검증 결과: +- + +검증 실패 분석: +- + +커밋 대상 파일: +- + +제안 커밋 메시지: +- + +커밋 여부: +- + +PR 제목 초안: +- + +PR 설명 초안: +- 요약: +- 변경 사항: +- 검증 결과: +- 리뷰 포인트: +- 리스크 / 후속 작업: +- 관련 이슈: + +남은 리스크: +- + +다음 PR 전 확인 사항: +- +``` diff --git a/.pi/skills/fix-review/SKILL.md b/.pi/skills/fix-review/SKILL.md new file mode 100644 index 00000000..7fddd24d --- /dev/null +++ b/.pi/skills/fix-review/SKILL.md @@ -0,0 +1,171 @@ +--- +name: fix-review +description: Use this skill to apply explicitly approved review-twix findings with minimal diffs, without broad implementation or reinterpretation. +--- + +# fix-review + +## 목적 + +`fix-review`는 `review-twix`가 보고한 finding 중 사용자가 명시적으로 선택하거나 승인한 항목만 최소 diff로 수정합니다. + +이 skill은 review 결과에 대한 후속 실행 skill입니다. broad implementation skill이 아니며, 프로젝트 전체를 독자적으로 재해석하지 않습니다. + +## 다른 skill과의 관계 + +- `review-twix`: 문제를 찾고 기본적으로 보고만 수행합니다. +- `fix-review`: 승인된 review finding만 수정합니다. +- `final-review`: 최종 리뷰, 검증, 커밋 준비/실행 승인, PR 초안 생성을 수행합니다. +- `docs-refactor`: 문서 아키텍처/규칙 변경을 다룹니다. + +문서 전용 finding이 승인된 경우 `docs-refactor`의 지침을 참고할 수 있지만, `docs-refactor` 자체를 중복하지 않습니다. + +## 기준 문서 + +항상 먼저 읽습니다. + +1. `AGENTS.md` + +필요한 경우에만 관련 canonical docs를 추가로 읽습니다. + +- `review-twix` report: primary input +- `docs/Reference/Checklists.md`: implementation checklist +- `docs/Reference/ProjectRules.md`: project rules +- task-specific canonical docs + +`CLAUDE.md`는 architecture source of truth로 취급하지 않습니다. + +## 입력 기대값 + +사용자는 다음 중 하나 이상을 제공하거나 참조해야 합니다. + +- finding ID: 예) `R1`, `R2` +- severity filter: 예) `High only` +- 명시 승인 문구: 예) “fix R1 and R2” +- 허용 scope: 예) docs only, source only, specific files only + +## 기본 동작 + +- finding이 명시적으로 선택되거나 승인되지 않으면 수정하지 않습니다. +- 사용자가 “fix all”이라고 하면, scope가 작고 명확한 경우가 아니면 먼저 finding 요약과 확인 요청을 합니다. +- owner decision이 필요한 finding은 추측하지 않습니다. +- broad refactor가 필요한 finding은 파일을 수정하지 않고 계획을 제안합니다. +- finding이 `AGENTS.md` 또는 canonical docs와 충돌하면 중단하고 보고합니다. + +## 수정 규칙 + +- 최소 diff를 적용합니다. +- 기술적 의미를 보존합니다. +- style-only rewrite를 하지 않습니다. +- 새 architecture pattern을 도입하지 않습니다. +- 명시 승인 없이 public interface를 변경하지 않습니다. +- 명시 요청 없이 테스트를 만들지 않습니다. +- build command를 invent하지 않습니다. +- `TokenStorage`, Keychain, UserDefaults에 직접 접근하지 않습니다. +- `StackState`, `StackActionOf`를 도입하지 않습니다. +- Feature client는 기본적으로 protocol-based로 만들지 않습니다. +- 명시 요청 없이 삭제된 legacy docs를 복원하지 않습니다. +- 관련 없는 파일을 수정하지 않습니다. + +## 금지 사항 + +- general implementation 수행 금지 +- final-review 대체 금지 +- commit 금지 +- push 금지 +- PR 생성 금지 +- TypeScript extension 생성 금지 +- 승인되지 않은 finding opportunistic fix 금지 + +## Workflow + +### Phase 1 — Parse approved findings + +- 사용자가 선택한 review finding ID를 식별합니다. +- 허용 파일/scope를 식별합니다. +- 각 finding을 다음 중 하나로 분류합니다. + - documentation fix + - source code fix + - example code fix + - verification/config fix + - owner-decision required + - broad refactor +- 승인 scope가 모호하면 중단하고 확인합니다. + +### Phase 2 — Plan fixes + +- 승인된 각 finding별 최소 수정안을 제안합니다. +- 변경 예상 파일을 나열합니다. +- 위험을 식별합니다. +- 3개 초과 파일 변경 또는 broad refactor가 필요하면 편집 전에 확인을 요청합니다. + +### Phase 3 — Apply fixes + +- 승인된 finding 해결에 필요한 파일만 수정합니다. +- diff를 focused하게 유지합니다. +- 기존 convention을 보존합니다. +- 승인되지 않은 finding은 함께 수정하지 않습니다. + +### Phase 4 — Self-check + +- 수정한 finding을 다시 점검합니다. +- 원래 문제가 해결되었는지 확인합니다. +- 금지 패턴이 도입되지 않았는지 확인합니다. +- 관련 없는 파일이 변경되지 않았는지 확인합니다. + +### Phase 5 — Report + +한국어로 보고합니다. + +포함 항목: + +- 수정한 finding +- 수정하지 않은 finding과 이유 +- 변경 파일 +- 변경 요약 +- 자체 점검 결과 +- 검증 결과 / 검증 한계 +- 후속으로 `review-twix`에 다시 맡길 항목 + +## 검증 정책 + +Fastlane은 기본 실행하지 않습니다. + +검증 요청이 있으면 다음을 사용합니다. + +```bash +bundle exec fastlane ios ci_pr +``` + +Bundler를 사용할 수 없는 경우에만 fallback을 사용합니다. + +```bash +fastlane ios ci_pr +``` + +`tuist build`를 사용하지 않습니다. `xcodebuild` scheme/destination/configuration을 invent하지 않습니다. + +## 출력 형식 + +```text +수정한 finding: +- + +수정하지 않은 finding과 이유: +- + +변경 파일: +- + +변경 요약: +- + +자체 점검 결과: +- + +검증 결과 / 검증 한계: +- + +후속으로 review-twix에 다시 맡길 항목: +- +``` diff --git a/.pi/skills/handoff-twix/SKILL.md b/.pi/skills/handoff-twix/SKILL.md new file mode 100644 index 00000000..7f76e85d --- /dev/null +++ b/.pi/skills/handoff-twix/SKILL.md @@ -0,0 +1,447 @@ +--- +name: handoff-twix +description: Use this skill to coordinate concise low-token handoffs between Pi, Claude Code, review-twix, fix-review, and final-review in the Twix iOS repository. +--- + +# handoff-twix + +## 목적 + +`handoff-twix`는 Pi, Claude Code, `review-twix`, `fix-review`, `final-review` 사이의 low-token handoff를 조율합니다. + +이 skill은 implementation skill이 아닙니다. 명시 요청이 없는 한 Pi가 feature를 직접 구현하지 않습니다. Pi가 계획과 handoff 파일을 만들고, Claude Code가 구현하며, Pi가 review/fix/finalize를 이어갈 수 있도록 간결한 handoff 파일과 runner를 사용합니다. + +## Invocation behavior + +`handoff-twix`는 누가 작업을 수행할지 조율하는 orchestration skill입니다. 사용자가 handoff를 명시적으로 요청한 경우, 의미상 가장 가까운 다른 skill로 collapse하거나 silent switch하지 않습니다. 명시 요청이 없는 한 Pi가 직접 구현/문서 rewrite를 수행하지 않습니다. + +Rules: + +1. 사용자가 concrete task 없이 `/skill:handoff-twix`만 호출한 경우: + - task를 추론하지 않습니다. + - `docs-refactor`, `review-twix`, `fix-review`, `final-review`로 전환하지 않습니다. + - 어떤 handoff workflow를 원하는지 질문합니다. + - 다음 선택지를 간결하게 제시합니다. + - full handoff with Claude Code + - create handoff files only + - continue after Claude implementation + - review implementation result + - apply approved fixes + - proceed to final-review + +2. 사용자가 `/skill:handoff-twix`와 concrete task/command를 함께 제공한 경우: + - matching handoff workflow를 즉시 수행합니다. + - 필수 정보가 빠진 경우가 아니면 redundant clarification을 묻지 않습니다. + - `handoff-twix` 책임 범위 안에 머무릅니다. + - 사용자가 명시적으로 요청하지 않는 한 Pi가 직접 requested change를 구현하지 않습니다. + - full handoff가 요청되면 Pi는 handoff 파일을 만들고 Claude Code runner를 사용하거나 준비합니다. + - partial mode가 요청되면 해당 partial mode만 수행합니다. + +3. 요청 task가 의미상 `docs-refactor`, `review-twix`, `fix-review`, `final-review`에 가까운 경우: + - 사용자가 `handoff-twix` full handoff를 요청했다면 해당 skill로 silent switch하지 않습니다. + - full handoff mode에서 해당 skills는 Claude implementation을 대체하지 않고 references/phases로만 사용합니다. + - `docs-refactor`, `review-twix`, `fix-review`, `final-review`는 적절한 phase에서만 사용하거나 사용자가 해당 partial workflow를 명시적으로 요청한 경우에만 사용합니다. + +4. task가 `handoff-twix` scope 밖인 경우: + - `handoff-twix` scope 밖이라고 말합니다. + - 적절한 skill을 제안하되, 사용자가 요청하지 않으면 자동 전환하지 않습니다. + +5. 사용자가 이미 다음을 제공한 경우 “진행할까요?”를 묻지 않습니다. + - workflow type + - implementation agent 또는 default Claude Code runner + - task + - handoff 파일 작성에 충분한 scope + +6. 다음처럼 필수 detail이 빠진 경우에만 질문합니다. + - task가 제공되지 않음 + - full handoff와 files-only가 모호함 + - external Claude Code 실행이 요청되었지만 approval이 필요함 + - task에 owner decision이 필요함 + - broad refactor 또는 public API change가 암시됨 + +Examples: + +- Example A — skill only + - User: `/skill:handoff-twix` + - Expected behavior: 어떤 handoff workflow를 실행할지 질문합니다. `docs-refactor`나 implementation을 시작하지 않습니다. + +- Example B — full handoff + - User: `/skill:handoff-twix` + `Run full handoff with Claude Code: make docs-refactor/review-twix/fix-review/final-review usable by Codex/Claude Code through shared workflow docs.` + - Expected behavior: `PLAN.md`와 `IMPLEMENTATION_REQUEST.md`를 작성한 뒤 approval gates에 따라 Claude Code runner를 준비/실행합니다. Pi가 직접 docs를 수정하지 않습니다. + +- Example C — files only + - User: `/skill:handoff-twix` + `Create handoff files only for this task: ...` + - Expected behavior: `PLAN.md`와 `IMPLEMENTATION_REQUEST.md`를 작성한 뒤 중단합니다. + +- Example D — continue + - User: `/skill:handoff-twix` + `Claude implementation is done. Continue from IMPLEMENTATION_RESULT.md and git diff.` + - Expected behavior: result file과 `git diff`를 읽고 `review-twix` standard를 적용한 뒤 `REVIEW_REPORT.md`를 작성합니다. + +- Example E — outside scope + - User: handoff 없이 직접 docs rewrite를 요청합니다. + - Expected behavior: 사용자가 full handoff를 원하는 것이 아니라면 `docs-refactor`가 더 적절하다고 말합니다. + +## Implementation agent + +Claude Code가 고정 implementation agent입니다. + +- Preferred runner: `Scripts/run-claude-implementation.sh` +- Claude invocation: `claude -p` +- `--bare`는 현재 사용하지 않습니다. +- timeout은 사용하지 않습니다. +- budget은 `--max-budget-usd 5.00`입니다. +- permission mode는 `--permission-mode acceptEdits`입니다. +- manual handoff는 Claude runner를 사용할 수 없거나 사용자가 명시적으로 선택한 경우의 fallback입니다. + +Codex는 이 skill의 implementation orchestration 대상이 아닙니다. + +Claude implementation은 Fastlane/build verification을 실행하지 않습니다. Pi의 `final-review`가 verification, commit, PR draft를 담당합니다. + +## Primary workflow + +기본 동작은 end-to-end handoff입니다. 사용자가 전체 handoff를 요청하면 plan 작성부터 Claude Code handoff, 구현 후 review, 승인된 safe fix, 필요 시 final-review handoff까지 한 흐름으로 조율합니다. + +1. Pi가 concise implementation plan을 만듭니다. +2. Pi가 Claude Code용 handoff 파일을 작성합니다. +3. Claude Code가 runner 또는 manual handoff로 구현합니다. +4. Claude Code가 concise implementation result 파일을 작성합니다. +5. Pi가 `git diff`를 source of truth로 삼아 `review-twix` 기준으로 결과를 리뷰합니다. +6. 요청/승인 시 Pi가 `fix-review` 기준으로 승인된 safe finding만 수정합니다. +7. 요청 시 Pi가 `final-review`로 verification, commit, PR draft를 넘깁니다. + +개별 Mode A-E는 명시적으로 요청할 때 사용하는 partial workflow입니다. 전체 흐름을 진행하려면 Default workflow를 우선합니다. + +## 기준 문서 + +항상 먼저 읽습니다. + +1. `AGENTS.md` + +필요한 경우에만 canonical docs를 읽습니다. + +- `docs/Reference/Checklists.md` +- `docs/Reference/ProjectRules.md` +- `docs/Guides/NavigationStack.md` +- `docs/Guides/NetworkGuide.md` +- task-specific canonical docs + +규칙: + +- 긴 docs 내용을 handoff 파일에 복사하지 않습니다. +- handoff 파일은 canonical docs를 path로 참조합니다. +- `CLAUDE.md`를 architecture source of truth로 취급하지 않습니다. +- 삭제된 `Prompt.md`를 사용하지 않습니다. +- 구현 후에는 `git diff`를 source of truth로 사용합니다. +- `AGENTS.md` 또는 project docs를 길게 재진술하지 않습니다. + +## Handoff directory + +사용 경로: + +```text +.agent/handoff/ +``` + +예상 파일: + +```text +.agent/handoff/PLAN.md +.agent/handoff/IMPLEMENTATION_REQUEST.md +.agent/handoff/IMPLEMENTATION_RESULT.md +.agent/handoff/REVIEW_REPORT.md +.agent/handoff/FIX_REPORT.md +.agent/handoff/claude.out +.agent/handoff/claude.err +``` + +## 공통 원칙 + +- handoff 파일은 간결하게 유지합니다. +- 파일을 작성했다면 chat에는 큰 plan을 출력하지 않습니다. +- verbose chat output보다 structured handoff file을 우선합니다. +- full diff를 출력하지 않습니다. 요청 시에만 출력합니다. +- 다른 agent가 같은 session context를 갖고 있다고 가정하지 않습니다. +- file path와 concise instruction만 전달합니다. + +## Default workflow — End-to-end handoff + +사용 시점: 사용자가 full handoff process를 요청할 때. + +Process: + +1. `.agent/handoff/PLAN.md`를 작성합니다. +2. `.agent/handoff/IMPLEMENTATION_REQUEST.md`를 작성합니다. +3. Claude Code implementation 방식을 결정합니다. + - preferred: `Scripts/run-claude-implementation.sh` + - fallback: manual handoff to Claude Code +4. runner 실행이 요청된 경우: + - `Scripts/run-claude-implementation.sh`를 사용합니다. + - twix-gate confirmation을 존중합니다. + - runner는 `claude -p`를 사용합니다. + - runner는 `--bare`를 사용하지 않습니다. + - runner는 timeout을 사용하지 않습니다. + - runner는 budget `5.00 USD`를 사용합니다. + - runner는 Claude에게 Read/Edit/Write와 limited read-only bash만 허용합니다. + - runner는 git add/commit/push, Fastlane, xcodebuild, `tuist clean`, destructive commands를 금지합니다. +5. manual handoff가 선택된 경우: + - handoff 파일 작성 후 중단합니다. + - Claude Code에게 전달할 정확하고 간결한 instruction을 제공합니다. +6. Claude implementation 완료 후: + - 있으면 `.agent/handoff/IMPLEMENTATION_RESULT.md`를 읽습니다. + - result file에 `STATUS: DONE | BLOCKED | NO_CHANGES | FAILED` 중 하나가 있는지 확인합니다. + - `git diff`를 inspect합니다. + - `git diff`를 source of truth로 사용합니다. +7. `review-twix` standard를 실행합니다. +8. `.agent/handoff/REVIEW_REPORT.md`를 작성합니다. +9. finding이 있으면: + - blocking / non-blocking으로 분류합니다. + - `fix-review`로 안전하게 수정 가능한지 분류합니다. +10. 사용자가 safe finding 자동 수정을 이미 승인한 경우: + - 승인되고 safe로 분류된 finding만 `fix-review`로 수정합니다. + - `.agent/handoff/FIX_REPORT.md`를 작성합니다. +11. auto-fix 승인이 없으면: + - 중단하고 어떤 finding을 수정할지 질문합니다. +12. 요청된 경우 `final-review`로 hand off합니다. +13. `handoff-twix` 내부에서는 commit, push, PR 생성을 하지 않습니다. + +Important approval policy: + +- Plan approval은 commit approval이 아닙니다. +- Handoff execution approval은 git commit approval이 아닙니다. +- Claude runner 실행 approval은 git commit approval이 아닙니다. +- `fix-review` approval은 `final-review` commit approval과 별개입니다. +- broad refactor, owner-decision finding, public API change, risky architecture change는 approval을 위해 중단합니다. + +## Claude runner behavior + +`Scripts/run-claude-implementation.sh`는 repository root에서 실행합니다. + +Runner responsibilities: + +- `.agent/handoff/IMPLEMENTATION_REQUEST.md` 존재 확인 +- `.agent/handoff/` 생성 보장 +- `uuidgen`이 있으면 UUID session id 생성, 없으면 timestamp fallback 사용 +- Claude stdout을 `.agent/handoff/claude.out`에 저장 +- Claude stderr를 `.agent/handoff/claude.err`에 저장 +- Claude 종료 후 `.agent/handoff/IMPLEMENTATION_RESULT.md` 존재 확인 +- result file에 `STATUS:` line이 있는지 확인 +- session id, exit code, result status, output files를 짧게 출력 +- Claude 실패, result file 누락, `STATUS:` 누락 시 non-zero 반환 + +Claude must write: + +```text +.agent/handoff/IMPLEMENTATION_RESULT.md +``` + +Required result status: + +```text +STATUS: DONE | BLOCKED | NO_CHANGES | FAILED +``` + +## Partial workflows / explicit subcommands + +아래 Mode A-E는 사용자가 명시적으로 요청할 때 사용하는 optional partial workflow입니다. 정상적인 필수 순서가 아니며, full handoff 요청에는 Default workflow를 우선 적용합니다. + +예시 요청: + +- “Run full handoff with Claude Code” +- “Create handoff files only” +- “Run Claude implementation runner” +- “Continue after Claude implementation” +- “Review implementation result” +- “Apply approved fixes” +- “Proceed to final-review” + +## Mode A — Create Claude implementation handoff + +사용 시점: 사용자가 Claude Code용 작업 계획 파일만 만들라고 요청할 때. + +Process: + +1. `AGENTS.md`를 읽습니다. +2. 관련 파일만 inspect합니다. +3. architecture fit을 식별합니다. +4. `.agent/handoff/PLAN.md`를 작성합니다. +5. `.agent/handoff/IMPLEMENTATION_REQUEST.md`를 작성합니다. +6. 구현하지 않습니다. +7. 최종 chat output은 짧게 작성 파일만 알립니다. + +`PLAN.md` 포함 항목: + +- Goal +- Scope +- Relevant canonical docs +- Expected files or modules +- Architecture constraints +- Forbidden patterns +- Verification expectation +- Open questions + +`IMPLEMENTATION_REQUEST.md` 포함 항목: + +- `AGENTS.md` 먼저 읽기 +- `PLAN.md` 읽기 +- minimal diffs로 구현 +- 구현 후 `IMPLEMENTATION_RESULT.md` 작성 +- `IMPLEMENTATION_RESULT.md`에 `STATUS: DONE | BLOCKED | NO_CHANGES | FAILED` 포함 +- 긴 설명 출력 금지 +- `StackState`/`StackActionOf` 사용 금지 +- `TokenStorage` 직접 접근 금지 +- `xcodebuild` command invent 금지 +- git add/commit/push 금지 +- Fastlane/build verification 실행 금지 +- `tuist clean` 실행 금지 + +## Mode B — Run or prepare Claude implementation + +사용 시점: 사용자가 handoff를 기반으로 Claude Code 구현을 실행하거나 manual handoff instruction을 원할 때. + +Process: + +1. `IMPLEMENTATION_REQUEST.md`를 prompt source로 사용합니다. +2. preferred runner는 `Scripts/run-claude-implementation.sh`입니다. +3. runner 실행은 명시 요청과 twix-gate confirmation을 필요로 합니다. +4. runner를 사용할 수 없으면 manual handoff instruction을 제공합니다. +5. unknown Claude Code command를 hardcode하지 않습니다. 이 repo의 runner가 canonical command입니다. + +## Mode C — Review implementation result + +사용 시점: 사용자가 Claude Code 작업이 끝났고 review만 이어서 하라고 말할 때. + +Process: + +1. 있으면 `.agent/handoff/IMPLEMENTATION_RESULT.md`를 읽습니다. +2. `STATUS:` line을 확인합니다. +3. `git diff`를 inspect합니다. +4. 변경 diff에 `review-twix` standard를 적용합니다. +5. `.agent/handoff/REVIEW_REPORT.md`를 작성합니다. +6. 요청이 없으면 긴 review를 chat에 출력하지 않습니다. +7. blocking issue가 있으면 짧은 요약을 보고하고 `fix-review` 실행 여부를 묻습니다. + +## Mode D — Apply approved fixes + +사용 시점: 사용자가 review finding 수정을 승인했을 때. + +Process: + +1. `REVIEW_REPORT.md`를 primary input으로 사용합니다. +2. `fix-review` standard를 적용합니다. +3. 승인된 finding만 수정합니다. +4. `.agent/handoff/FIX_REPORT.md`를 작성합니다. +5. commit하지 않습니다. + +## Mode E — Finalize + +사용 시점: 사용자가 PR finalization으로 진행하라고 요청할 때. + +Process: + +1. `final-review`로 hand off합니다. +2. `final-review`가 verification, commit, PR draft를 처리합니다. +3. `final-review` workflow를 중복하지 않습니다. + +## Token efficiency rules + +- chat output을 짧게 유지합니다. +- file output을 우선합니다. +- 요청 없이 full diff를 출력하지 않습니다. +- end-to-end mode에서는 stage summary와 file path만 chat에 표시합니다. +- 요청 없이 full `PLAN.md`, full `REVIEW_REPORT.md`, full `IMPLEMENTATION_RESULT.md`, full diff를 출력하지 않습니다. +- `AGENTS.md` 또는 docs 전문을 붙여넣지 않습니다. +- 다른 agent에게 긴 summary 출력을 요구하지 않습니다. +- Claude Code는 verbose chat reply 대신 result file을 작성하도록 요청합니다. + +## External agent rules + +- Claude Code만 implementation agent로 사용합니다. +- Preferred execution path는 `Scripts/run-claude-implementation.sh`입니다. +- Manual Claude Code handoff는 fallback입니다. +- 사용자가 달리 말하지 않는 한 Pi가 review/fix/final-review 책임을 유지합니다. +- Claude Code가 같은 session context를 갖고 있다고 가정하지 않습니다. +- file path와 concise instruction만 전달합니다. + +## Safety + +- twix-gate approval gate를 존중합니다. +- 명시 사용자 승인 없이 Claude runner를 실행하지 않습니다. +- commit하지 않습니다. +- push하지 않습니다. +- PR을 열지 않습니다. +- permission을 우회하지 않습니다. +- dangerous auto-approval flag를 사용하지 않습니다. +- Claude를 full-auto/yolo/bypass mode로 실행하지 않습니다. +- plan/handoff/fix approval을 final-review commit approval로 간주하지 않습니다. +- Claude implementation은 verification/commit/PR draft를 수행하지 않습니다. + +## 출력 형식 + +한국어로 보고합니다. + +### End-to-end mode + +```text +진행 단계: +- + +작성/갱신한 handoff 파일: +- + +구현 agent: +- Claude Code + +리뷰 결과: +- + +자동 수정: +- + +다음 단계: +- +``` + +### Mode A + +```text +작성한 handoff 파일: +- + +Claude Code에게 줄 다음 명령: +- + +열린 질문: +- + +토큰 절약 방식: +- +``` + +### Mode C + +```text +리뷰 결과 요약: +- + +REVIEW_REPORT.md 위치: +- + +blocking 이슈: +- + +fix-review 실행 여부 질문: +- +``` + +### Mode D + +```text +수정한 finding: +- + +FIX_REPORT.md 위치: +- + +남은 이슈: +- +``` diff --git a/.pi/skills/plan-twix/SKILL.md b/.pi/skills/plan-twix/SKILL.md new file mode 100644 index 00000000..a46fd9e6 --- /dev/null +++ b/.pi/skills/plan-twix/SKILL.md @@ -0,0 +1,152 @@ +--- +name: plan-twix +description: Use this skill to create concise implementation plans for Twix iOS work, save them to handoff files, and track plan approval before handoff-twix implementation. +--- + +# plan-twix + +## 목적 + +`plan-twix`는 Twix iOS 작업을 위한 concise implementation plan을 작성하고 approval state를 추적합니다. + +이 skill은 독립적으로 사용할 수 있고, `handoff-twix` 전에 사용할 수도 있습니다. + +- `.agent/handoff/PLAN.md`를 작성합니다. +- `.agent/handoff/PLAN_APPROVAL.md`를 생성 또는 갱신합니다. +- 사용자가 plan을 approve / revise / reject할 수 있게 합니다. +- 코드를 구현하지 않습니다. +- `.agent/handoff/PLAN.md`와 `.agent/handoff/PLAN_APPROVAL.md` 외의 project file을 수정하지 않습니다. + +## Planner choice + +Preferred planner: + +- Codex / GPT-5.5 when available + +Fallback planner: + +- Pi internal planning + +Optional fallback: + +- Claude Code plan mode may be mentioned only as an optional fallback. +- Claude Code는 `handoff-twix`의 fixed implementation agent이므로 default planner로 사용하지 않습니다. + +Planning and implementation separation: + +- Codex/GPT-5.5 plans. +- Claude Code implements through `handoff-twix`. +- Pi reviews/fixes/finalizes through review/fix/final workflows. + +## Invocation behavior + +1. `/skill:plan-twix`가 concrete task 없이 호출된 경우: + - task를 추론하지 않습니다. + - 어떤 작업을 계획할지 질문합니다. + +2. concrete task와 함께 호출된 경우: + - `AGENTS.md`를 읽습니다. + - 필요한 경우에만 relevant canonical docs를 읽습니다. + - docs 내용을 plan에 길게 복사하지 않습니다. + - `.agent/handoff/PLAN.md`를 작성합니다. + - `.agent/handoff/PLAN_APPROVAL.md`를 `STATUS: PENDING`으로 생성 또는 갱신합니다. + - 한국어로 짧게 요약합니다. + - 사용자에게 approve / revise / reject 중 선택하라고 요청합니다. + +3. 사용자가 plan을 approve한 경우: + - `.agent/handoff/PLAN_APPROVAL.md`를 `STATUS: APPROVED`로 갱신합니다. + - 필요한 경우 `.agent/handoff/PLAN.md`의 status를 `STATUS: APPROVED`로 갱신합니다. + - 구현을 시작하지 않습니다. + - 이제 `handoff-twix`를 실행할 수 있다고 안내합니다. + +4. 사용자가 revise를 요청한 경우: + - `.agent/handoff/PLAN.md`를 갱신합니다. + - `.agent/handoff/PLAN_APPROVAL.md`는 `STATUS: PENDING`으로 유지합니다. + - 구현을 시작하지 않습니다. + +5. 사용자가 reject한 경우: + - `.agent/handoff/PLAN_APPROVAL.md`를 `STATUS: REJECTED`로 갱신합니다. + - 필요한 경우 `.agent/handoff/PLAN.md`의 status를 `STATUS: REJECTED`로 갱신합니다. + - 진행하지 않습니다. + +## PLAN.md required format + +```text +STATUS: PROPOSED | APPROVED | BLOCKED | REJECTED +# Plan +## Goal +## Scope +## Relevant canonical docs +## Expected files/modules +## Architecture constraints +## Forbidden patterns +## Verification expectation +## Open questions +## Handoff notes +``` + +## PLAN_APPROVAL.md required format + +```text +STATUS: PENDING | APPROVED | REJECTED +# Plan Approval +## Decision +## Approved plan file +## Notes +``` + +## Planning rules + +- Plan approval은 implementation approval이 아닙니다. +- Plan approval은 commit approval이 아닙니다. +- `handoff-twix`는 implementation에 여전히 Claude Code runner를 사용해야 합니다. +- `final-review`는 verification, commit, PR draft를 계속 담당해야 합니다. +- plan이 broad refactor, public API change, new architecture exception, owner decision을 암시하면: + - `STATUS: BLOCKED`로 표시하거나 + - `Open questions`에 명시합니다. +- `xcodebuild` command를 invent하지 않습니다. +- Verification expectation은 Pi/final-review의 Fastlane 기준을 사용합니다. + - `bundle exec fastlane ios ci_pr` + - fallback: `fastlane ios ci_pr` +- direct `TokenStorage`, Keychain, UserDefaults 접근을 제안하지 않습니다. +- `StackState`, `StackActionOf`를 제안하지 않습니다. +- feature client를 기본적으로 protocol-based로 제안하지 않습니다. +- Interface public type을 강제로 `Source.swift`에 몰아넣는 계획을 제안하지 않습니다. +- 프로젝트 문서의 긴 내용을 plan에 복사하지 않고 path로 참조합니다. +- 최소 diff, module boundary, dependency direction, public API 최소화를 우선합니다. + +## Relationship to handoff-twix + +- `handoff-twix`는 `.agent/handoff/PLAN_APPROVAL.md`가 `STATUS: APPROVED`일 때만 `.agent/handoff/PLAN.md`를 reuse해야 합니다. +- `PLAN.md`가 있어도 approval이 pending/rejected이면 `handoff-twix`는 중단하고 plan approval 또는 revision을 요청해야 합니다. +- 이 skill은 명시 요청이 없는 한 `.agent/handoff/IMPLEMENTATION_REQUEST.md`를 만들지 않습니다. +- 이 skill은 implementation agent를 실행하지 않습니다. + +## Output format + +한국어로 보고합니다. + +```text +작성한 파일: +- + +planner: +- + +plan status: +- + +핵심 계획 요약: +- + +열린 질문: +- + +승인 선택지: +- approve +- revise +- reject + +다음 단계: +- +``` diff --git a/.pi/skills/review-twix/SKILL.md b/.pi/skills/review-twix/SKILL.md new file mode 100644 index 00000000..4608b557 --- /dev/null +++ b/.pi/skills/review-twix/SKILL.md @@ -0,0 +1,110 @@ +--- +name: review-twix +description: Use this skill for Twix iOS code, diff, PR, and architecture compliance review. Default behavior is report-only unless edits are explicitly requested. +--- + +# review-twix + +## 목적 + +Twix iOS 코드, diff, PR, 문서 변경을 아키텍처/TCA/MFA 관점에서 리뷰합니다. + +이 skill은 기존 임시 MODE 2 코드/아키텍처 리뷰 흐름을 대체합니다. 기본 동작은 **보고 전용**이며, 명시 요청 없이는 파일을 수정하지 않습니다. + +## 사용 시점 + +- PR 리뷰 +- 구현 전 아키텍처 적합성 검토 +- 구현 후 리스크 점검 +- 코드가 canonical docs를 따르는지 확인 +- docs-refactor 또는 향후 구현 작업에 넘길 구조화된 발견 사항 생성 + +## 기준 문서 + +우선순위: + +1. `AGENTS.md` +2. `docs/Architecture/Overview.md` +3. `docs/Reference/ProjectRules.md` +4. `docs/Reference/Checklists.md` +5. `docs/Reference/FileOrganization.md` +6. `docs/Reference/NamingConventions.md` +7. `docs/Guides/NavigationStack.md` +8. `docs/Guides/NetworkGuide.md` +9. `docs/QuickStart.md`는 튜토리얼로만 취급 + +`CLAUDE.md`는 Claude Code 진입점이며 아키텍처 source of truth가 아닙니다. + +## 리뷰 항목 + +- Clean Architecture boundary +- MFA Interface/Sources split +- dependency direction +- 올바른 module / feature / layer 배치 +- One Type Per File 기본 원칙 +- TCA State / Action / Reducer ownership +- side effects through dependencies and Effects +- minimal public API +- duplicate clients / factories / routes / models +- struct-based TCA Clients by default +- protocol overgeneration 금지 +- `[Route]` 배열 NavigationStack 패턴 +- `StackState` / `StackActionOf` recommended usage 금지 +- TokenManager / TokenStorage rule +- direct Keychain / UserDefaults / token persistence access 금지 +- duplicated Authorization/header/refresh logic 금지 +- testing/build verification limits + +## 검증 정책 + +- Setup/generation: `tuist install`, `tuist generate`, 필요 시 `tuist clean` +- CI/PR verification: `bundle exec fastlane ios ci_pr` +- Bundler 사용 불가 시: `fastlane ios ci_pr` +- direct `xcodebuild`는 scheme / destination / configuration이 명시적으로 문서화되었거나 제공된 경우에만 사용합니다. +- 검증을 실행할 수 없으면 검증 한계를 보고합니다. + +## 금지 사항 + +- 명시 요청 없이 파일 수정 금지 +- 자동 refactor 금지 +- 아키텍처 규칙 invent 금지 +- implementation skill 생성 금지 +- TypeScript extension 생성 금지 +- `CLAUDE.md`를 아키텍처 source of truth로 취급 금지 + +## 출력 형식 + +한국어로 보고합니다. + +```text +리뷰 범위: +- + +적용한 규칙: +- + +발견한 문제: +- ID: + 심각도: + 파일: + 위치: + 규칙/문서: + 문제: + 영향도: + 권장 수정: + +바로 수정 가능한 항목: +- + +확인 필요한 항목: +- + +검증 결과 / 검증 한계: +- + +docs-refactor에 전달할 문서 이슈: +- + +향후 구현 작업에 전달할 수정 후보: +- +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c7f208fb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,329 @@ +# AGENTS.md + +Cross-agent baseline instructions for this repository. + +This file summarizes the stable project rules that apply to any coding agent. Detailed technical guidance remains in `docs/*.md`; Claude Code-specific guidance remains in `CLAUDE.md`; reusable Pi skills live under `.pi/skills/`. + +--- + +## Project baseline + +- Platform: iOS only +- Minimum target: iOS 17 +- UI: SwiftUI +- State management: TCA 1.23 +- Architecture: Clean Architecture + Micro Feature Architecture (MFA) +- Project docs are authoritative. Do not invent architecture rules. + +Core principles: + +- Keep Interface and Implementation separated. +- Use TCA Dependency Container for dependency injection. +- Use ViewFactory/factory-based external view composition where required. +- Do not access token storage directly; token access must be mediated through TokenManager. +- Prefer minimal, deterministic, reproducible changes. +- Ask for missing files or unclear project information instead of assuming. + +--- + +## Documentation roles + +### `AGENTS.md` + +Cross-agent operational baseline: + +- project stack +- safe editing policy +- architecture guardrails +- documentation lookup order +- known unresolved items + +### `CLAUDE.md` + +Claude Code-specific guide: + +- Claude Code quick reference +- Claude-specific usage tips +- links into detailed documentation + +Do not treat Claude-specific workflow tips as universal agent rules unless also stated here or in technical docs. + +### Reusable agent skills + +Canonical skill instructions live under `.pi/skills/`. + +Cross-agent skills: + +- `docs-refactor`: documentation refactoring and architecture rule cleanup +- `review-twix`: Twix iOS architecture/code review +- `fix-review`: apply explicitly approved review findings with minimal diffs +- `final-review`: pre-PR review, verification, commit preparation, and PR draft + +Agent-specific access: + +- Pi uses `.pi/skills/{name}/SKILL.md` directly. +- Claude Code uses `.claude/skills/{name}/SKILL.md` thin links that point back to the canonical `.pi/skills/` files. +- Codex CLI uses `.codex/skills/{name}/SKILL.md` thin links that point back to the canonical `.pi/skills/` files. + +Pi-only skill: + +- `handoff-twix` remains Pi-only because it orchestrates Pi-specific handoff, runner, review/fix/final-review flow. + +### `docs/*.md` + +Project technical documentation: + +- architecture details +- feature implementation rules +- TCA patterns +- network guide +- navigation guide +- naming conventions +- file organization rules +- checklists and examples + +Use these docs for implementation details rather than expanding `AGENTS.md` with long tutorials. + +--- + +## Recommended documentation lookup order + +Before architectural or feature work, read the relevant docs in this order: + +1. `AGENTS.md` +2. `CLAUDE.md` if the workflow is Claude-specific +3. `docs/Reference/ProjectRules.md` when team/project rules are requested +4. `docs/Architecture/Overview.md` +5. Task-specific docs: + - Canonical implementation checklist: `docs/Reference/Checklists.md` + - Network/client patterns: `docs/Guides/NetworkGuide.md` + - Navigation: `docs/Guides/NavigationStack.md` + - File structure: `docs/Reference/FileOrganization.md` + - Naming/style: `docs/Reference/NamingConventions.md` + - Project/team rules: `docs/Reference/ProjectRules.md` + - TCA onboarding/tutorial only: `docs/QuickStart.md` + +If a referenced doc is missing, ask before assuming its contents. + +--- + +## Repository intelligence + +When CodeGraph is available, use it for broad repository exploration, symbol +relationship discovery, and impact analysis before falling back to wide +`rg`/file-reading sweeps. + +Use this workflow: + +- For architecture or unfamiliar-area questions, start with CodeGraph + `context`, `query`, `impact`, `callers`, or `callees`. +- For exact literal searches, narrow symbol lookups, and final evidence, use + `rg` and direct file reads. +- If CodeGraph reports stale or pending files, verify those files directly + before relying on the indexed result. +- Do not commit `.codegraph/`; it is a generated local SQLite index. Regenerate + it with `codegraph init -i` when missing, and use `codegraph sync` only when + working outside an active MCP watcher or after a branch switch / batch edit. + +--- + +## Architecture guardrails + +### Feature structure + +General Feature modules should follow Interface/Sources separation: + +```text +Projects/Feature/{Feature}/ +├── Interface/Sources/ +└── Sources/ +``` + +Interface layer is the public boundary. Consumers should generally depend on Interface modules, not implementation Sources. Interface typically contains public API/contracts: + +- public Reducer type +- public State and Action +- Client definitions if needed +- ViewFactory/factory definitions if needed +- `DependencyValues` extensions +- `TestDependencyKey` conformance where appropriate + +Sources layer hides implementation details. Sources typically contains implementation: + +- default Reducer initializer +- reducer logic +- internal SwiftUI View +- live dependency implementations +- linker/static-library support if required by the project pattern + +### Exception features + +The following are documented as exception features: + +- Auth +- Onboarding +- MainTab + +They may be directly composed by App or coordinator/root features and are not always forced through the same Interface/ViewFactory constraints as ordinary feature modules. + +### TCA rules + +- Use `@Reducer` for reducers. +- Use `@ObservableState` for state. +- State should conform to `Equatable`. +- Keep TCA nested `State` and `Action` with their Reducer. +- Model actions as events, not commands. +- Use `@Dependency` for dependencies. +- Wrap asynchronous side effects in TCA Effects such as `.run`. +- Reducers should not directly perform side effects outside Effects/dependencies. + +### Action naming + +Follow documented naming conventions: + +- User actions: `Tapped`, `Changed`, `Selected` +- System responses: `Response` +- Lifecycle: `onAppear`, `onDisappear`, etc. +- Delegate events: `delegate(Delegate)` when parent communication is needed + +### Navigation + +Use the project-wide `[Route]` array NavigationStack pattern documented in `docs/Guides/NavigationStack.md`. + +Do not introduce TCA's official `StackState` + `@Reducer enum Path` pattern unless the architecture docs are explicitly changed first. The project currently documents that this pattern does not fit the Interface/Implementation split. + +### Network clients + +For feature dependencies, prefer struct-based TCA Clients documented in `docs/Guides/NetworkGuide.md`. + +Protocol-based clients are allowed only when an existing Core protocol, platform abstraction, legacy integration, or explicit documentation requires them. + +### Token access + +Token access must be mediated by the current `TokenManager` pattern. + +Current codebase evidence: + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` (`DomainAuthInterface`, `public actor TokenManager`, `DependencyValues.tokenManager`) +- Token storage interface: `Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift` (`CoreStorageInterface`, `TokenStorageProtocol`, `TokenStorageClient`, `DependencyValues.tokenStorage`) +- Keychain implementation: `Projects/Core/Storage/Sources/KeychainTokenStorage.swift` (`CoreStorage`) +- Current auth header pattern: `Projects/Domain/Auth/Sources/AuthInterceptor.swift` uses `TokenManager` +- Current App/root wiring: `Projects/App/Sources/View/TwixApp.swift` configures the live token storage dependency + +Do not read/write token persistence directly from Features, Reducers, Views, ordinary Clients, or request-building code. Do not use `@Dependency(\.tokenStorage)`, `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, or UserDefaults directly for token access outside the allowed exceptions. + +Allowed direct TokenStorage usage is limited to TokenManager internals, Core Storage interface/implementation, App/root dependency wiring, tests/mocks, and approved auth infrastructure that depends on `TokenManager` rather than `TokenStorage` directly. Do not introduce another token/header path without owner approval. + +--- + +## Implementation quality gate + +Before non-trivial implementation, verify that the planned structure is not only compliant with documented team architecture, but also clean, maintainable, and appropriate for the change. + +Check architecture fit before writing code: + +- Clean Architecture boundaries are preserved. +- Interface/Implementation split is respected where applicable. +- Dependency direction remains correct; higher-level modules must not depend on lower-level implementation details in the wrong direction. +- New code is placed in the correct module, feature, layer, and target. +- TCA `State`, `Action`, and `Reducer` ownership is clear and belongs to the feature that owns the behavior. +- Side effects go through dependencies and TCA Effects, not directly through views or reducers. +- Public API is minimal; expose only what other modules actually need. +- Coupling is not increased unnecessarily between features, domains, clients, factories, routes, or models. +- Existing clients, factories, routes, models, and dependency keys are reused when appropriate; do not create duplicates for the same responsibility. +- The change does not create avoidable future refactoring cost, such as temporary abstractions becoming public contracts or feature-specific logic leaking into shared layers. + +Stop and ask before introducing: + +- new architectural patterns +- new module-boundary exceptions +- new global clients/factories/routes/models +- new shared abstractions that are not clearly required by current use cases +- changes that contradict existing docs or nearby code patterns + +After implementation, summarize: + +- the architecture decisions made +- why the chosen module/layer placement is appropriate +- any dependency-direction or public-API risks +- any follow-up cleanup or verification that remains + +--- + +## File organization guardrails + +Default rule: + +- Prefer One Type Per File for major types. + +Documented exceptions: + +- Keep TCA `State` and `Action` nested inside the Reducer. +- Keep private helper types with their owner. +- Keep very small helper types with their owner when splitting would reduce cohesion. +- Preserve access levels and module boundaries when moving code. + +Interface-specific rule: + +- Interface modules are public boundaries, but still prefer One Type Per File for new or significantly modified Interface modules. +- Existing `Interface/Sources/Source.swift` files may remain as legacy/compatibility patterns. +- Follow nearby existing code patterns unless they weaken the public boundary or make the public API unclear. + +--- + +## Build and verification + +Tuist is canonical for setup/generation: + +```bash +tuist install +tuist generate +tuist clean +``` + +Use `tuist clean` only when regeneration cleanup is needed. `tuist build` is not the standard command. + +CI/PR-level verification uses Fastlane: + +```bash +bundle exec fastlane ios ci_pr +``` + +Use `fastlane ios ci_pr` only when Bundler is not available. If a repo-local command documents a shorter `fastlane ci_pr` convention, follow that documented convention; otherwise prefer the Bundler command above. + +Do not invent direct `xcodebuild` scheme, destination, or configuration values. Direct `xcodebuild` may be used only when scheme, destination, and configuration are explicitly documented or provided for a direct-xcodebuild-specific task. If verification cannot be run, report the verification limit. + +### Testing + +Tests are not currently established as a normal requirement. + +- Do not create tests unless explicitly requested. +- Do not claim tests were run if no test target or command exists. +- For risky logic changes, propose a test plan. +- Report verification limits caused by missing tests or missing commands. + +--- + +## Editing policy + +- Do not edit files until the requested scope is clear. +- Prefer minimal diffs over rewrites. +- Do not rewrite unrelated sections. +- Verify feasibility before modifying architecture documents. +- Keep public interfaces stable unless the user explicitly requests breaking changes. +- Do not delete legacy documents unless explicitly approved. +- Do not create Pi skills or Pi extensions unless explicitly requested. + +--- + +## Known documentation issues / unresolved items + +These issues are known from the current docs. Do not silently fix them unless asked. + +Intentional docs cleanup decisions: `Rules.md` was migrated into `docs/Reference/ProjectRules.md` and deleted; `Prompt.md` was replaced by Pi skills/workflows and deleted; `docs/Checklists.md` was replaced by `docs/Reference/Checklists.md` and deleted. This was owner-approved for that cleanup only; future legacy document deletions still require explicit approval. + +- Direct `xcodebuild` values are intentionally not documented as the normal verification path; ask if a direct-xcodebuild-specific task requires them. +- SwiftLint follows the Tuist-configured script documented in `docs/Reference/ProjectRules.md`. +- Some existing documentation references may still point to docs that are not currently present. + +When these affect a task, ask for confirmation before proceeding. diff --git a/Claude.md b/Claude.md index 3a4dd7c5..b8f363b4 100644 --- a/Claude.md +++ b/Claude.md @@ -1,142 +1,39 @@ # Claude Code 가이드 -> 이 파일은 Claude Code CLI에서 프로젝트 맥락을 빠르게 파악하기 위한 가이드입니다. +> Claude Code CLI에서 이 저장소를 작업할 때 사용하는 얇은 진입점입니다. -## 📌 빠른 참조 +## 먼저 읽기 -- [팀 규칙](./Rules.md) - 반드시 지켜야 할 팀 합의사항 +- 공통 에이전트 기준: @AGENTS.md +- `@AGENTS.md` import/reference가 동작하지 않는 환경에서는 [AGENTS.md](./AGENTS.md)를 먼저 읽으세요. ---- - -## 🎯 프로젝트 요약 - -- **아키텍처**: SwiftUI + TCA + Micro Features Architecture -- **빌드 시스템**: Tuist -- **핵심 원칙**: Interface/Implementation 분리, Dependency Injection, ViewFactory 패턴, TokenManager 단일 중재(직접 TokenStorage 접근 금지) +`AGENTS.md`가 Pi, Codex CLI, Claude Code에 공통으로 적용되는 기준입니다. 이 파일은 Claude Code 전용 메모만 유지합니다. --- -## 📚 문서 구조 - -모든 문서는 **docs/** 폴더에 계층적으로 구성되어 있습니다. - -### 처음 배울 때 -1. [빠른 시작](./docs/QuickStart.md) - TCA 기본 개념 (10분) -2. [아키텍처 개요](./docs/Architecture/Overview.md) - 전체 구조 - -### Feature 개발할 때 -1. [팀 규칙](./Rules.md) - DocC, Reducer, ViewFactory 규칙 -2. [네트워크 통신](./docs/Guides/NetworkGuide.md) - API 호출 -3. [NavigationStack](./docs/Guides/NavigationStack.md) - 화면 전환 - -### 코드 작성 시 참고 -1. [네이밍 규칙](./docs/Reference/NamingConventions.md) - Action, File 네이밍 -2. [체크리스트](./docs/Reference/Checklists.md) - Feature 구현 체크리스트 -3. [파일 구조화 규칙](./docs/Reference/FileOrganization.md) - 파일 분리 및 구조화 - ---- - -## 🏗️ 현재 구현된 Feature - -### Auth Feature -- **위치**: `Projects/Feature/Auth/` -- **역할**: Apple 로그인 -- **플로우**: 로그인 성공 → `.delegate(.loginSucceeded)` → MainTab 전환 +## Claude Code 작업 메모 -### MainTab Feature -- **위치**: `Projects/Feature/Sources/` -- **역할**: 메인 탭 화면 (홈/통계/커플/마이페이지) +- 작업을 시작하기 전에 `AGENTS.md`의 문서 조회 순서와 편집 정책을 따르세요. +- 팀 규칙이 필요한 작업은 [docs/Reference/ProjectRules.md](./docs/Reference/ProjectRules.md)를 함께 확인하세요. +- 상세 구현은 작업 종류에 맞는 `docs/*.md`를 확인하세요. +- 재사용 skill은 `.claude/skills/{name}/SKILL.md`에서 진입하되, 실제 원본 지침은 해당 파일이 가리키는 `.pi/skills/{name}/SKILL.md`를 읽으세요. +- `handoff-twix`는 Pi 전용 orchestration skill이므로 Claude Code skill로 사용하지 않습니다. +- 누락되었거나 링크가 깨진 문서는 추정하지 말고 사용자에게 확인하세요. --- -## 🔧 자주 사용하는 명령어 - -### Tuist -```bash -tuist generate # 프로젝트 생성 -tuist install # 의존성 설치 -tuist clean # 캐시 정리 -``` - -### Git -```bash -# 커밋 규칙 -feat: 새로운 기능 추가 -fix: 버그 수정 -refactor: 코드 리팩토링 -chore: 빌드 설정, 패키지 등 -docs: 문서 수정 -``` - ---- - -## 📖 상세 문서 찾기 - -모든 상세 문서는 [README.md](./README.md#-문서-구조)에서 찾을 수 있습니다. - -### 아키텍처 -- [아키텍처 개요](./docs/Architecture/Overview.md) -- [Interface/Implementation 분리](./docs/Architecture/InterfaceImplementation.md) -- [Reducer 패턴](./docs/Architecture/ReducerPattern.md) -- [Dependency Injection](./docs/Architecture/DependencyInjection.md) -- [ViewFactory 패턴](./docs/Architecture/ViewFactory.md) - -### 가이드 -- [빠른 시작](./docs/QuickStart.md) -- [네트워크 통신](./docs/Guides/NetworkGuide.md) -- [NavigationStack](./docs/Guides/NavigationStack.md) -- [복잡한 State 관리](./docs/Guides/StateManagement.md) -- [테스트 작성](./docs/Guides/Testing.md) +## Claude Code 사용 예시 -### 레퍼런스 -- [네이밍 규칙](./docs/Reference/NamingConventions.md) -- [체크리스트](./docs/Reference/Checklists.md) -- [파일 구조화 규칙](./docs/Reference/FileOrganization.md) - -### 예제 -- [Auth Feature](./docs/Examples/Auth.md) -- [MainTab Feature](./docs/Examples/MainTab.md) - ---- - -## 💡 Claude Code 사용 팁 - -### 작업 시작 전 -``` -"README.md 읽고 [작업 내용] 해줘" +```text +"AGENTS.md와 docs/Reference/ProjectRules.md를 읽고 [작업 내용] 해줘" "docs/Guides/NetworkGuide.md 참고해서 API Client 만들어줘" +"이 Reducer가 AGENTS.md와 ProjectRules.md 규칙을 잘 따르는지 확인해줘" ``` -### 규칙 확인 -``` -"Rules.md 기반으로 Feature 만들어줘" -``` - -### 코드 리뷰 -``` -"Auth Feature 코드 리뷰해줘" -"이 Reducer가 Rules.md 규칙을 잘 따르는지 확인해줘" -``` - ---- - -## 🗂️ 구버전 문서 - -- `Claude_OLD.md` - 계층화 이전의 모놀리식 문서 (백업용) - --- -**문서 버전**: 2.0 (계층적 구조) -**마지막 업데이트**: 2026-01-12 -**작성자**: Claude Code Assistant - ---- - -## 📝 참고사항 - -이 문서는 Claude Code가 프로젝트 맥락을 빠르게 파악하기 위한 **요약본**입니다. +## 참고 -**상세 내용은 각 문서를 참고하세요:** -- 전체 개요: [README.md](./README.md) -- 팀 규칙: [Rules.md](./Rules.md) -- 가이드: [docs/](./docs/) +- 이 파일은 Claude Code 전용 진입점입니다. +- 프로젝트의 공통 기준은 `AGENTS.md`에 있습니다. +- 상세 기술 문서는 `docs/*.md`에 있습니다. diff --git a/Gemfile b/Gemfile index 7a118b49..b2a45d3e 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,4 @@ source "https://rubygems.org" gem "fastlane" +gem "multi_json" diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 52daecaa..7a8bfc65 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -31,7 +31,7 @@ private let commonInfoPlist: [String: Plist.Value] = Project.Environment.InfoPli "DEEPLINK_HOST": "$(DEEPLINK_HOST)", "API_BASE_URL": "$(API_BASE_URL)", "NSCameraUsageDescription": "UseCamera", - "CFBundleShortVersionString": "1.1.2" + "CFBundleShortVersionString": "1.1.3" ], uniquingKeysWith: { current, _ in current }) private let commonDependencies: [TargetDependency] = [ @@ -43,7 +43,6 @@ private let commonDependencies: [TargetDependency] = [ .external(dependency: .KakaoSDKAuth), .external(dependency: .KakaoSDKCommon), .external(dependency: .GoogleSignIn), - .external(dependency: .FirebaseCore), .external(dependency: .FirebaseMessaging), .external(dependency: .FirebaseRemoteConfig), .core(implements: .crashlytics) diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index c0c2f1ad..00000000 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/114.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/114.png index 897e02f0..1325af85 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/114.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/120.png index 66a25168..2b3c3194 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/120.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/180.png index 14e43af5..3727cc03 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/180.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/29.png index 160af721..e08c7548 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/29.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/40.png index 2ee3568d..583ffd02 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/40.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/57.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/57.png index a89d4139..b01ddff6 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/57.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/58.png index e6924d3b..ed99c3c7 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/58.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/60.png index 496bb92a..1d4678a3 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/60.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/80.png index 0d895397..3f7c6202 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/80.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/87.png index f0d6e1e6..d032683e 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/87.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 00000000..224b4568 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 73d3b7f6..ba250a75 100644 --- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1,80 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"}]} \ No newline at end of file +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "AppIcon.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/App/Sources/Reducer/AppCoordinator.swift b/Projects/App/Sources/Reducer/AppCoordinator.swift index 971b655b..50a7359c 100644 --- a/Projects/App/Sources/Reducer/AppCoordinator.swift +++ b/Projects/App/Sources/Reducer/AppCoordinator.swift @@ -171,6 +171,10 @@ struct AppCoordinator { pushClient: pushClient, notificationClient: notificationClient ), + requestNotificationAuthorizationIfNeededEffect( + pushClient: pushClient, + notificationClient: notificationClient + ), subscribeTokenRefreshEffect( pushClient: pushClient, notificationClient: notificationClient @@ -184,7 +188,7 @@ struct AppCoordinator { // pending 딥링크가 있으면 처리 if let pendingDeepLink = state.pendingNotificationDeepLink { state.pendingNotificationDeepLink = nil - effects.append(.send(.route(.mainTab(.notificationDeepLinkReceived(pendingDeepLink))))) + effects.append(.send(.route(.mainTab(.view(.notificationDeepLinkReceived(pendingDeepLink)))))) } return .merge(effects) @@ -231,7 +235,7 @@ struct AppCoordinator { } state.pendingNotificationDeepLink = nil - return .send(.route(.mainTab(.notificationDeepLinkReceived(deepLink)))) + return .send(.route(.mainTab(.view(.notificationDeepLinkReceived(deepLink))))) case let .route(.auth(.delegate(.loginSucceeded(authResult)))): crashlytics.setUserIdentifier("\(authResult.userId)") @@ -280,7 +284,7 @@ struct AppCoordinator { if let pendingDeepLink = state.pendingNotificationDeepLink { state.pendingNotificationDeepLink = nil - effects.append(.send(.route(.mainTab(.notificationDeepLinkReceived(pendingDeepLink))))) + effects.append(.send(.route(.mainTab(.view(.notificationDeepLinkReceived(pendingDeepLink)))))) } return .merge(effects) @@ -348,7 +352,7 @@ private func registerFCMTokenEffect( // 1. 현재 권한 상태 확인 let settings = await UNUserNotificationCenter.current().notificationSettings() - // 이미 권한이 허용된 경우에만 등록 (notDetermined/denied는 온보딩 완료 시점에서 처리) + // 이미 권한이 허용된 경우에만 등록 (notDetermined는 별도 fallback/온보딩에서 처리) guard settings.authorizationStatus == .authorized else { return } // 3. APNS 등록 및 FCM 토큰 획득 @@ -364,6 +368,29 @@ private func registerFCMTokenEffect( } } +private func requestNotificationAuthorizationIfNeededEffect( + pushClient: PushClient, + notificationClient: NotificationClient +) -> Effect { + .run { send in + let settings = await UNUserNotificationCenter.current().notificationSettings() + guard settings.authorizationStatus == .notDetermined else { return } + + let granted = (try? await pushClient.requestAuthorization()) ?? false + guard granted else { return } + + await pushClient.registerForRemoteNotifications() + + guard let token = try? await pushClient.getFCMToken(), + let deviceId = await UIDevice.current.identifierForVendor?.uuidString else { + return + } + + try? await notificationClient.registerFCMToken(token, deviceId) + await send(.registerFCMTokenCompleted) + } +} + private func subscribeTokenRefreshEffect( pushClient: PushClient, notificationClient: NotificationClient diff --git a/Projects/Domain/Auth/Project.swift b/Projects/Domain/Auth/Project.swift index 3cf298c5..33136d0e 100644 --- a/Projects/Domain/Auth/Project.swift +++ b/Projects/Domain/Auth/Project.swift @@ -23,8 +23,7 @@ let project = Project.makeModule( .external(dependency: .KakaoSDKCommon), .external(dependency: .KakaoSDKAuth), .external(dependency: .KakaoSDKUser), - .external(dependency: .GoogleSignIn), - .external(dependency: .GoogleSignInSwift) + .external(dependency: .GoogleSignIn) ] ) ), diff --git a/Projects/Domain/Goal/Interface/Sources/DTO/PokeRequestDTO.swift b/Projects/Domain/Goal/Interface/Sources/DTO/PokeRequestDTO.swift new file mode 100644 index 00000000..a594ad8a --- /dev/null +++ b/Projects/Domain/Goal/Interface/Sources/DTO/PokeRequestDTO.swift @@ -0,0 +1,17 @@ +// +// PokeRequestDTO.swift +// DomainGoalInterface +// +// Created by 정지훈 on 5/28/26. +// + +import Foundation + +/// 목표를 찌르기 위한 요청 DTO입니다. +public struct PokeRequestDTO: Encodable { + public let date: String + + public init(date: String) { + self.date = date + } +} diff --git a/Projects/Domain/Goal/Interface/Sources/Endpoint/PokeEndpoint.swift b/Projects/Domain/Goal/Interface/Sources/Endpoint/PokeEndpoint.swift index bedd5df5..593fd0b9 100644 --- a/Projects/Domain/Goal/Interface/Sources/Endpoint/PokeEndpoint.swift +++ b/Projects/Domain/Goal/Interface/Sources/Endpoint/PokeEndpoint.swift @@ -11,13 +11,13 @@ import Foundation /// 찌르기 관련 API 엔드포인트입니다. public enum PokeEndpoint: Endpoint { /// 파트너에게 찌르기 - case poke(goalId: Int64) + case poke(goalId: Int64, request: PokeRequestDTO) } extension PokeEndpoint { public var path: String { switch self { - case let .poke(goalId): + case let .poke(goalId, _): return "/api/v1/pokes/goals/\(goalId)" } } @@ -38,7 +38,10 @@ extension PokeEndpoint { } public var body: (any Encodable)? { - nil + switch self { + case let .poke(_, request): + return request + } } public var requiresAuth: Bool { true } diff --git a/Projects/Domain/Goal/Interface/Sources/GoalClient.swift b/Projects/Domain/Goal/Interface/Sources/GoalClient.swift index b06621cd..8d18e1d3 100644 --- a/Projects/Domain/Goal/Interface/Sources/GoalClient.swift +++ b/Projects/Domain/Goal/Interface/Sources/GoalClient.swift @@ -26,7 +26,7 @@ public struct GoalClient { public var updateGoal: (Int64, GoalUpdateRequestDTO) async throws -> Goal public var deleteGoal: (Int64) async throws -> Void public var completeGoal: (Int64) async throws -> GoalCompleteResponseDTO - public var pokePartner: (Int64) async throws -> Void + public var pokePartner: (Int64, PokeRequestDTO) async throws -> Void /// 목표 조회 클로저를 주입하여 GoalClient를 생성합니다. /// @@ -47,7 +47,7 @@ public struct GoalClient { updateGoal: @escaping (Int64, GoalUpdateRequestDTO) async throws -> Goal, deleteGoal: @escaping (Int64) async throws -> Void, completeGoal: @escaping (Int64) async throws -> GoalCompleteResponseDTO, - pokePartner: @escaping (Int64) async throws -> Void + pokePartner: @escaping (Int64, PokeRequestDTO) async throws -> Void ) { self.fetchGoals = fetchGoals self.createGoal = createGoal @@ -135,7 +135,7 @@ extension GoalClient: TestDependencyKey { completedAt: "" ) }, - pokePartner: { _ in + pokePartner: { _, _ in assertionFailure("GoalClient.pokePartner가 구현되지 않았습니다. withDependencies로 mock을 주입하세요.") } ) @@ -283,7 +283,7 @@ extension GoalClient: TestDependencyKey { completedAt: "2026-02-22T00:00:00Z" ) }, - pokePartner: { _ in + pokePartner: { _, _ in return } ) diff --git a/Projects/Domain/Goal/Sources/GoalClient+Live.swift b/Projects/Domain/Goal/Sources/GoalClient+Live.swift index c5e64075..f0767ff3 100644 --- a/Projects/Domain/Goal/Sources/GoalClient+Live.swift +++ b/Projects/Domain/Goal/Sources/GoalClient+Live.swift @@ -99,10 +99,13 @@ extension GoalClient: @retroactive DependencyKey { throw error } }, - pokePartner: { goalId in + pokePartner: { goalId, requestDTO in do { let _: EmptyResponse = try await networkClient.request( - endpoint: PokeEndpoint.poke(goalId: goalId) + endpoint: PokeEndpoint.poke( + goalId: goalId, + request: requestDTO + ) ) } catch { throw error diff --git a/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift b/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift index c81d584f..0eed0fab 100644 --- a/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift +++ b/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift @@ -10,4 +10,9 @@ import Foundation public struct PhotoLogUpdateReactionResponseDTO: Decodable { public let photologId: Int64 public let reaction: String + + public init(photologId: Int64, reaction: String) { + self.photologId = photologId + self.reaction = reaction + } } diff --git a/Projects/Feature/Auth/Example/Sources/AuthApp.swift b/Projects/Feature/Auth/Example/Sources/AuthApp.swift index 23f39000..db92cc6e 100644 --- a/Projects/Feature/Auth/Example/Sources/AuthApp.swift +++ b/Projects/Feature/Auth/Example/Sources/AuthApp.swift @@ -7,10 +7,15 @@ import ComposableArchitecture import FeatureAuth +import SharedPerfTestingSupport import SwiftUI @main struct AuthApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { AuthView( @@ -19,6 +24,8 @@ struct AuthApp: App { reducer: { AuthReducer() } ) ) + .perfRoot("auth") + .perfReadyMarker("auth") } } } diff --git a/Projects/Feature/Auth/ExampleUITests/Sources/AuthExampleSmokeTests.swift b/Projects/Feature/Auth/ExampleUITests/Sources/AuthExampleSmokeTests.swift new file mode 100644 index 00000000..ebc0c9d6 --- /dev/null +++ b/Projects/Feature/Auth/ExampleUITests/Sources/AuthExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class AuthExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("auth") + } +} diff --git a/Projects/Feature/Auth/Project.swift b/Projects/Feature/Auth/Project.swift index a216e45f..d3bf1613 100644 --- a/Projects/Feature/Auth/Project.swift +++ b/Projects/Feature/Auth/Project.swift @@ -39,6 +39,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .auth) ] ) diff --git a/Projects/Feature/Auth/Sources/Reducer/AuthReducer.swift b/Projects/Feature/Auth/Sources/Reducer/AuthReducer.swift index 6c8a2c71..d1a27692 100644 --- a/Projects/Feature/Auth/Sources/Reducer/AuthReducer.swift +++ b/Projects/Feature/Auth/Sources/Reducer/AuthReducer.swift @@ -27,7 +27,7 @@ import Foundation @Reducer public struct AuthReducer { @Dependency(\.analyticsClient) var analyticsClient - + @ObservableState public struct State: Equatable { public var isLoading = false @@ -38,18 +38,29 @@ public struct AuthReducer { } public enum Action { - case onAppear - case appleLoginButtonTapped - case kakaoLoginButtonTapped - case googleLoginButtonTapped - case loginResponse(Result) - case dismissError - case delegate(Delegate) + // MARK: - View + public enum View { + case onAppear + case appleLoginButtonTapped + case kakaoLoginButtonTapped + case googleLoginButtonTapped + case dismissError + } + + // MARK: - Response + public enum Response { + case loginResponse(Result) + } + // MARK: - Delegate @CasePathable public enum Delegate { case loginSucceeded(AuthResult) } + + case view(View) + case response(Response) + case delegate(Delegate) } public init() {} @@ -57,32 +68,60 @@ public struct AuthReducer { public var body: some ReducerOf { Reduce { state, action in switch action { - case .onAppear: - analyticsClient.logEvent(AuthAnalyticsEvent.loginViewed) + case .view(let viewAction): + return reduceView(state: &state, action: viewAction) + + case .response(let responseAction): + return reduceResponse(state: &state, action: responseAction) + + case .delegate: return .none - - case .appleLoginButtonTapped: - return Self.handleLogin(provider: .apple, state: &state) + } + } + } +} + +// MARK: - View + +private extension AuthReducer { + func reduceView( + state: inout State, + action: Action.View + ) -> Effect { + switch action { + case .onAppear: + analyticsClient.logEvent(AuthAnalyticsEvent.loginViewed) + return .none - case .kakaoLoginButtonTapped: - return Self.handleLogin(provider: .kakao, state: &state) + case .appleLoginButtonTapped: + return Self.handleLogin(provider: .apple, state: &state) - case .googleLoginButtonTapped: - return Self.handleLogin(provider: .google, state: &state) + case .kakaoLoginButtonTapped: + return Self.handleLogin(provider: .kakao, state: &state) - case .loginResponse(.success(let result)): - return Self.handleLoginSuccess(state: &state, result: result) + case .googleLoginButtonTapped: + return Self.handleLogin(provider: .google, state: &state) + + case .dismissError: + state.errorMessage = nil + return .none + } + } +} - case .loginResponse(.failure(let error)): - return Self.handleLoginFailure(state: &state, error: error) +// MARK: - Response - case .dismissError: - state.errorMessage = nil - return .none +private extension AuthReducer { + func reduceResponse( + state: inout State, + action: Action.Response + ) -> Effect { + switch action { + case .loginResponse(.success(let result)): + return Self.handleLoginSuccess(state: &state, result: result) - case .delegate: - return .none - } + case .loginResponse(.failure(let error)): + return Self.handleLoginFailure(state: &state, error: error) } } } @@ -110,9 +149,9 @@ private extension AuthReducer { return .run { send in do { let authResult = try await authClient.signIn(provider) - await send(.loginResponse(.success(authResult))) + await send(.response(.loginResponse(.success(authResult)))) } catch { - await send(.loginResponse(.failure(error))) + await send(.response(.loginResponse(.failure(error)))) } } } diff --git a/Projects/Feature/Auth/Sources/View/AuthView.swift b/Projects/Feature/Auth/Sources/View/AuthView.swift index 755d4093..7cadab94 100644 --- a/Projects/Feature/Auth/Sources/View/AuthView.swift +++ b/Projects/Feature/Auth/Sources/View/AuthView.swift @@ -30,11 +30,11 @@ public struct AuthView: View { "로그인 실패", isPresented: Binding( get: { store.errorMessage != nil }, - set: { _ in store.send(.dismissError) } + set: { _ in store.send(.view(.dismissError)) } ) ) { Button("확인") { - store.send(.dismissError) + store.send(.view(.dismissError)) } } message: { Text(store.errorMessage ?? "") @@ -119,7 +119,7 @@ private extension AuthView { private extension AuthView { var kakaoLoginButton: some View { Button { - store.send(.kakaoLoginButtonTapped) + store.send(.view(.kakaoLoginButtonTapped)) } label: { HStack(spacing: Spacing.spacing6) { Image.Icon.Symbol.kakao @@ -142,7 +142,7 @@ private extension AuthView { var googleLoginButton: some View { Button { - store.send(.googleLoginButtonTapped) + store.send(.view(.googleLoginButtonTapped)) } label: { HStack(spacing: Spacing.spacing6) { Image.Icon.Symbol.google @@ -169,7 +169,7 @@ private extension AuthView { var appleLoginButton: some View { Button { - store.send(.appleLoginButtonTapped) + store.send(.view(.appleLoginButtonTapped)) } label: { HStack(spacing: Spacing.spacing6) { Image.Icon.Symbol.apple diff --git a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift index 7b1c98bb..1bbd820a 100644 --- a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift +++ b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift @@ -10,12 +10,19 @@ import SwiftUI import ComposableArchitecture import CoreCaptureSession import CoreCaptureSessionInterface +import SharedPerfTestingSupport @main struct GoalDetailApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { GoalDetailExampleView() + .perfRoot("goal-detail") + .perfReadyMarker("goal-detail") } } } diff --git a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift index b998ff87..0aa6630c 100644 --- a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift +++ b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift @@ -5,14 +5,19 @@ // Created by 정지훈 on 1/23/26. // +import AVFoundation import SwiftUI import ComposableArchitecture import CoreCaptureSession +import CoreCaptureSessionInterface +import DomainGoalInterface +import DomainPhotoLogInterface import FeatureGoalDetail import FeatureGoalDetailInterface import FeatureProofPhoto import FeatureProofPhotoInterface +import SharedPerfTestingSupport import SharedDesignSystem struct GoalDetailExampleView: View { @@ -20,7 +25,18 @@ struct GoalDetailExampleView: View { GoalDetailView( store: Store( initialState: GoalDetailReducer.State( - currentUser: .mySelf, + // Branch by launch scenario. + // + // - Probe / rendering scenarios target `ReactionBarView`, + // which is gated by `isShowReactionBar = !isFrontMyCard + // && isCompleted`. They require `.you`. + // - Default-mode tests (Smoke / Navigation / ColdLaunch) + // target the primary-cta button (`feature.goal-detail + // .primary-cta`), which is only present when + // `isFrontMyCard` (i.e. `.mySelf`). Forcing `.you` for + // them would hide the button and break the navigation + // test. + currentUser: Self.initialCurrentUser, id: 1, verificationDate: "2026-02-07" ), @@ -29,15 +45,74 @@ struct GoalDetailExampleView: View { proofPhotoReducer: ProofPhotoReducer() ) }, withDependencies: { - $0.captureSessionClient = .liveValue + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue $0.proofPhotoFactory = .liveValue $0.goalClient = .previewValue + // Local no-op mock for the reaction update path. Without + // it, `reactionEmojiTapped` would hit a real network + // client and either crash (testValue) or fan out to the + // server. Rendering scenarios must stay local. + $0.photoLogClient = .perfMock } ) ) } } +private extension GoalDetailExampleView { + /// Pick `.you` only when a Pass 3 PERF scenario is active so the + /// reaction bar is reachable. Otherwise default to `.mySelf` so the + /// primary-cta button is visible — required by the existing + /// `GoalDetailExampleNavigationTests`. + static var initialCurrentUser: GoalDetail.Owner { + if UITestMode.isProbeScenario || UITestMode.isRenderingScenario { + return .you + } + return .mySelf + } +} + #Preview { GoalDetailExampleView() } + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) +} + +private extension PhotoLogClient { + /// Local no-op mock for Pass 3 rendering scenarios. Each closure returns + /// an empty success response without touching the network. Only + /// `updateReaction` is exercised by the reaction rapid-fire scenario; + /// the others are stubs to satisfy the struct's required initializer. + static let perfMock = Self( + fetchUploadURL: { _ in + PhotoLogUploadURLResponseDTO(uploadUrl: "", fileName: "") + }, + uploadImageData: { _, _ in }, + createPhotoLog: { _ in + PhotoLogCreateResponseDTO( + photologId: 0, + goalId: 0, + imageUrl: "", + comment: "", + verificationDate: "" + ) + }, + updateReaction: { photologId, request in + PhotoLogUpdateReactionResponseDTO( + photologId: photologId, + reaction: request.reaction + ) + }, + updatePhotoLog: { _, _ in }, + deletePhotoLog: { _ in } + ) +} diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift new file mode 100644 index 00000000..37cec90a --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift @@ -0,0 +1,15 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class GoalDetailExampleColdLaunchTests: XCTestCase { + func testColdLaunch() { + measure(metrics: [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("goal-detail", timeout: 30) + } + } +} diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleNavigationTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleNavigationTests.swift new file mode 100644 index 00000000..673d0493 --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleNavigationTests.swift @@ -0,0 +1,19 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class GoalDetailExampleNavigationTests: XCTestCase { + func testPrimaryCtaPresentsProofPhoto() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("goal-detail") + + let primaryCta = app.descendants(matching: .any)["feature.goal-detail.primary-cta"] + XCTAssertTrue(primaryCta.waitForExistence(timeout: 5), "primary-cta not found") + primaryCta.tap() + + let destinationReady = app.descendants(matching: .any)["feature.goal-detail-to-proof-photo.ready"] + XCTAssertTrue( + destinationReady.waitForExistence(timeout: 10), + "goal-detail-to-proof-photo ready marker did not appear" + ) + } +} diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleRenderingTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleRenderingTests.swift new file mode 100644 index 00000000..6112028c --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleRenderingTests.swift @@ -0,0 +1,105 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITests for FeatureGoalDetailExample. +/// +/// These tests are NOT benchmarks. They drive deterministic UI activity so +/// that a real-device xctrace recording (Time Profiler + Animation Hitches) +/// captures the GoalDetail rendering path. XCTest pass/fail and any timing +/// the harness prints are not the metric. +/// +/// ## Intended use +/// +/// 1. Launch on a real device. The test launches with +/// `-UITEST` + `-UITEST_RENDERING_SCENARIO` + `-UITEST_SEED default` +/// and `disableAnimations: false`. The GoalDetail Example app does not +/// have a PERF probe harness today, but rendering scenarios still +/// require the rendering launch flag so any future probe additions +/// stay gated off. +/// 2. Attach `xcrun xctrace record --attach FeatureGoalDetailExample` +/// once the driver has the GoalDetail view ready (UITest log shows +/// `feature.goal-detail.ready` exists). +/// 3. Stop the trace when the test reports completion. +/// +/// ## Scenarios +/// +/// - `testRendering_goalDetailInitialRender` — launch + idle window so the +/// trace covers initial render + `FlyingReactionOverlay.TimelineView` +/// continuously ticking at 60 Hz on an empty `reactions` array. +/// - `testRendering_goalDetailReactionRapidFire` — cycles through all five +/// `ReactionEmoji` cases, dispatching `.reactionEmojiTapped` for each. +/// Each tap mutates `state.selectedReactionEmoji`, fans out 20 flying +/// particles via the overlay, and posts to a local no-op +/// `photoLogClient.updateReaction` mock injected by +/// `GoalDetailExampleView`. +/// +/// ## Determinism +/// +/// - `goalClient.previewValue` returns a deterministic GoalDetail item; the +/// Example launches with `currentUser: .you` so the reaction bar is +/// guaranteed visible. +/// - PhotoLogClient is a local in-process mock (`PhotoLogClient.perfMock`) +/// so no network call is issued. +final class GoalDetailExampleRenderingTests: XCTestCase { + + /// Drives initial render + 7s idle window. Captures FlyingReactionOverlay + /// TimelineView's 60 Hz idle cost — relevant to the GoalDetail "무겁게 + /// 느껴진다" VoC. + func testRendering_goalDetailInitialRender() { + let app = XCUIApplication.launchForPerf( + seed: "default", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("goal-detail", timeout: 30) + + // 7s idle window. xctrace recording should cover this entirely. + Thread.sleep(forTimeInterval: 7.0) + } + + /// Cycles through all five reaction emojis and taps each repeatedly. + /// Different emoji per tap so `state.selectedReactionEmoji != reactionEmoji` + /// guard always passes and the reducer actually mutates state. Each tap + /// emits 20 flying particles for ~0.85–1.35s, so consecutive taps keep + /// FlyingReactionOverlay continuously busy. + func testRendering_goalDetailReactionRapidFire() { + let app = XCUIApplication.launchForPerf( + seed: "default", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("goal-detail", timeout: 30) + + // 5 ReactionEmoji rawValues. Cycling these guarantees each tap + // passes the `selectedReactionEmoji != reactionEmoji` guard so the + // reducer mutates state on every tap (not just on the first). + let reactionIdentifiers = [ + "feature.goal-detail.reaction-ICON_HAPPY", + "feature.goal-detail.reaction-ICON_TROUBLE", + "feature.goal-detail.reaction-ICON_LOVE", + "feature.goal-detail.reaction-ICON_DOUBT", + "feature.goal-detail.reaction-ICON_FUCK" + ] + var firstReactionExists = false + for identifier in reactionIdentifiers { + let element = app.descendants(matching: .any).matching(identifier: identifier).firstMatch + if !firstReactionExists { + firstReactionExists = element.waitForExistence(timeout: 10) + XCTAssertTrue(firstReactionExists, "Reaction bar not visible: \(identifier) missing") + } else { + XCTAssertTrue(element.exists, "Missing reaction identifier: \(identifier)") + } + } + + // 8 cycles × 5 emojis = 40 taps total. Each tap triggers state + // mutation + 20 particle emit + async no-op photoLogClient call. + for _ in 0..<8 { + for identifier in reactionIdentifiers { + app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .tap() + } + } + } +} diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleSmokeTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleSmokeTests.swift new file mode 100644 index 00000000..a6475fb2 --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class GoalDetailExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("goal-detail") + } +} diff --git a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift index 6e8b7256..b1f2e9c2 100644 --- a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift +++ b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift @@ -90,9 +90,13 @@ public struct GoalDetailReducer { public var isCameraPermissionAlertPresented: Bool = false public var selectedReactionEmoji: ReactionEmoji? - public var myHasEmoji: Bool { isFrontMyCard && selectedReactionEmoji != nil } + public var didPlayMyEmojiAppearAnimation: Bool = false + public var shouldShowMyEmojiAnimation: Bool { + isFrontMyCard && selectedReactionEmoji != nil && !didPlayMyEmojiAppearAnimation + } public var isShowReactionBar: Bool { !isFrontMyCard && isCompleted } public var isLoading: Bool { item == nil } + public var isFetchFailed: Bool = false public var isEditing: Bool = false public var isSavingPhotoLog: Bool = false public var pendingEditedImageData: Data? @@ -129,41 +133,58 @@ public struct GoalDetailReducer { /// GoalDetail 화면에서 발생하는 액션입니다. public enum Action: BindableAction { case binding(BindingAction) - - // MARK: - LifeCycle - case onAppear - case onDisappear - - // MARK: - Action - case bottomButtonTapped - case navigationBarTapped(TXNavigationBar.Action) - case reactionEmojiTapped(ReactionEmoji) - case cardSwiped - case focusChanged(Bool) - case dimmedBackgroundTapped - case updateMyPhotoLog(GoalDetail.CompletedGoal.PhotoLog) - - // MARK: - State Update - case authorizationCompleted(isAuthorized: Bool) - case fethedGoalDetailItem(GoalDetail) - case fetchGoalDetailFailed - case updateCurrentCardReaction(String?) - case reactionUpdateFailed(previousReaction: String?) - case showToast(TXToastType) - case proofPhotoDismissed - case cameraPermissionAlertDismissed - case updatePhotoLog - + // MARK: - Child Action case proofPhoto(ProofPhotoReducer.Action) - + + // MARK: - View + public enum View: Equatable { + case onAppear + case onDisappear + case bottomButtonTapped + case navigationBarTapped(TXNavigationBar.Action) + case reactionEmojiTapped(ReactionEmoji) + case cardSwiped + case myEmojiAppearAnimationPlayed + case focusChanged(Bool) + case dimmedBackgroundTapped + case proofPhotoDismissed + case cameraPermissionAlertDismissed + case dataRetryTapped + } + + // MARK: - Internal + public enum Internal: Equatable { + case updatePhotoLog + case updateMyPhotoLog(GoalDetail.CompletedGoal.PhotoLog) + } + + // MARK: - Response + public enum Response: Equatable { + case authorizationCompleted(isAuthorized: Bool) + case fethedGoalDetailItem(GoalDetail) + case fetchGoalDetailFailed + case updateCurrentCardReaction(photoLogId: Int64, reaction: String?) + case reactionUpdateFailed(previousReaction: String?) + } + + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(TXToastType) + } + // MARK: - Delegate case delegate(Delegate) - + /// GoalDetail 화면에서 외부로 전달하는 이벤트입니다. public enum Delegate { case navigateBack } + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입된 Reduce와 ProofPhotoReducer로 리듀서를 구성합니다. diff --git a/Projects/Feature/GoalDetail/Project.swift b/Projects/Feature/GoalDetail/Project.swift index f406f3d1..721a0853 100644 --- a/Projects/Feature/GoalDetail/Project.swift +++ b/Projects/Feature/GoalDetail/Project.swift @@ -24,6 +24,7 @@ let project = Project.makeModule( .core(interface: .captureSession), .domain(interface: .photoLog), .shared(implements: .designSystem), + .shared(implements: .perfTestingSupport), .shared(implements: .util), .external(dependency: .ComposableArchitecture) ] @@ -46,25 +47,28 @@ let project = Project.makeModule( ) ), - .feature( - example: .goalDetail, - config: .init( - infoPlist: .extendingDefault( - with: Project.Environment.InfoPlist.launchScreen.merging( - [ - "NSCameraUsageDescription": "UseCamera" - ], - uniquingKeysWith: { current, _ in current } - ) - ), - dependencies: [ - .shared(implements: .designSystem), - .feature(implements: .goalDetail), - .feature(implements: .proofPhoto), - .core(implements: .captureSession), - .external(dependency: .ComposableArchitecture) + .feature( + example: .goalDetail, + config: .init( + infoPlist: .extendingDefault( + with: Project.Environment.InfoPlist.launchScreen.merging( + [ + "NSCameraUsageDescription": "UseCamera" + ], + uniquingKeysWith: { current, _ in current } + ) + ), + dependencies: [ + .shared(implements: .designSystem), + .feature(implements: .goalDetail), + .feature(implements: .proofPhoto), + .core(implements: .captureSession), + .domain(interface: .goal), + .domain(interface: .photoLog), + .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .goalDetail) ] ) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift b/Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift index 8e701d79..df38fdd8 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift @@ -57,22 +57,35 @@ struct FlyingReactionOverlay: View { let alignment: Alignment var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { context in - ZStack(alignment: alignment) { - ForEach(reactions) { reaction in - let progress = reaction.progress(at: context.date) - if progress > 0, progress <= 1 { - reaction.emoji.image - .offset( - x: reaction.xOffset(at: progress), - y: reaction.yOffset(at: progress) - ) - .opacity(reaction.opacity(at: progress)) - .scaleEffect(reaction.scale) + Group { + if reactions.isEmpty { + // Idle guard: when there are no active particles, do not + // run the 60Hz TimelineView. Pass 2 trace identified the + // unconditional TimelineView as a ~0.12% idle CPU draw. + // When `.emit` adds particles the body re-evaluates and + // the else branch starts a fresh TimelineView; when the + // emitter clears the array we fall back to this branch + // and the TimelineView stops ticking. + Color.clear + } else { + TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { context in + ZStack(alignment: alignment) { + ForEach(reactions) { reaction in + let progress = reaction.progress(at: context.date) + if progress > 0, progress <= 1 { + reaction.emoji.image + .offset( + x: reaction.xOffset(at: progress), + y: reaction.yOffset(at: progress) + ) + .opacity(reaction.opacity(at: progress)) + .scaleEffect(reaction.scale) + } + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) } .allowsHitTesting(false) } diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift index 04ebd4a5..2997eb3f 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift @@ -21,6 +21,10 @@ private enum PokeCancelID: Hashable { case poke(Int64) } +private enum ReactionUpdateCancelID: Hashable { + case update(Int64) +} + private enum PokeCooldownManager { private static let userDefaultsKey = "pokeCooldownTimestamps" private static let cooldownInterval: TimeInterval = 3 * 60 * 60 @@ -79,6 +83,7 @@ extension GoalDetailReducer { @Dependency(\.goalClient) var goalClient @Dependency(\.photoLogClient) var photoLogClient @Dependency(\.analyticsClient) var analyticsClient + @Dependency(\.continuousClock) var clock let timeFormatter = RelativeTimeFormatter() @@ -86,59 +91,64 @@ extension GoalDetailReducer { let reducer = Reduce { state, action in switch action { // MARK: - LifeCycle - case .onAppear: + case .view(.onAppear): let date = state.verificationDate let goalId = state.goalId + state.isFetchFailed = false return .run { send in do { let item = try await goalClient.fetchGoalDetail(date, goalId) - await send(.fethedGoalDetailItem(item)) + await send(.response(.fethedGoalDetailItem(item))) } catch { - await send(.fetchGoalDetailFailed) + await send(.response(.fetchGoalDetailFailed)) } } - case .onDisappear: + case .view(.onDisappear): return .none + + case .view(.dataRetryTapped): + return .send(.view(.onAppear)) // MARK: - Action - case .bottomButtonTapped: + case .view(.bottomButtonTapped): if state.currentCompletedGoal?.status == .completed { - return .send(.showToast(.warning(message: "끝난 목표는 인증이 불가능해요!"))) + return .send(.presentation(.showToast(.warning(message: "끝난 목표는 인증이 불가능해요!")))) } let shouldGoToProofPhoto = (state.currentUser == .mySelf && !state.isCompleted) || state.isEditing if shouldGoToProofPhoto { return .run { send in let isAuthorized = await captureSessionClient.fetchIsAuthorized() - await send(.authorizationCompleted(isAuthorized: isAuthorized)) + await send(.response(.authorizationCompleted(isAuthorized: isAuthorized))) } } guard state.currentUser == .you, !state.isCompleted else { return .none } let goalId = state.currentGoalId if let remaining = PokeCooldownManager.remainingCooldown(goalId: goalId) { let timeText = PokeCooldownManager.formatRemainingTime(remaining) - return .send(.showToast(.warning(message: "\(timeText) 뒤에 다시 찌를 수 있어요"))) + return .send(.presentation(.showToast(.warning(message: "\(timeText) 뒤에 다시 찌를 수 있어요")))) } + let date = state.verificationDate return .run { send in PokeCooldownManager.recordPoke(goalId: goalId) do { - try await goalClient.pokePartner(goalId) + try await goalClient.pokePartner(goalId, PokeRequestDTO(date: date)) analyticsClient.logEvent(GoalDetailAnalyticsEvent.pokeSent) - await send(.showToast(.poke(message: "상대방을 찔렀어요!"))) + await send(.presentation(.showToast(.poke(message: "상대방을 찔렀어요!")))) } catch { PokeCooldownManager.removePoke(goalId: goalId) - await send(.showToast(.warning(message: "찌르기에 실패했어요"))) + await send(.presentation(.showToast(.warning(message: "찌르기에 실패했어요")))) } } .debounce(id: PokeCancelID.poke(goalId), for: .milliseconds(300), scheduler: DispatchQueue.main) - case let .navigationBarTapped(action): + case let .view(.navigationBarTapped(action)): if case .backTapped = action { return .send(.delegate(.navigateBack)) } else if case .rightTapped = action { if state.isEditing { - return .send(.updatePhotoLog) + return .send(.internal(.updatePhotoLog)) } else { state.isEditing = true state.commentText = state.comment @@ -146,47 +156,61 @@ extension GoalDetailReducer { } return .none - case let .reactionEmojiTapped(reactionEmoji): + case let .view(.reactionEmojiTapped(reactionEmoji)): guard state.currentUser == .you else { return .none } guard state.selectedReactionEmoji != reactionEmoji else { return .none } guard let photoLogId = state.currentCard?.photologId else { return .none } let previousReaction = state.currentCard?.reaction state.selectedReactionEmoji = reactionEmoji - return .concatenate( - .send(.updateCurrentCardReaction(reactionEmoji.rawValue)), - .run { send in - do { - let request = PhotoLogUpdateReactionRequestDTO(reaction: reactionEmoji.rawValue) - _ = try await photoLogClient.updateReaction(photoLogId, request) - analyticsClient - .logEvent( - GoalDetailAnalyticsEvent.emojiReactionSent(emoji: reactionEmoji.rawValue) + return .run { [clock] send in + try await clock.sleep(for: .milliseconds(300)) + do { + let request = PhotoLogUpdateReactionRequestDTO(reaction: reactionEmoji.rawValue) + _ = try await photoLogClient.updateReaction(photoLogId, request) + analyticsClient + .logEvent( + GoalDetailAnalyticsEvent.emojiReactionSent(emoji: reactionEmoji.rawValue) + ) + await send( + .response( + .updateCurrentCardReaction( + photoLogId: photoLogId, + reaction: reactionEmoji.rawValue ) - } catch { - await send(.reactionUpdateFailed(previousReaction: previousReaction)) - } + ) + ) + } catch is CancellationError { + return + } catch { + await send(.response(.reactionUpdateFailed(previousReaction: previousReaction))) } - ) + } + .cancellable(id: ReactionUpdateCancelID.update(photoLogId), cancelInFlight: true) - case .cardSwiped: + case .view(.cardSwiped): state.currentUser = state.currentUser == .mySelf ? .you : .mySelf state.commentText = state.comment state.selectedReactionEmoji = state.currentCard?.reaction.flatMap(ReactionEmoji.init(from:)) state.createdAt = timeFormatter.displayText(from: state.currentCard?.createdAt) return .none + + case .view(.myEmojiAppearAnimationPlayed): + state.didPlayMyEmojiAppearAnimation = true + return .none - case let .focusChanged(isFocused): + case let .view(.focusChanged(isFocused)): state.isCommentFocused = isFocused return .none - case .dimmedBackgroundTapped: + case .view(.dimmedBackgroundTapped): state.isCommentFocused = false return .none // MARK: - State Update - case let .fethedGoalDetailItem(item): + case let .response(.fethedGoalDetailItem(item)): state.item = item + state.isFetchFailed = false if let goalIndex = state.completedGoalItems.firstIndex(where: { $0.myPhotoLog?.goalId == state.goalId || $0.yourPhotoLog?.goalId == state.goalId }) { @@ -200,18 +224,17 @@ extension GoalDetailReducer { return .none - case .fetchGoalDetailFailed: - return .send(.showToast(.warning(message: "목표 상세 조회에 실패했어요"))) + case .response(.fetchGoalDetailFailed): + state.isFetchFailed = true + return .none - case let .updateCurrentCardReaction(reaction): - guard state.currentUser == .you else { return .none } + case let .response(.updateCurrentCardReaction(photoLogId: photoLogId, reaction: reaction)): guard let item = state.item else { return .none } - let targetGoalId = state.currentGoalId var updatedCompletedGoals = item.completedGoals guard let index = updatedCompletedGoals.firstIndex(where: { goal in guard let goal else { return false } - return goal.myPhotoLog?.goalId == targetGoalId || goal.yourPhotoLog?.goalId == targetGoalId + return goal.yourPhotoLog?.photologId == photoLogId }) else { return .none } guard let currentGoal = updatedCompletedGoals[index] else { return .none } @@ -229,15 +252,17 @@ extension GoalDetailReducer { ) return .none - case let .reactionUpdateFailed(previousReaction): - state.selectedReactionEmoji = previousReaction.flatMap(ReactionEmoji.init(from:)) - return .send(.showToast(.warning(message: "리액션 전송에 실패했어요"))) + case let .response(.reactionUpdateFailed(previousReaction)): + if state.currentUser == .you { + state.selectedReactionEmoji = previousReaction.flatMap(ReactionEmoji.init(from:)) + } + return .send(.presentation(.showToast(.warning(message: "리액션 전송에 실패했어요")))) - case let .showToast(toast): + case let .presentation(.showToast(toast)): state.toast = toast return .none - case let .authorizationCompleted(isAuthorized): + case let .response(.authorizationCompleted(isAuthorized)): if !isAuthorized { state.isCameraPermissionAlertPresented = true return .none @@ -252,11 +277,11 @@ extension GoalDetailReducer { return .none - case .cameraPermissionAlertDismissed: + case .view(.cameraPermissionAlertDismissed): state.isCameraPermissionAlertPresented = false return .none - case .updatePhotoLog: + case .internal(.updatePhotoLog): if let current = state.currentCard, state.currentUser == .mySelf { guard let photologId = current.photologId else { return .none } let pendingEditedImageData = state.pendingEditedImageData @@ -297,7 +322,7 @@ extension GoalDetailReducer { await send(.binding(.set(\.isSavingPhotoLog, false))) } catch { await send(.binding(.set(\.isSavingPhotoLog, false))) - await send(.showToast(.warning(message: "인증샷 수정에 실패했어요"))) + await send(.presentation(.showToast(.warning(message: "인증샷 수정에 실패했어요")))) } } } @@ -316,13 +341,13 @@ extension GoalDetailReducer { myPhotoLog.goalName = state.goalName myPhotoLog.photologId = state.currentCard?.photologId - return .send(.updateMyPhotoLog(myPhotoLog)) + return .send(.internal(.updateMyPhotoLog(myPhotoLog))) - case .proofPhotoDismissed: + case .view(.proofPhotoDismissed): state.proofPhoto = nil return .none - case let .updateMyPhotoLog(myPhotoLog): + case let .internal(.updateMyPhotoLog(myPhotoLog)): guard let item = state.item else { return .none } let targetGoalId = myPhotoLog.goalId diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 5a4bf592..52d8379f 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -12,6 +12,7 @@ import ComposableArchitecture import FeatureGoalDetailInterface import FeatureProofPhotoInterface import SharedDesignSystem +import SharedPerfTestingSupport import Kingfisher @@ -36,7 +37,6 @@ public struct GoalDetailView: View { @State private var rectFrame: CGRect = .zero @State private var keyboardFrame: CGRect = .zero @StateObject private var myEmojiFlyingReactionEmitter = FlyingReactionEmitter() - @State private var didPlayMyEmojiAppearAnimation = false @State private var cardOffset: CGFloat = .zero @State private var isCrossingDuringDrag: Bool = false @@ -61,64 +61,92 @@ public struct GoalDetailView: View { } public var body: some View { - VStack(spacing: 0) { - navigationBar - .zIndex(1) - - if store.item != nil { - cardView - .padding(.horizontal, 27) - .padding(.top, isSEDevice ? 47 : 103) - - if store.isCompleted { - completedBottomContent - } else if store.currentCompletedGoal?.status != .completed { - bottomButton - .padding(.top, 105) - .overlay(alignment: .bottomLeading) { - pokeImage - .offset(x: 79, y: -45) - } + GeometryReader { _ in + ZStack { + mainContent + + if store.isEditing && store.isCommentFocused { + dimmedView + .ignoresSafeArea() + } + + if shouldShowCommentOverlay { + floatingCommentOverlay + } + + if store.isShowReactionBar { + reactionBar } } - - Spacer() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .ignoresSafeArea(.keyboard) - .background(dimmedView) + .background(Color.Common.white) .toolbar(.hidden, for: .navigationBar) .observeKeyboardFrame($keyboardFrame) .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .onDisappear { - didPlayMyEmojiAppearAnimation = false myEmojiFlyingReactionEmitter.clear() - store.send(.onDisappear) + store.send(.view(.onDisappear)) } .fullScreenCover( isPresented: $store.isPresentedProofPhoto, - onDismiss: { store.send(.proofPhotoDismissed) }, + onDismiss: { store.send(.view(.proofPhotoDismissed)) }, content: { - IfLetStore(store.scope(state: \.proofPhoto, action: \.proofPhoto)) { store in - proofPhotoFactory.makeView(store) + if let proofPhotoStore = store.scope(state: \.proofPhoto, action: \.proofPhoto) { + proofPhotoFactory.makeView(proofPhotoStore) + .perfReadyMarker("goal-detail-to-proof-photo") } } ) .cameraPermissionAlert( isPresented: $store.isCameraPermissionAlertPresented, - onDismiss: { store.send(.cameraPermissionAlertDismissed) } + onDismiss: { store.send(.view(.cameraPermissionAlertDismissed)) } ) .overlay(alignment: .bottom) { myEmojiFlyingReactionOverlay } .txToast(item: $store.toast, customPadding: 54) - .txLoading(isPresented: store.isSavingPhotoLog) + .txLoading(isPresented: store.isLoading || store.isSavingPhotoLog) } } // MARK: - SubViews private extension GoalDetailView { + var mainContent: some View { + VStack(spacing: 0) { + navigationBar + .zIndex(1) + + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else { + if store.item != nil { + cardView + .padding(.horizontal, 27) + .padding(.top, Constants.cardTopPadding) + + if store.isCompleted { + completedBottomContent + } else if store.currentCompletedGoal?.status != .completed { + bottomButton + .padding(.top, 105) + .overlay(alignment: .bottomLeading) { + pokeImage + .offset(x: 79, y: -45) + } + } + } + + Spacer() + } + } + } + var navigationBar: some View { TXNavigationBar( style: .subContent( @@ -130,14 +158,15 @@ private extension GoalDetailView { ) ), onAction: { action in - store.send(.navigationBarTapped(action)) + store.send(.view(.navigationBarTapped(action))) } ) - .overlay(dimmedView) } var cardView: some View { ZStack { + cardFrameReader + myCard .zIndex(effectiveIsFrontMyCard ? 1 : 0) @@ -147,6 +176,7 @@ private extension GoalDetailView { .gesture( DragGesture() .onChanged { value in + guard !store.isEditing else { return } let translation = value.translation let width = resistedDragWidth( for: translation.width, @@ -166,14 +196,35 @@ private extension GoalDetailView { cardOffset = repeatedCardOffset(for: width) isCrossingDuringDrag = shouldCrossCards(for: width) } - .onEnded { _ in + .onEnded { value in + guard !store.isEditing else { return } + + let translation = value.translation + let width = resistedDragWidth( + for: translation.width, + velocity: value.velocity.width + ) + + guard abs(width) >= abs(translation.height) else { + withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { + resetDragState() + } + return + } + withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { resetDragState() - store.send(.cardSwiped) + store.send(.view(.cardSwiped)) } } ) } + + var cardFrameReader: some View { + Color.clear + .frame(width: Constants.cardSize, height: Constants.cardSize) + .readSize { rectFrame = $0 } + } @ViewBuilder var myCard: some View { @@ -182,7 +233,6 @@ private extension GoalDetailView { isCompleted: store.myCardIsCompleted, imageData: store.pendingEditedImageData, imageURL: store.myCard?.imageUrl, - comment: store.myCard?.comment ?? "", showsMyEmoji: effectiveIsFrontMyCard && store.selectedReactionEmoji != nil ) .offset(x: cardOffset * (effectiveIsFrontMyCard ? 1 : -1)) @@ -195,7 +245,6 @@ private extension GoalDetailView { isCompleted: store.partnerCardIsCompleted, imageData: nil, imageURL: store.partnerCard?.imageUrl, - comment: store.partnerCard?.comment ?? "", showsMyEmoji: false ) .offset(x: cardOffset * (effectiveIsFrontMyCard ? -1 : 1)) @@ -213,12 +262,6 @@ private extension GoalDetailView { .padding(.top, 14) .padding(.trailing, 36) } - - if store.isShowReactionBar { - reactionBar - .padding(.top, isSEDevice ? 23 : 73) - .padding(.horizontal, 20) - } } var createdAtText: some View { @@ -232,9 +275,16 @@ private extension GoalDetailView { ReactionBarView( selectedEmoji: store.selectedReactionEmoji, onSelect: { emoji in - store.send(.reactionEmojiTapped(emoji)) + store.send(.view(.reactionEmojiTapped(emoji))) } ) + .padding(.horizontal, Constants.reactionBarHorizontalPadding) + .position( + x: rectFrame.midX, + y: rectFrame.maxY + + Constants.reactionBarTopPadding + + Constants.reactionBarHeight / 2 + ) } var backgroundCard: some View { @@ -247,8 +297,7 @@ private extension GoalDetailView { shape: shape, lineWidth: 1.6 ) - .frame(width: 336, height: 336) - .overlay(dimmedView) + .frame(width: Constants.cardSize, height: Constants.cardSize) .clipShape(shape) } @@ -258,7 +307,6 @@ private extension GoalDetailView { isCompleted: Bool, imageData: Data?, imageURL: String?, - comment: String, showsMyEmoji: Bool ) -> some View { ZStack { @@ -269,7 +317,6 @@ private extension GoalDetailView { isCompleted: isCompleted, imageData: imageData, imageURL: imageURL, - comment: comment, showsMyEmoji: showsMyEmoji ) .opacity(isFront ? 1 : 0) @@ -281,14 +328,12 @@ private extension GoalDetailView { isCompleted: Bool, imageData: Data?, imageURL: String?, - comment: String, showsMyEmoji: Bool ) -> some View { if isCompleted { completedImageCard( imageData: imageData, imageURL: imageURL, - comment: comment, showsMyEmoji: showsMyEmoji ) } else { @@ -316,26 +361,28 @@ private extension GoalDetailView { shape: shape, lineWidth: 1.6 ) - .overlay(dimmedView) } @ViewBuilder func completedImageCard( imageData: Data?, imageURL: String?, - comment: String, showsMyEmoji: Bool ) -> some View { if let imageData, let editedImage = UIImage(data: imageData) { - completedImageCardContainer(comment: comment, showsMyEmoji: showsMyEmoji) { + completedImageCardContainer( + showsMyEmoji: showsMyEmoji + ) { Image(uiImage: editedImage) .resizable() .scaledToFill() } } else if let imageURL, let url = URL(string: imageURL) { - completedImageCardContainer(comment: comment, showsMyEmoji: showsMyEmoji) { + completedImageCardContainer( + showsMyEmoji: showsMyEmoji + ) { KFImage(url) .resizable() .scaledToFill() @@ -366,26 +413,52 @@ private extension GoalDetailView { state: .standard ), onTap: { - store.send(.bottomButtonTapped) + store.send(.view(.bottomButtonTapped)) } ) + .perfControl(slug: "goal-detail", element: "primary-cta") } - + @ViewBuilder func commentCircle(comment: String) -> some View { - let keyboardInset = max(0, rectFrame.maxY - keyboardFrame.minY) TXCommentCircle( commentText: store.isEditing ? $store.commentText : .constant(comment), isEditable: store.isEditing, - keyboardInset: keyboardInset, isFocused: $store.isCommentFocused, onFocused: { isFocused in - store.send(.focusChanged(isFocused)) + store.send(.view(.focusChanged(isFocused))) } ) - .animation(.easeOut(duration: 0.25), value: keyboardInset) } - + + var shouldShowCommentOverlay: Bool { + guard effectiveFrontCardIsCompleted, rectFrame != .zero else { return false } + return store.isEditing || !currentFrontComment.isEmpty + } + + var currentFrontComment: String { + if effectiveIsFrontMyCard { + return store.myCard?.comment ?? "" + } else { + return store.partnerCard?.comment ?? "" + } + } + + var floatingCommentOverlay: some View { + GeometryReader { rootGeo in + let rootFrame = rootGeo.frame(in: .global) + let posX = rectFrame.minX - rootFrame.minX + let posY = rectFrame.minY - rootFrame.minY + + commentCircle(comment: currentFrontComment) + .padding(.bottom, 26) + .frame(width: rectFrame.width, height: rectFrame.height, alignment: .bottom) + .rotationEffect(frontCardRotation) + .offset(x: posX + cardOffset, y: posY - keyboardInset) + .animation(.easeOut(duration: 0.25), value: keyboardInset) + } + } + var dimmedView: some View { Color.Dimmed.dimmed70 .opacity(store.isEditing && store.isCommentFocused ? 1 : 0) @@ -394,33 +467,24 @@ private extension GoalDetailView { .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() .onTapGesture { - store.send(.dimmedBackgroundTapped) + store.send(.view(.dimmedBackgroundTapped)) } } func completedImageCardContainer( - comment: String, showsMyEmoji: Bool, @ViewBuilder content: @escaping () -> Content ) -> some View { let shape = RoundedRectangle(cornerRadius: 20) return Color.clear - .frame(width: 336, height: 336) - .readSize { rectFrame = $0 } + .frame(width: Constants.cardSize, height: Constants.cardSize) .overlay { content() .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() } - .overlay(dimmedView) .clipShape(shape) - .overlay(alignment: .bottom) { - if !comment.isEmpty { - commentCircle(comment: comment) - .padding(.bottom, 26) - } - } .insideBorder( Color.Gray.gray500, shape: shape, @@ -477,10 +541,8 @@ private extension GoalDetailView { containerWidth: CGFloat, containerHeight: CGFloat ) { - guard store.myHasEmoji, - !didPlayMyEmojiAppearAnimation, + guard store.shouldShowMyEmojiAnimation, let selectedEmoji = store.selectedReactionEmoji else { return } - didPlayMyEmojiAppearAnimation = true myEmojiFlyingReactionEmitter.emit( emoji: selectedEmoji, config: .goalDetailBottom( @@ -488,6 +550,7 @@ private extension GoalDetailView { height: containerHeight ) ) + store.send(.view(.myEmojiAppearAnimationPlayed)) } } @@ -497,6 +560,18 @@ private extension GoalDetailView { isCrossingDuringDrag ? !store.isFrontMyCard : store.isFrontMyCard } + var keyboardInset: CGFloat { + max(0, rectFrame.maxY - keyboardFrame.minY) + } + + var frontCardRotation: Angle { + effectiveIsFrontMyCard ? .degrees(0) : .degrees(-8) + } + + var effectiveFrontCardIsCompleted: Bool { + effectiveIsFrontMyCard ? store.myCardIsCompleted : store.partnerCardIsCompleted + } + func repeatedCardOffset(for width: CGFloat) -> CGFloat { let maxOffset = Constants.maxCardOffset let direction: CGFloat = width >= 0 ? 1 : -1 @@ -528,19 +603,23 @@ private extension GoalDetailView { cardOffset = .zero isCrossingDuringDrag = false } - - // 다른곳에서도 쓸 때 Util로 빼기 - private var isSEDevice: Bool { - UIScreen.main.bounds.height <= 667 - } } // MARK: - Constants private extension GoalDetailView { enum Constants { + static var isSEDevice: Bool { + UIScreen.main.bounds.height <= 667 + } + static let maxCardOffset: CGFloat = 100 static let dragVelocityThreshold: CGFloat = 1200 static let minimumDragResistance: CGFloat = 0.35 + static var cardTopPadding: CGFloat { isSEDevice ? 34 : 89 } + static var cardSize: CGFloat { isSEDevice ? 321 : 336 } + static let reactionBarHeight: CGFloat = 77 + static let reactionBarHorizontalPadding: CGFloat = 20 + static var reactionBarTopPadding: CGFloat { isSEDevice ? 19 : 69 } } } diff --git a/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift b/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift index fa9095fd..a54af3db 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift @@ -8,6 +8,7 @@ import SwiftUI import SharedDesignSystem +import SharedPerfTestingSupport struct ReactionBarView: View { let selectedEmoji: ReactionEmoji? @@ -26,8 +27,8 @@ struct ReactionBarView: View { var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - shadowView(proxy: proxy) - .offset(y: 10) + shadowView + .offset(y: 9) reactionBar(proxy: proxy) } @@ -45,45 +46,97 @@ struct ReactionBarView: View { // MARK: - SubViews private extension ReactionBarView { - func shadowView(proxy: GeometryProxy) -> some View { + var shadowView: some View { Color.Gray.gray200 - .frame(width: proxy.size.width, height: 67) + .frame(maxWidth: 368) + .frame(height: 68) .clipShape(.capsule) } func reactionBar(proxy: GeometryProxy) -> some View { HStack(spacing: 0) { ForEach(ReactionEmoji.allCases, id: \.self) { emoji in - Button { - onSelect(emoji) - flyingReactionEmitter.emit( - emoji: emoji, - config: .reactionBar(width: proxy.size.width) - ) - } label: { - emoji.image - .padding(.horizontal, 8) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(selectedEmoji == emoji ? Color.Gray.gray300 : Color.clear) - - if emoji != ReactionEmoji.allCases.last { - Rectangle() - .frame(width: 1) + Group { + if case .happy = emoji { + firstButton(proxy: proxy, emoji: emoji) + } else if case .fuck = emoji { + lastButton(proxy: proxy, emoji: emoji) + } else { + rectButton(proxy: proxy, emoji: emoji) + } } + .perfControl(slug: "goal-detail", element: "reaction-\(emoji.rawValue)") } } .background(Color.Gray.gray100) - .frame(width: proxy.size.width, height: 68) + .frame(maxWidth: 368) + .frame(height: 68) .clipShape(.capsule) .overlay( Capsule() .stroke(Color.Gray.gray500, lineWidth: 1) ) } + + func firstButton(proxy: GeometryProxy, emoji: ReactionEmoji) -> some View { + Button { + onSelect(emoji) + flyingReactionEmitter.emit( + emoji: emoji, + config: .reactionBar(width: proxy.size.width) + ) + } label: { + emoji.image + .padding(.leading, 10) + .padding(.trailing, 6) + .padding(.bottom, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 70, maxWidth: 84, maxHeight: .infinity) + .background(selectedEmoji == emoji ? Color.Gray.gray300 : Color.clear) + .clipShape(UnevenRoundedRectangle(topLeadingRadius: 999, bottomLeadingRadius: 999)) + } + + func lastButton(proxy: GeometryProxy, emoji: ReactionEmoji) -> some View { + Button { + onSelect(emoji) + flyingReactionEmitter.emit( + emoji: emoji, + config: .reactionBar(width: proxy.size.width) + ) + } label: { + emoji.image + .padding(.leading, 3) + .padding(.trailing, 10) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 70, maxWidth: 84, maxHeight: .infinity) + .background(selectedEmoji == emoji ? Color.Gray.gray300 : Color.clear) + .clipShape(UnevenRoundedRectangle(bottomTrailingRadius: 999, topTrailingRadius: 999)) + } + + func rectButton(proxy: GeometryProxy, emoji: ReactionEmoji) -> some View { + Button { + onSelect(emoji) + flyingReactionEmitter.emit( + emoji: emoji, + config: .reactionBar(width: proxy.size.width) + ) + } label: { + emoji.image + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 70, maxWidth: 80, maxHeight: .infinity) + .background(selectedEmoji == emoji ? Color.Gray.gray300 : Color.clear) + .overlay( + Rectangle() + .stroke(Color.Gray.gray500, lineWidth: 1) + ) + } } private extension ReactionBarView { + static func reactionBarConfig(width: CGFloat) -> FlyingReactionConfig { let minX: CGFloat = 8 let maxXInset: CGFloat = 32 diff --git a/Projects/Feature/GoalDetail/Testing/Sources/Source.swift b/Projects/Feature/GoalDetail/Testing/Sources/Source.swift index 89510740..a4003b50 100644 --- a/Projects/Feature/GoalDetail/Testing/Sources/Source.swift +++ b/Projects/Feature/GoalDetail/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 01/21/26. // -/// Remove Or Edit +/// Stable perf seed names for the GoalDetail example app. +public enum GoalDetailPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/Home/Example/Sources/HomeApp.swift b/Projects/Feature/Home/Example/Sources/HomeApp.swift index 8276367b..63ad890c 100644 --- a/Projects/Feature/Home/Example/Sources/HomeApp.swift +++ b/Projects/Feature/Home/Example/Sources/HomeApp.swift @@ -1,17 +1,134 @@ -// -// HomeView.swift -// -// -// Created by Jihun on 01/26/26. -// - +import AVFoundation +import ComposableArchitecture +import CoreCaptureSession +import CoreCaptureSessionInterface +import DomainGoalInterface +import DomainNotificationInterface +import Foundation +import FeatureGoalDetail +import FeatureGoalDetailInterface +import FeatureHome +import FeatureHomeInterface +import FeatureMakeGoal +import FeatureMakeGoalInterface +import FeatureNotification +import FeatureNotificationInterface +import FeatureProofPhoto +import FeatureProofPhotoInterface +import FeatureSettings +import FeatureSettingsInterface +import FeatureStats +import FeatureStatsInterface +import SharedPerfTestingSupport import SwiftUI @main struct HomeApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { - Text("Hello Twix") + HomeCoordinatorView( + store: Store( + initialState: HomeCoordinator.State(), + reducer: { + HomeCoordinator( + goalDetailReducer: GoalDetailReducer( + proofPhotoReducer: ProofPhotoReducer() + ), + statsDetailReducer: StatsDetailReducer(), + proofPhotoReducer: ProofPhotoReducer(), + makeGoalReducer: MakeGoalReducer(), + editGoalListReducer: EditGoalListReducer(), + settingsReducer: SettingsReducer(), + notificationReducer: NotificationReducer() + ) + }, + withDependencies: { + $0.goalClient = HomeApp.goalClient(for: UITestMode.seedName) + $0.notificationClient = .previewValue + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue + $0.proofPhotoFactory = .liveValue + $0.goalDetailFactory = .liveValue + $0.statsDetailFactory = .liveValue + $0.makeGoalFactory = .liveValue + $0.settingsFactory = .liveValue + $0.notificationFactory = .liveValue + } + ) + ) + .perfRoot("home") + .perfReadyMarker("home") + } + } +} + +private extension HomeApp { + static func goalClient(for seed: String) -> GoalClient { + guard UITestMode.isEnabled else { return .previewValue } + switch seed { + case "scroll-50": + return perfGoalClient(count: 50) + case "home-heavy": + // First authoritative rendering driver dataset. 200 cells is + // large enough to force multiple scroll passes and exercise + // LazyVStack materialization without making the recording window + // unbounded. + return perfGoalClient(count: 200) + default: + return .previewValue } } + + static func perfGoalClient(count: Int) -> GoalClient { + var client = GoalClient.previewValue + client.fetchGoals = { _ in + GoalList( + hasEverRegisteredGoal: true, + goals: (1...count).map(perfScrollGoal(index:)) + ) + } + return client + } + + static func perfScrollGoal(index: Int) -> Goal { + let id = Int64(index) + let icon: String = index.isMultiple(of: 2) ? "ICON_EXERCISE" : "ICON_BOOK" + let myVerification = Goal.Verification( + photologId: id * 10 + 1, + isCompleted: index.isMultiple(of: 3), + imageURL: nil, + emoji: nil + ) + let yourVerification = Goal.Verification( + photologId: id * 10 + 2, + isCompleted: index.isMultiple(of: 4), + imageURL: nil, + emoji: nil + ) + return Goal( + id: id, + goalIcon: icon, + title: "Perf scroll item #\(index)", + myVerification: myVerification, + yourVerification: yourVerification, + repeatCycle: .daily, + repeatCount: 1, + startDate: "2026-02-01", + endDate: nil + ) + } +} + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) } diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift new file mode 100644 index 00000000..dc359fea --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift @@ -0,0 +1,15 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleColdLaunchTests: XCTestCase { + func testColdLaunch() { + measure(metrics: [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home", timeout: 30) + } + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift new file mode 100644 index 00000000..2bbbf4d5 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift @@ -0,0 +1,134 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITest for FeatureHomeExample. +/// +/// This is a deterministic UI driver, **not a benchmark**. The XCTest +/// pass/fail status and any timing the XCTest harness happens to print are +/// not the rendering metric — they only indicate whether the deterministic +/// UI script completed. The authoritative rendering metric is a real-device +/// xctrace / Instruments trace recorded while this driver runs. +/// +/// ## Intended use +/// +/// 1. Launch this test against a real device. It launches with +/// `-UITEST` + `-UITEST_RENDERING_SCENARIO` + `-UITEST_SEED home-heavy` +/// and `disableAnimations: false`, so the PERF probe harness stays gated +/// off and animations behave like production. +/// 2. Attach `xcrun xctrace record --attach FeatureHomeExample` (Time +/// Profiler or SwiftUI template) once the driver enters the scroll +/// phase (UITest log shows `Synthesize event`). +/// 3. Stop the trace when the test reports completion. +/// 4. Compare before/after traces in Instruments. That comparison is the +/// authoritative rendering metric. +/// +/// ## Determinism +/// +/// - Single seed: `home-heavy` → 200 deterministic cells (`HomeApp.swift`). +/// - Fixed coordinate-based drag pattern (25 up + 25 down = 50 +/// interactions). Coordinate-based drag is denser and produces less +/// accessibility idle wait between gestures than `swipeUp()`, so a +/// higher fraction of the recording window is actual scroll work. +/// - `disableAnimations: false` so SwiftUI animation timing reflects +/// production. (Smoke / probe scenarios use the default `true` for +/// stability; rendering scenarios must NOT inherit that setting.) +/// - No XCTest `measure(metrics:)`. The driver runs once per launch. +/// +/// ## Guardrails +/// +/// - Asserts that probe harness identifiers are absent, to catch the bug +/// where someone accidentally activates `-UITEST_PROBE_SCENARIO` and +/// pollutes a rendering trace with the 44pt layout shift. +final class HomeExampleFeedScrollRenderingTests: XCTestCase { + + /// Drives a same-screen state-change rendering scenario. Each calendar + /// swipe dispatches the production `weekCalendarSwipe` action, which + /// cascades through `.setCalendarDate` → `calendarWeeks` rebuild + + /// `.fetchGoals` → 200-cell list reload + LazyVStack re-render. No + /// navigation, no scroll-position change, no PERF harness. The + /// authoritative metric is the Instruments / xctrace trace recorded + /// while this driver runs. Not a benchmark. + func testRendering_homeHeavyCalendarWeekSweep() { + let app = XCUIApplication.launchForPerf( + seed: "home-heavy", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("home", timeout: 30) + + let probeToastShow = app.descendants(matching: .any)["feature.home.perf.toast-show"] + XCTAssertFalse( + probeToastShow.exists, + "PERF probe harness is active under a rendering scenario launch. The trace would be polluted by the 44pt layout shift. Re-check launchForPerf(scenario:) arguments." + ) + + // `feature.home.calendar` may be present on multiple descendants + // because the TXCalendar composite propagates the accessibility + // identifier to its internal cells. The first match in document + // order is the calendar container — that's the swipe target. + let calendar = app.descendants(matching: .any) + .matching(identifier: "feature.home.calendar") + .firstMatch + XCTAssertTrue(calendar.waitForExistence(timeout: 10), "feature.home.calendar not found") + + // Horizontal drag on the calendar bar fires onSwipe -> reducer + // `weekCalendarSwipe` -> `setCalendarDate(...)`. Each tick triggers + // calendarWeeks regeneration + items refetch + cardList re-render. + // 20 left + 20 right = 40 deterministic same-screen state changes. + let left = calendar.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5)) + let right = calendar.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + for _ in 0..<20 { + right.press(forDuration: 0.01, thenDragTo: left) + left.press(forDuration: 0.01, thenDragTo: right) + } + } + + /// Drives the home-heavy feed scroll. Not a benchmark — use Instruments + /// for the rendering metric. See class doc. + func testRendering_homeHeavyFeedScroll() { + let app = XCUIApplication.launchForPerf( + seed: "home-heavy", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("home", timeout: 30) + + // Guardrail: rendering scenarios must NOT activate the PERF probe + // harness, otherwise the 44pt layout shift would change scroll + // geometry / LazyVStack materialization range and contaminate the + // trace. If these identifiers exist, someone passed + // `-UITEST_PROBE_SCENARIO` by accident. + let probeToastShow = app.descendants(matching: .any)["feature.home.perf.toast-show"] + XCTAssertFalse( + probeToastShow.exists, + "PERF probe harness is active under a rendering scenario launch. The trace this driver produces would be polluted by the 44pt layout shift. Re-check launchForPerf(scenario:) arguments." + ) + + let feed = app.descendants(matching: .any)["feature.home.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 10), "feature.home.feed not found") + + // Coordinate-based dense drag drive. + // + // IMPORTANT: normalize coordinates against the *window*, NOT the + // `feed` element. `feed` is the LazyVStack inside a ScrollView and + // its accessibility frame reports the *content* size (200 cells + // ≈ 16,000pt tall on this fixture). Drag origins normalized to + // that frame land far below the visible viewport and the OS + // delivers them to Springboard, which backgrounds the app between + // every drag and contaminates the recording with system activity. + // + // Window-normalized coordinates with safe dy values stay inside + // the visible feed area (navbar + calendar bar live above dy 0.30, + // home indicator lives below dy 0.85). + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "no window") + let top = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35)) + let bottom = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.80)) + for _ in 0..<25 { + bottom.press(forDuration: 0.01, thenDragTo: top) + } + for _ in 0..<25 { + top.press(forDuration: 0.01, thenDragTo: bottom) + } + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleNavigationTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleNavigationTests.swift new file mode 100644 index 00000000..fa786d34 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleNavigationTests.swift @@ -0,0 +1,23 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleNavigationTests: XCTestCase { + func testTappingCellPushesDestination() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home") + + let firstCell = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home.cell.'")) + .firstMatch + XCTAssertTrue(firstCell.waitForExistence(timeout: 5), "no Home cell found") + firstCell.tap() + + let destinationReady = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home-to-'")) + .firstMatch + XCTAssertTrue( + destinationReady.waitForExistence(timeout: 10), + "no destination ready marker (home-to-goal-detail or home-to-stats-detail) appeared" + ) + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift new file mode 100644 index 00000000..0ff0ae15 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift @@ -0,0 +1,126 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **probe-only** UITests for FeatureHomeExample. +/// +/// IMPORTANT — these tests are NOT UI Rendering benchmarks. +/// +/// - The XCTest `Clock Monotonic Time` / `CPU Instructions Retired` numbers +/// reported by `measureActionLatency` include XCUI tap synthesis, marker +/// polling, accessibility-tree synchronization, and app/test process IPC. +/// They do not isolate SwiftUI rendering cost. +/// - The authoritative UI Rendering metric is an Xcode Instruments / xctrace +/// `Time Profiler` (or `SwiftUI`) trace recorded on a real device. The +/// UITest is only the deterministic driver that drives the app through the +/// same UI steps during recording. +/// - `PerfRebuildProxyPing` is a proxy signal for view rebuild frequency +/// (View struct init). It is not an exact SwiftUI body-evaluation counter. +/// - The `PerfToastPresentationHarness` modifier conditionally adds +/// `store.toast` observation to HomeView **only when UITestMode.isEnabled**. +/// Production HomeView does not observe `toast`. The probe scenario is +/// therefore an artificial path used to exercise observation scoping +/// experiments; it is NOT representative of the user's real rendering path. +/// - The PERF action harness sits as the first VStack child in HomeView and +/// shifts the production layout by ~44pt **only when the probe scenario is +/// active** (`-UITEST_PROBE_SCENARIO`). Plain UITest launches and +/// `-UITEST_RENDERING_SCENARIO` launches do not activate the harness, so +/// production layout / scroll geometry is preserved for those modes. Even +/// inside the probe scenario, residual effects of the harness on SwiftUI +/// layout pass, accessibility tree, scroll geometry, and LazyVStack +/// materialization cannot be fully ruled out — probe DELTAs should be +/// interpreted cautiously and any authoritative claim must be verified +/// with an Instruments trace from a rendering-scenario launch. +/// +/// Treat the numbers below as **driver/probe sanity metrics**. Do not cite +/// them as UI Rendering improvement evidence. +/// +/// ## Reading the measured metric +/// +/// `measureActionLatency(repetitions: 5)` reports the time for the **bundle** +/// of 5 repetitions per `measure` iteration. To derive per-state-change +/// latency: `bundle / repetitions / (state changes per repetition)`. For +/// these probes each repetition performs 2 state changes (show+dismiss or +/// next+prev), so per-action latency = `bundle / 5 / 2`. +final class HomeExampleRenderingProbeTests: XCTestCase { + + /// Probe: toggle `store.toast` via PERF-only buttons and confirm the + /// `PerfToastPresentationHarness` marker + `home.view.rebuild.proxy` + /// counter respond. Not an authoritative rendering metric. + func testProbe_toastShowDismiss_markerAndCounter() { + let app = XCUIApplication.launchForPerf(seed: "default", scenario: .probe) + waitForFeatureReady("home", timeout: 30) + + let showButton = app.descendants(matching: .any)["feature.home.perf.toast-show"] + let dismissButton = app.descendants(matching: .any)["feature.home.perf.toast-dismiss"] + XCTAssertTrue(showButton.waitForExistence(timeout: 5), "PERF toast-show button missing") + XCTAssertTrue(dismissButton.exists, "PERF toast-dismiss button missing") + awaitPerfMarker(slug: "home", key: "toast", value: "hidden", timeout: 5) + + // Probe-only driver metric. See class doc-comment. + measureActionLatency(repetitions: 5) { + showButton.tap() + awaitPerfMarker(slug: "home", key: "toast", value: "visible") + dismissButton.tap() + awaitPerfMarker(slug: "home", key: "toast", value: "hidden") + } + + let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") + XCTAssertGreaterThan( + rebuildProxy, + 0, + "home.view.rebuild.proxy counter never incremented (proxy signal, not exact body count)" + ) + print("[perf-probe-counters] home.view.rebuild.proxy=\(rebuildProxy)") + } + + /// Probe: toggle `calendarDate` via PERF-only buttons and confirm the + /// calendar sub-view `perfStateMarker` responds. Triggers a real + /// production cascade (calendarWeeks / items refetch), so this probe + /// includes more than a pure presentation-only state change. Not an + /// authoritative rendering metric. + func testProbe_calendarMonthToggle_markerAndCounter() { + let app = XCUIApplication.launchForPerf(seed: "default", scenario: .probe) + waitForFeatureReady("home", timeout: 30) + + let nextButton = app.descendants(matching: .any)["feature.home.perf.calendar-next"] + let prevButton = app.descendants(matching: .any)["feature.home.perf.calendar-prev"] + XCTAssertTrue(nextButton.waitForExistence(timeout: 5), "PERF calendar-next button missing") + XCTAssertTrue(prevButton.exists, "PERF calendar-prev button missing") + + let baseMarker = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home.marker.calendar-month.'")) + .firstMatch + XCTAssertTrue(baseMarker.waitForExistence(timeout: 5), "initial calendar-month marker missing") + let baseIdentifier = baseMarker.identifier + let baseValue = baseIdentifier.replacingOccurrences( + of: "feature.home.marker.calendar-month.", + with: "" + ) + let baseParts = baseValue.split(separator: "-").compactMap { Int($0) } + guard baseParts.count == 2 else { + XCTFail("unexpected base marker identifier: \(baseIdentifier)") + return + } + let baseYear = baseParts[0] + let baseMonth = baseParts[1] + let nextYear = baseMonth == 12 ? baseYear + 1 : baseYear + let nextMonth = baseMonth == 12 ? 1 : baseMonth + 1 + let nextValue = "\(nextYear)-\(nextMonth)" + + // Probe-only driver metric. See class doc-comment. + measureActionLatency(repetitions: 5) { + nextButton.tap() + awaitPerfMarker(slug: "home", key: "calendar-month", value: nextValue) + prevButton.tap() + awaitPerfMarker(slug: "home", key: "calendar-month", value: baseValue) + } + + let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") + XCTAssertGreaterThan( + rebuildProxy, + 0, + "home.view.rebuild.proxy counter never incremented (proxy signal, not exact body count)" + ) + print("[perf-probe-counters] home.view.rebuild.proxy=\(rebuildProxy)") + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift new file mode 100644 index 00000000..d8d1fcd7 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift @@ -0,0 +1,22 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleScrollTests: XCTestCase { + func testScrollFiftyCells() { + let app = XCUIApplication.launchForPerf(seed: "scroll-50") + waitForFeatureReady("home", timeout: 30) + + let feed = app.descendants(matching: .any)["feature.home.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 5), "feature.home.feed not found") + + measure(metrics: [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + for _ in 0..<5 { + feed.swipeUp() + } + } + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleSmokeTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleSmokeTests.swift new file mode 100644 index 00000000..590c5fa5 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home") + } +} diff --git a/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift b/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift index 2b91479d..cc32e831 100644 --- a/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift @@ -35,24 +35,55 @@ public struct EditGoalListReducer { /// let state = EditGoalListReducer.State() /// ``` public struct State: Equatable { - - public var calendarDate: TXCalendarDate - public var calendarWeeks: [[TXCalendarDateItem]] - public var editableGoals: [EditableGoal]? - public var cards: [GoalEditCardItem]? + public struct Data: Equatable { + public var calendarDate: TXCalendarDate + public var calendarWeeks: [[TXCalendarDateItem]] + public var editableGoals: [EditableGoal]? + public var cards: [GoalEditCardItem]? + public var selectedCardMenu: GoalEditCardItem? + public var pendingGoalId: Int64? + public var pendingAction: PendingAction? + + public init(calendarDate: TXCalendarDate) { + self.calendarDate = calendarDate + self.calendarWeeks = TXCalendarDataGenerator.generateWeekData(for: calendarDate) + } + } + + public struct UIState: Equatable { + public var isLoading: Bool = true + public var isFetchFailed: Bool = false + public init() { } + } + + public struct Presentation: Equatable { + public var modal: TXModalStyle? + public var toast: TXToastType? + public init() { } + } + + public var data: Data + public var ui = UIState() + public var presentation = Presentation() + + public var calendarDate: TXCalendarDate { get { data.calendarDate } set { data.calendarDate = newValue } } + public var calendarWeeks: [[TXCalendarDateItem]] { get { data.calendarWeeks } set { data.calendarWeeks = newValue } } + public var editableGoals: [EditableGoal]? { get { data.editableGoals } set { data.editableGoals = newValue } } + public var cards: [GoalEditCardItem]? { get { data.cards } set { data.cards = newValue } } + public var selectedCardMenu: GoalEditCardItem? { get { data.selectedCardMenu } set { data.selectedCardMenu = newValue } } + public var modal: TXModalStyle? { get { presentation.modal } set { presentation.modal = newValue } } + public var toast: TXToastType? { get { presentation.toast } set { presentation.toast = newValue } } + public var isLoading: Bool { get { ui.isLoading } set { ui.isLoading = newValue } } + public var isFetchFailed: Bool { get { ui.isFetchFailed } set { ui.isFetchFailed = newValue } } + public var pendingGoalId: Int64? { get { data.pendingGoalId } set { data.pendingGoalId = newValue } } + public var pendingAction: PendingAction? { get { data.pendingAction } set { data.pendingAction = newValue } } public var hasCards: Bool { !(cards?.isEmpty ?? true) } - public var selectedCardMenu: GoalEditCardItem? - public var modal: TXModalStyle? - public var toast: TXToastType? - public var isLoading: Bool = true - public var pendingGoalId: Int64? - public var pendingAction: PendingAction? public enum PendingAction: Equatable { case delete case complete } - + /// 기본 상태를 생성합니다. /// /// ## 사용 예시 @@ -60,46 +91,61 @@ public struct EditGoalListReducer { /// let state = EditGoalListReducer.State() /// ``` public init(calendarDate: TXCalendarDate) { - self.calendarDate = calendarDate - self.calendarWeeks = TXCalendarDataGenerator.generateWeekData(for: calendarDate) + self.data = Data(calendarDate: calendarDate) } } /// 목표 편집 화면에서 발생 가능한 이벤트입니다. public enum Action: BindableAction { case binding(BindingAction) - - // MARK: - LifeCycle - case onAppear - case onDisappear - - // MARK: - User Action - case calendarDateSelected(TXCalendarDateItem) - case weekCalendarSwipe(TXCalendar.SwipeGesture) - case navigationBackButtonTapped - case cardMenuButtonTapped(GoalEditCardItem) - case cardMenuItemSelected(GoalDropList) - case backgroundTapped - case modalConfirmTapped - case toastButtonTapped - - // MARK: - Update State - case setCalendarDate(TXCalendarDate) - case fetchGoals - case fetchGoalsCompleted([EditableGoal], date: TXCalendarDate) - case deleteGoalCompleted(goalId: Int64) - case completeGoalCompleted(goalId: Int64) - case apiError(String) - case showToast(TXToastType) + + // MARK: - View + public enum View: Equatable { + case onAppear + case onDisappear + case calendarDateSelected(TXCalendarDateItem) + case weekCalendarSwipe(TXCalendar.SwipeGesture) + case navigationBackButtonTapped + case cardMenuButtonTapped(GoalEditCardItem) + case cardMenuItemSelected(GoalDropList) + case backgroundTapped + case modalConfirmTapped + case toastButtonTapped + case dataRetryTapped + } + + // MARK: - Internal + public enum Internal: Equatable { + case setCalendarDate(TXCalendarDate) + case fetchGoals + } + + // MARK: - Response + public enum Response: Equatable { + case fetchGoalsCompleted([EditableGoal], date: TXCalendarDate) + case deleteGoalCompleted(goalId: Int64) + case completeGoalCompleted(goalId: Int64) + case apiError(String) + } + + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(TXToastType) + } // MARK: - Delegate case delegate(Delegate) - + public enum Delegate { case navigateBack case goToGoalEdit(EditableGoal) case goToCompletedStats } + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입한 Reduce로 EditGoalListReducer를 구성합니다. diff --git a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift index fbd0281a..dc0adf3f 100644 --- a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift @@ -37,28 +37,129 @@ public struct HomeReducer { /// let state = HomeReducer.State() /// ``` public struct State: Equatable { - public var items: [HomeGoalItem] = [] - public var isLoading: Bool = true - public var mainTitle: String = "KEEPILUV" - public var calendarMonthTitle: String = "" - public var calendarWeeks: [[TXCalendarDateItem]] = [] - public var calendarDate: TXCalendarDate = .init() - public var calendarSheetDate: TXCalendarDate = .init() - public var goalsCache: [String: [HomeGoalItem]] = [:] - public var isRefreshHidden: Bool = true - public var isCalendarSheetPresented: Bool = false - public var pendingDeleteGoalID: Int64? - public var pendingDeletePhotologID: Int64? + public struct Data: Equatable { + public var items: [HomeGoalItem] = [] + public var calendarWeeks: [[TXCalendarDateItem]] = [] + public var calendarDate: TXCalendarDate = .init() + public var calendarSheetDate: TXCalendarDate = .init() + public var goalsCache: [String: [HomeGoalItem]] = [:] + public var pendingDeleteGoalID: Int64? + public var pendingDeletePhotologID: Int64? + public var hadFirstGoal: Bool? + + public init() { } + } + + public struct UIState: Equatable { + public var isLoading: Bool = true + public var isFetchFailed: Bool = false + public var mainTitle: String = "KEEPILUV" + public var calendarMonthTitle: String = "" + public var isRefreshHidden: Bool = true + public var hasUnreadNotification: Bool = false + + public init() { } + } + + public struct Presentation: Equatable { + public var toast: TXToastType? + public var modal: TXModalStyle? + public var isCalendarSheetPresented: Bool = false + public var isProofPhotoPresented: Bool = false + public var isAddGoalPresented: Bool = false + public var isCameraPermissionAlertPresented: Bool = false + + public init() { } + } + + public var data = Data() + public var ui = UIState() + public var presentation = Presentation() + public var proofPhoto: ProofPhotoReducer.State? + + public var items: [HomeGoalItem] { + get { data.items } + set { data.items = newValue } + } + public var isLoading: Bool { + get { ui.isLoading } + set { ui.isLoading = newValue } + } + public var isFetchFailed: Bool { + get { ui.isFetchFailed } + set { ui.isFetchFailed = newValue } + } + public var mainTitle: String { + get { ui.mainTitle } + set { ui.mainTitle = newValue } + } + public var calendarMonthTitle: String { + get { ui.calendarMonthTitle } + set { ui.calendarMonthTitle = newValue } + } + public var calendarWeeks: [[TXCalendarDateItem]] { + get { data.calendarWeeks } + set { data.calendarWeeks = newValue } + } + public var calendarDate: TXCalendarDate { + get { data.calendarDate } + set { data.calendarDate = newValue } + } + public var calendarSheetDate: TXCalendarDate { + get { data.calendarSheetDate } + set { data.calendarSheetDate = newValue } + } + public var goalsCache: [String: [HomeGoalItem]] { + get { data.goalsCache } + set { data.goalsCache = newValue } + } + public var isRefreshHidden: Bool { + get { ui.isRefreshHidden } + set { ui.isRefreshHidden = newValue } + } + public var isCalendarSheetPresented: Bool { + get { presentation.isCalendarSheetPresented } + set { presentation.isCalendarSheetPresented = newValue } + } + public var pendingDeleteGoalID: Int64? { + get { data.pendingDeleteGoalID } + set { data.pendingDeleteGoalID = newValue } + } + public var pendingDeletePhotologID: Int64? { + get { data.pendingDeletePhotologID } + set { data.pendingDeletePhotologID = newValue } + } + public var toast: TXToastType? { + get { presentation.toast } + set { presentation.toast = newValue } + } + public var modal: TXModalStyle? { + get { presentation.modal } + set { presentation.modal = newValue } + } + public var isProofPhotoPresented: Bool { + get { presentation.isProofPhotoPresented } + set { presentation.isProofPhotoPresented = newValue } + } + public var isAddGoalPresented: Bool { + get { presentation.isAddGoalPresented } + set { presentation.isAddGoalPresented = newValue } + } + public var isCameraPermissionAlertPresented: Bool { + get { presentation.isCameraPermissionAlertPresented } + set { presentation.isCameraPermissionAlertPresented = newValue } + } + public var hasUnreadNotification: Bool { + get { ui.hasUnreadNotification } + set { ui.hasUnreadNotification = newValue } + } + public var hadFirstGoal: Bool? { + get { data.hadFirstGoal } + set { data.hadFirstGoal = newValue } + } public var hasCards: Bool { !items.isEmpty } public var isEmptyVisible: Bool { !isLoading && items.isEmpty } public var nowDate: CalendarNow { CalendarNow() } - public var toast: TXToastType? - public var modal: TXModalStyle? - public var isProofPhotoPresented: Bool = false - public var isAddGoalPresented: Bool = false - public var isCameraPermissionAlertPresented: Bool = false - public var hasUnreadNotification: Bool = false - public var hadFirstGoal: Bool? public var goalSectionTitle: String { let now = CalendarNow() let today = TXCalendarDate(year: now.year, month: now.month, day: now.day) @@ -70,8 +171,6 @@ public struct HomeReducer { } return "오늘 우리 목표" } - - public var proofPhoto: ProofPhotoReducer.State? /// 기본 상태를 생성합니다. /// @@ -79,66 +178,86 @@ public struct HomeReducer { /// ```swift /// let state = HomeReducer.State() /// ``` - public init() { - } + public init() { } } /// 홈 화면에서 발생 가능한 모든 이벤트를 정의합니다. /// /// ## 사용 예시 /// ```swift - /// store.send(.onAppear) + /// store.send(.view(.onAppear)) /// ``` public enum Action: BindableAction { case binding(BindingAction) - + // MARK: - Child Action case proofPhoto(ProofPhotoReducer.Action) - - // MARK: - LifeCycle - case onAppear - - // MARK: - User Action - case calendarDateSelected(TXCalendarDateItem) - case weekCalendarSwipe(TXCalendar.SwipeGesture) - case navigationBarAction(TXNavigationBar.Action) - case monthCalendarConfirmTapped - case goalCheckButtonTapped(id: Int64, isChecked: Bool) - case modalConfirmTapped - case yourCardTapped(GoalCardItem) - case myCardTapped(GoalCardItem) - case headerTapped(GoalCardItem) - case floatingButtonTapped - case editButtonTapped - - // MARK: - Update State - case fetchGoals - case fetchGoalsCompleted(GoalList, date: TXCalendarDate) - case setCalendarDate(TXCalendarDate) - case setCalendarSheetPresented(Bool) - case showToast(TXToastType) - case setPokeButtonDisabled(goalId: Int64, Bool, date: TXCalendarDate) - case authorizationCompleted(id: Int64, isAuthorized: Bool) - case proofPhotoDismissed - case addGoalButtonTapped(GoalCategory) - case cameraPermissionAlertDismissed - case fetchGoalsFailed - case deletePhotoLogCompleted(goalId: Int64) - case deletePhotoLogFailed - case fetchUnreadResponse(Bool) + + // MARK: - View + public enum View: Equatable { + case onAppear + case refreshPulled + case calendarDateSelected(TXCalendarDateItem) + case weekCalendarSwipe(TXCalendar.SwipeGesture) + case navigationBarAction(TXNavigationBar.Action) + case monthCalendarConfirmTapped + case goalCheckButtonTapped(id: Int64, isChecked: Bool) + case modalConfirmTapped + case yourCardTapped(GoalCardItem) + case myCardTapped(GoalCardItem) + case headerTapped(GoalCardItem) + case floatingButtonTapped + case editButtonTapped + case proofPhotoDismissed + case addGoalButtonTapped(GoalCategory) + case cameraPermissionAlertDismissed + case dataRetryTapped + case perfToastShowTapped + case perfToastDismissTapped + case perfCalendarNextTapped + case perfCalendarPreviousTapped + } + + // MARK: - Internal + public enum Internal: Equatable { + case fetchGoals + case setCalendarDate(TXCalendarDate) + case setCalendarSheetPresented(Bool) + case setPokeButtonDisabled(goalId: Int64, Bool, date: TXCalendarDate) + } + + // MARK: - Response + public enum Response { + case fetchGoalsCompleted(GoalList, date: TXCalendarDate) + case fetchGoalsFailed(date: TXCalendarDate) + case authorizationCompleted(id: Int64, isAuthorized: Bool) + case deletePhotoLogCompleted(goalId: Int64) + case deletePhotoLogFailed + case fetchUnreadResponse(Bool) + } + + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(TXToastType) + } // MARK: - Delegate case delegate(Delegate) - + /// 홈 화면에서 외부로 전달하는 이벤트입니다. public enum Delegate { case goToGoalDetail(id: Int64, owner: GoalDetail.Owner, verificationDate: String) - case goToStatsDetail(id: Int64) + case goToStatsDetail(id: Int64, calendarDate: TXCalendarDate) case goToMakeGoal(GoalCategory) case goToEditGoalList(date: TXCalendarDate) case goToSettings case goToNotification } + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입한 Reduce로 HomeReducer를 구성합니다. diff --git a/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift b/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift index f99b8896..04ec0051 100644 --- a/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift +++ b/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift @@ -66,7 +66,7 @@ public struct HomeCoordinator { /// /// ## 사용 예시 /// ```swift - /// store.send(.home(.onAppear)) + /// store.send(.home(.view(.onAppear))) /// ``` public enum Action: BindableAction { case binding(BindingAction) diff --git a/Projects/Feature/Home/Project.swift b/Projects/Feature/Home/Project.swift index ecb166ef..cb3c033f 100644 --- a/Projects/Feature/Home/Project.swift +++ b/Projects/Feature/Home/Project.swift @@ -41,6 +41,7 @@ let project = Project.makeModule( .feature(interface: .home), .core(interface: .analytics), .shared(implements: .designSystem), + .shared(implements: .perfTestingSupport), .shared(implements: .util), .external(dependency: .ComposableArchitecture) ] @@ -69,11 +70,20 @@ let project = Project.makeModule( .feature(interface: .common), .feature(implements: .home), .feature(interface: .home), + .feature(implements: .goalDetail), + .feature(implements: .makeGoal), + .feature(implements: .notification), + .feature(implements: .proofPhoto), + .feature(implements: .settings), + .feature(implements: .stats), + .core(implements: .captureSession), .domain(interface: .goal), + .domain(interface: .notification), .shared(implements: .designSystem), .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .home) ] ) diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift index 36cf2b46..786a8242 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift @@ -30,52 +30,52 @@ extension EditGoalListReducer { let reducer = Reduce { state, action in switch action { // MARK: - LifeCycle - case .onAppear: - return .send(.fetchGoals) + case .view(.onAppear): + return .send(.internal(.fetchGoals)) - case .onDisappear: + case .view(.onDisappear): state.selectedCardMenu = nil return .none // MARK: - User Action - case let .calendarDateSelected(item): + case let .view(.calendarDateSelected(item)): guard let components = item.dateComponents, let year = components.year, let month = components.month, let day = components.day else { return .none } - return .send(.setCalendarDate(.init(year: year, month: month, day: day))) + return .send(.internal(.setCalendarDate(.init(year: year, month: month, day: day)))) - case let .weekCalendarSwipe(swipe): + case let .view(.weekCalendarSwipe(swipe)): switch swipe { case .next: - guard let nextWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let nextWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: 1 ) else { return .none } - return .send(.setCalendarDate(nextWeekDate)) + return .send(.internal(.setCalendarDate(nextWeekDate))) case .previous: - guard let previousWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let previousWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: -1 ) else { return .none } - return .send(.setCalendarDate(previousWeekDate)) + return .send(.internal(.setCalendarDate(previousWeekDate))) } - case .navigationBackButtonTapped: + case .view(.navigationBackButtonTapped): return .send(.delegate(.navigateBack)) - case let .cardMenuButtonTapped(card): + case let .view(.cardMenuButtonTapped(card)): state.selectedCardMenu = state.selectedCardMenu == card ? nil : card return .none - case let .cardMenuItemSelected(item): + case let .view(.cardMenuItemSelected(item)): guard let card = state.selectedCardMenu else { return .none } switch item { @@ -88,7 +88,7 @@ extension EditGoalListReducer { state.toast = .warning(message: "이미 완료한 목표입니다!") } else { guard let editableGoal = state.editableGoals?.first(where: { $0.id == card.id }) else { - return .send(.apiError("목표 수정에 실패했어요")) + return .send(.response(.apiError("목표 수정에 실패했어요"))) } return .send(.delegate(.goToGoalEdit(editableGoal))) } @@ -119,11 +119,11 @@ extension EditGoalListReducer { state.selectedCardMenu = nil return .none - case .backgroundTapped: + case .view(.backgroundTapped): state.selectedCardMenu = nil return .none - case .modalConfirmTapped: + case .view(.modalConfirmTapped): guard !state.isLoading, let goalId = state.pendingGoalId, let pendingAction = state.pendingAction else { @@ -138,9 +138,9 @@ extension EditGoalListReducer { return .run { send in do { _ = try await goalClient.completeGoal(goalId) - await send(.completeGoalCompleted(goalId: goalId)) + await send(.response(.completeGoalCompleted(goalId: goalId))) } catch { - await send(.apiError("이미 끝났습니다.")) + await send(.response(.apiError("이미 끝났습니다."))) } } @@ -148,28 +148,32 @@ extension EditGoalListReducer { return .run { send in do { try await goalClient.deleteGoal(goalId) - await send(.deleteGoalCompleted(goalId: goalId)) + await send(.response(.deleteGoalCompleted(goalId: goalId))) } catch { - await send(.apiError("목표 삭제에 실패했어요")) + await send(.response(.apiError("목표 삭제에 실패했어요"))) } } } - case .toastButtonTapped: + case .view(.toastButtonTapped): return .send(.delegate(.goToCompletedStats)) + + case .view(.dataRetryTapped): + return .send(.internal(.fetchGoals)) // MARK: - Update State - case let .setCalendarDate(date): + case let .internal(.setCalendarDate(date)): if date == state.calendarDate { return .none } state.calendarDate = date state.calendarWeeks = TXCalendarDataGenerator.generateWeekData(for: date) state.isLoading = true - return .send(.fetchGoals) + return .send(.internal(.fetchGoals)) - case .fetchGoals: + case .internal(.fetchGoals): state.isLoading = true + state.isFetchFailed = false let date = state.calendarDate return .run { send in do { @@ -190,17 +194,18 @@ extension EditGoalListReducer { endDate: goal.endDate ) } - await send(.fetchGoalsCompleted(goals, date: date)) + await send(.response(.fetchGoalsCompleted(goals, date: date))) } catch { - await send(.apiError("목표 조회에 실패했어요")) + await send(.response(.apiError("목표 조회에 실패했어요"))) } } - case let .fetchGoalsCompleted(goals, date): + case let .response(.fetchGoalsCompleted(goals, date)): if date != state.calendarDate { return .none } state.isLoading = false + state.isFetchFailed = false state.editableGoals = goals let items = goals.map { GoalEditCardItem( @@ -218,29 +223,34 @@ extension EditGoalListReducer { } return .none - case let .deleteGoalCompleted(goalId): + case let .response(.deleteGoalCompleted(goalId)): state.isLoading = false state.pendingGoalId = nil state.pendingAction = nil state.editableGoals?.removeAll { $0.id == goalId } state.cards?.removeAll { $0.id == goalId } - return .send(.showToast(.delete(message: "목표가 삭제되었어요"))) + return .send(.presentation(.showToast(.delete(message: "목표가 삭제되었어요")))) - case let .completeGoalCompleted(goalId): + case let .response(.completeGoalCompleted(goalId)): state.isLoading = false state.pendingGoalId = nil state.pendingAction = nil state.editableGoals?.removeAll { $0.id == goalId } state.cards?.removeAll { $0.id == goalId } - return .send(.showToast(.success(message: "목표를 이뤘어요", buttonText: "보러가기"))) + return .send(.presentation(.showToast(.success(message: "목표를 이뤘어요", buttonText: "보러가기")))) - case let .apiError(message): + case let .response(.apiError(message)): state.isLoading = false state.pendingGoalId = nil state.pendingAction = nil - return .send(.showToast(.warning(message: message))) + + if state.cards == nil { + state.isFetchFailed = true + return .none + } + return .send(.presentation(.showToast(.warning(message: message)))) - case let .showToast(toast): + case let .presentation(.showToast(toast)): state.toast = toast return .none diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift index c819aa08..c14ab15f 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift @@ -21,30 +21,34 @@ struct EditGoalListView: View { navigationBar weekCalendar .padding(.top, 4) - if let cards = store.cards, !cards.isEmpty { - cardScrollView - .padding(.bottom, 1) + + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else if let cards = store.cards { + if cards.isEmpty { + emptyContent + } else { + cardScrollView + .padding(.bottom, 1) + } + } else { + Spacer() } - - Spacer() } .ignoresSafeArea(.container, edges: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay { - if let cards = store.cards, cards.isEmpty { - emptyContent - } - } .toolbar(.hidden, for: .navigationBar) .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .onDisappear { - store.send(.onDisappear) + store.send(.view(.onDisappear)) } .onTapGesture { guard store.selectedCardMenu != nil else { return } - store.send(.backgroundTapped) + store.send(.view(.backgroundTapped)) } .transaction { transaction in transaction.animation = nil @@ -53,12 +57,12 @@ struct EditGoalListView: View { item: $store.modal, onAction: { action in if action == .confirm { - store.send(.modalConfirmTapped) + store.send(.view(.modalConfirmTapped)) } } ) .txToast(item: $store.toast, onButtonTap: { - store.send(.toastButtonTapped) + store.send(.view(.toastButtonTapped)) }) .txLoading(isPresented: store.isLoading) } @@ -67,7 +71,7 @@ struct EditGoalListView: View { private extension EditGoalListView { var navigationBar: some View { TXNavigationBar(style: .subTitle(title: "편집", type: .back)) { _ in - store.send(.navigationBackButtonTapped) + store.send(.view(.navigationBackButtonTapped)) } } @@ -77,10 +81,10 @@ private extension EditGoalListView { currentDate: $store.calendarDate, weeks: store.calendarWeeks, onSelect: { item in - store.send(.calendarDateSelected(item)) + store.send(.view(.calendarDateSelected(item))) }, onSwipe: { swipe in - store.send(.weekCalendarSwipe(swipe)) + store.send(.view(.weekCalendarSwipe(swipe))) } ) .frame(maxWidth: .infinity, maxHeight: 76) @@ -100,7 +104,7 @@ private extension EditGoalListView { endDate: card.endDate ), onMenuTap: { - store.send(.cardMenuButtonTapped(card)) + store.send(.view(.cardMenuButtonTapped(card))) } ) .overlay(alignment: .topTrailing) { @@ -117,7 +121,7 @@ private extension EditGoalListView { var dropdown: some View { TXDropdown(items: GoalDropList.allCases) { action in - store.send(.cardMenuItemSelected(action)) + store.send(.view(.cardMenuItemSelected(action))) } .offset(x: -16, y: 48) } diff --git a/Projects/Feature/Home/Sources/Home/HomeCalendarSection.swift b/Projects/Feature/Home/Sources/Home/HomeCalendarSection.swift new file mode 100644 index 00000000..9bc11de7 --- /dev/null +++ b/Projects/Feature/Home/Sources/Home/HomeCalendarSection.swift @@ -0,0 +1,49 @@ +// +// HomeCalendarSection.swift +// FeatureHome +// + +import SwiftUI + +import ComposableArchitecture +import FeatureHomeInterface +import SharedDesignSystem +import SharedPerfTestingSupport + +/// `$calendarDate` binding과 `calendarWeeks`를 읽습니다. +/// 캘린더 월 marker도 이 하위 뷰에 두어 parent body read-set으로 새지 않게 합니다. +struct HomeCalendarSection: View { + @Bindable var store: StoreOf + + var body: some View { + let calendarView = TXCalendar( + mode: .weekly, + currentDate: $store.calendarDate, + weeks: store.calendarWeeks, + config: .init( + dateStyle: .init(lastDateTextColor: Color.Gray.gray500) + ), + onSelect: { item in + store.send(.view(.calendarDateSelected(item))) + }, + onSwipe: { swipe in + store.send(.view(.weekCalendarSwipe(swipe))) + } + ) + .frame(maxWidth: .infinity, maxHeight: 76) + .perfControl(slug: "home", element: "calendar") + .transaction { transaction in + transaction.animation = nil + } + + if UITestMode.isProbeScenario { + calendarView.perfStateMarker( + slug: "home", + key: "calendar-month", + value: "\(store.calendarDate.year)-\(store.calendarDate.month)" + ) + } else { + calendarView + } + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomeContentSection.swift b/Projects/Feature/Home/Sources/Home/HomeContentSection.swift new file mode 100644 index 00000000..35317069 --- /dev/null +++ b/Projects/Feature/Home/Sources/Home/HomeContentSection.swift @@ -0,0 +1,73 @@ +// +// HomeContentSection.swift +// FeatureHome +// + +import SwiftUI + +import ComposableArchitecture +import FeatureHomeInterface +import SharedDesignSystem +import SharedPerfTestingSupport + +/// `items`, `goalSectionTitle`을 읽는 홈 콘텐츠 영역입니다. +/// 50/200개 셀 `LazyVStack`을 소유하며, presentation flag 변경은 `HomePresentationLayer`에서 처리해 +/// 카드 리스트 read-set을 오염시키지 않습니다. +struct HomeContentSection: View { + let store: StoreOf + + var body: some View { + #if PERF_TESTING + if UITestMode.isEnabled, UITestMode.isSwiftUISelfRunFeedScroll { + HomeSelfRunFeedScrollHarness(store: store) { + scrollContent + } + } else { + scrollContent + } + #else + scrollContent + #endif + } + + private var scrollContent: some View { + ScrollView { + Group { + HomeHeaderRow(store: store) + cardList + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 103) + } + .refreshable { + store.send(.view(.refreshPulled)) + } + } + + var cardList: some View { + LazyVStack(spacing: 16) { + ForEach(store.items) { item in + goalCard(for: item) + .perfCell(slug: "home", stableId: item.id) + } + } + .padding(.top, 12) + .perfFeed("home") + } + + func goalCard(for item: HomeGoalItem) -> some View { + GoalCardView( + item: item.card, + onHeaderTapped: { store.send(.view(.headerTapped(item.card))) }, + onCheckButtonTapped: { + store.send(.view(.goalCheckButtonTapped( + id: item.id, + isChecked: item.card.myCard.isSelected + ))) + }, + actionLeft: { store.send(.view(.myCardTapped(item.card))) }, + actionRight: { store.send(.view(.yourCardTapped(item.card))) } + ) + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift b/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift new file mode 100644 index 00000000..e056ef9b --- /dev/null +++ b/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift @@ -0,0 +1,82 @@ +// +// HomeEmptyContentSection.swift +// FeatureHome +// + +import SwiftUI + +import ComposableArchitecture +import FeatureHomeInterface +import SharedDesignSystem + +/// `hadFirstGoal`을 읽는 빈 상태 영역입니다. +/// `goalEmptyView`의 center anchor를 기기 화면 기준 y축 중앙에 배치합니다. +struct HomeEmptyContentSection: View { + let store: StoreOf + + var body: some View { + VStack(spacing: 0) { + GeometryReader { geo in + let frame = geo.frame(in: .global) + let deviceHeight = UIScreen.main.bounds.height + let deviceCenterYInSection = max(0, deviceHeight / 2 - frame.minY) + + ScrollView { + goalEmptyView + .frame(width: geo.size.width) + .position(x: geo.size.width / 2, y: deviceCenterYInSection) + } + .scrollIndicators(.hidden) + .refreshable { + store.send(.view(.refreshPulled)) + } + .overlay(alignment: .bottomTrailing) { + if store.hadFirstGoal == false { + emptyArrow + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + } + } + + @ViewBuilder + var goalEmptyView: some View { + Group { + if store.hadFirstGoal == true { + VStack(spacing: 8) { + Image.Illustration.scare + .resizable() + .frame(width: 164, height: 164) + + Text("이 날은 목표가 없어요!") + .typography(.t2_16b) + .foregroundStyle(Color.Gray.gray400) + } + } else if store.hadFirstGoal == false { + VStack(spacing: 0) { + Image.Illustration.emptyPoke + .frame(height: 116) + + Text("첫 목표를 세워볼까요?") + .typography(.t2_16b) + .foregroundStyle(Color.Gray.gray400) + .padding(.top, 16) + + Text("+ 버튼을 눌러 목표를 추가해보세요") + .typography(.c1_12r) + .foregroundStyle(Color.Gray.gray300) + .padding(.top, 4) + } + } + } + } + + var emptyArrow: some View { + Image.Illustration.arrow + .padding(.bottom, 71 + TXTabBarLayout.height) + .padding(.trailing, 86) + .ignoresSafeArea() + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomeHeaderRow.swift b/Projects/Feature/Home/Sources/Home/HomeHeaderRow.swift new file mode 100644 index 00000000..c4dcf8b2 --- /dev/null +++ b/Projects/Feature/Home/Sources/Home/HomeHeaderRow.swift @@ -0,0 +1,34 @@ +// +// HomeHeaderRow.swift +// FeatureHome +// + +import SwiftUI + +import ComposableArchitecture +import FeatureHomeInterface +import SharedDesignSystem + +/// `goalSectionTitle` 재계산이 콘텐츠 전체나 카드 리스트 대신 작은 Text row만 +/// invalidate하도록 분리한 영역입니다. +struct HomeHeaderRow: View { + let store: StoreOf + + var body: some View { + HStack(spacing: 0) { + Text(store.goalSectionTitle) + .typography(.b1_14b) + + Spacer() + + Button { + store.send(.view(.editButtonTapped)) + } label: { + Text("편집") + .typography(.b1_14b) + .foregroundStyle(Color.Gray.gray500) + } + } + .frame(height: 24) + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomeNavigationBarSection.swift b/Projects/Feature/Home/Sources/Home/HomeNavigationBarSection.swift new file mode 100644 index 00000000..90beaa6f --- /dev/null +++ b/Projects/Feature/Home/Sources/Home/HomeNavigationBarSection.swift @@ -0,0 +1,32 @@ +// +// HomeNavigationBarSection.swift +// FeatureHome +// + +import SwiftUI + +import ComposableArchitecture +import FeatureHomeInterface +import SharedDesignSystem + +/// `mainTitle`, `calendarMonthTitle`, `isRefreshHidden`, `hasUnreadNotification`을 읽습니다. +/// 콘텐츠나 presentation 상태 변경이 내비게이션 바를 다시 그리지 않도록 +/// read-set을 분리합니다. +struct HomeNavigationBarSection: View { + let store: StoreOf + + var body: some View { + TXNavigationBar( + style: .home( + .init( + subTitle: store.calendarMonthTitle, + mainTitle: store.mainTitle, + isHiddenRefresh: store.isRefreshHidden, + isRemainedAlarm: store.hasUnreadNotification + ) + ), onAction: { action in + store.send(.view(.navigationBarAction(action))) + } + ) + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomePerfSupport.swift b/Projects/Feature/Home/Sources/Home/HomePerfSupport.swift new file mode 100644 index 00000000..2a00f00c --- /dev/null +++ b/Projects/Feature/Home/Sources/Home/HomePerfSupport.swift @@ -0,0 +1,155 @@ +// +// HomePerfSupport.swift +// FeatureHome +// + +import SwiftUI + +import ComposableArchitecture +import FeatureHomeInterface +import SharedDesignSystem +import SharedPerfTestingSupport + +#if PERF_TESTING +/// Pass 4-S2 Home feed self-run scroll 전용 harness입니다. +/// Production content section에서 ScrollViewReader / Task 상태를 분리합니다. +struct HomeSelfRunFeedScrollHarness: View { + let store: StoreOf + private let content: Content + + /// Pass 4-S2: 초기 layout settling 중 body가 여러 번 invalidation되어도 self-run scroll Task가 + /// scene appearance당 한 번만 실행되도록 보호합니다. + @State private var selfRunScrollStarted: Bool = false + /// Pass 4-S2: scrollTo sequence가 끝나면 `"true"`로 바뀌며, trace 분석에서 post-scroll window를 + /// 분리할 수 있도록 `perfStateMarker`로 노출합니다. + @State private var selfRunScrollDone: String = "false" + + init( + store: StoreOf, + @ViewBuilder content: () -> Content + ) { + self.store = store + self.content = content() + } + + var body: some View { + ScrollViewReader { proxy in + content + .perfStateMarker( + slug: "home", + key: "swiftui-selfrun-scroll", + value: selfRunScrollDone + ) + .onAppear { startSelfRunScrollIfNeeded(proxy: proxy) } + } + } + + private func startSelfRunScrollIfNeeded(proxy: ScrollViewProxy) { + guard !selfRunScrollStarted else { return } + selfRunScrollStarted = true + let allIds = store.items.map(\.id) + let stridedTargets = stride(from: 5, to: allIds.count, by: 5) + .compactMap { allIds.indices.contains($0) ? allIds[$0] : nil } + let preRollNanos: UInt64 = 1_000_000_000 + let stepIntervalNanos: UInt64 = 300_000_000 + Task { @MainActor in + try? await Task.sleep(nanoseconds: preRollNanos) + for id in stridedTargets { + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(id, anchor: .top) + } + try? await Task.sleep(nanoseconds: stepIntervalNanos) + } + selfRunScrollDone = "true" + } + } +} +#endif + +/// Pass 3 probe scenario(toast / calendar month toggle)에서만 쓰는 PERF 전용 control입니다. +/// `store.calendarDate` 읽기를 별도 sub-view에 가둬 probe scenario에서도 parent `HomeView.body` +/// read-set을 오염시키지 않게 합니다. +/// +/// Production layout에서는 `UITestMode.isProbeScenario`가 false라 이 branch가 진입되지 않습니다. +/// 이 harness는 `-UITEST_PROBE_SCENARIO` launch argument가 있을 때만 노출되며, authoritative rendering +/// scenario(`-UITEST_RENDERING_SCENARIO`)와 섞으면 안 됩니다. +struct HomePerfActionHarness: View { + let store: StoreOf + + var body: some View { + HStack(spacing: 0) { + Button { + store.send(.view(.perfToastShowTapped)) + } label: { + Text(verbatim: "T") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.toast-show") + + Button { + store.send(.view(.perfToastDismissTapped)) + } label: { + Text(verbatim: "X") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.toast-dismiss") + + Button { + store.send(.view(.perfCalendarNextTapped)) + } label: { + Text(verbatim: "▶") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.calendar-next") + + Button { + store.send(.view(.perfCalendarPreviousTapped)) + } label: { + Text(verbatim: "◀") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.calendar-prev") + } + .opacity(0.05) + } +} + +/// Pass 3 probe scenario에서 `toast` state-change marker를 노출하는 PERF 전용 modifier입니다. +/// Production에서는 `content`를 그대로 반환해 `HomeView` read-set에 `toast`가 +/// 포함되지 않게 합니다. +struct PerfToastPresentationHarness: ViewModifier { + @Binding var toast: TXToastType? + + func body(content: Content) -> some View { + if UITestMode.isProbeScenario { + content + .overlay(alignment: .bottom) { + if toast != nil { + Color.clear.frame(width: 1, height: 1) + } + } + .perfStateMarker( + slug: "home", + key: "toast", + value: toast == nil ? "hidden" : "visible" + ) + } else { + content + } + } +} + +/// Probe scenario에서만 `perfCounterMarkers` accessibility overlay를 붙입니다. +/// Rendering / smoke launch에서는 marker overlay가 붙지 않습니다. +struct PerfHomeCounterMarkersHarness: ViewModifier { + func body(content: Content) -> some View { + if UITestMode.isProbeScenario { + content.perfCounterMarkers( + slug: "home", + keys: ["home.view.rebuild.proxy"] + ) + } else { + content + } + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomePresentationLayer.swift b/Projects/Feature/Home/Sources/Home/HomePresentationLayer.swift new file mode 100644 index 00000000..c5861180 --- /dev/null +++ b/Projects/Feature/Home/Sources/Home/HomePresentationLayer.swift @@ -0,0 +1,67 @@ +// +// HomePresentationLayer.swift +// FeatureHome +// + +import SwiftUI + +import ComposableArchitecture +import FeatureHomeInterface +import FeatureProofPhotoInterface +import SharedDesignSystem + +/// 모든 presentation modifier(bottom sheet, modal, fullScreenCover, alert)를 소유합니다. +/// Presentation binding read-set을 이 modifier body로 모아, presentation flag 변경이 +/// `HomeContentSection`이나 `HomeNavigationBarSection`을 invalidate하지 않게 합니다. +struct HomePresentationLayer: ViewModifier { + @Bindable var store: StoreOf + @Dependency(\.proofPhotoFactory) var proofPhotoFactory + + func body(content: Content) -> some View { + content + .txBottomSheet( + isPresented: $store.presentation.isAddGoalPresented, + showDragIndicator: true, + sheetContent: { + AddGoalListView { category in + store.send(.view(.addGoalButtonTapped(category))) + } + } + ) + .txBottomSheet( + isPresented: $store.presentation.isCalendarSheetPresented, + sheetContent: { + TXCalendarBottomSheet( + selectedDate: $store.data.calendarSheetDate, + completeButtonText: "완료", + onComplete: { + store.send(.view(.monthCalendarConfirmTapped)) + } + ) + } + ) + .txModal( + item: $store.presentation.modal, + onAction: { action in + if action == .confirm { + store.send(.view(.modalConfirmTapped)) + } + } + ) + .transaction { transaction in + transaction.disablesAnimations = false + } + .fullScreenCover( + isPresented: $store.presentation.isProofPhotoPresented, + onDismiss: { store.send(.view(.proofPhotoDismissed)) }, + ) { + if let proofPhotoStore = store.scope(state: \.proofPhoto, action: \.proofPhoto) { + proofPhotoFactory.makeView(proofPhotoStore) + } + } + .cameraPermissionAlert( + isPresented: $store.presentation.isCameraPermissionAlertPresented, + onDismiss: { store.send(.view(.cameraPermissionAlertDismissed)) } + ) + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 5bf7f5ea..6dd24ef7 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -101,7 +101,7 @@ extension HomeReducer { switch action { // MARK: - Life Cycle - case .onAppear: + case .view(.onAppear): if state.calendarDate.day == nil { let now = state.nowDate let date = TXCalendarDate( @@ -109,29 +109,52 @@ extension HomeReducer { month: now.month, day: now.day ) - return .send(.setCalendarDate(date)) + return .send(.internal(.setCalendarDate(date))) } state.isLoading = true - return .send(.fetchGoals) + return .send(.internal(.fetchGoals)) // MARK: - User Action - case let .calendarDateSelected(item): + case .view(.refreshPulled): + return .send(.internal(.fetchGoals)) + + case .view(.dataRetryTapped): + return .send(.internal(.fetchGoals)) + + case .view(.perfToastShowTapped): + return .send(.presentation(.showToast(.warning(message: "perf-toast")))) + + case .view(.perfToastDismissTapped): + state.toast = nil + return .none + + case .view(.perfCalendarNextTapped): + var next = state.calendarDate + next.goToNextMonth() + return .send(.internal(.setCalendarDate(next))) + + case .view(.perfCalendarPreviousTapped): + var previous = state.calendarDate + previous.goToPreviousMonth() + return .send(.internal(.setCalendarDate(previous))) + + case let .view(.calendarDateSelected(item)): guard let components = item.dateComponents, let year = components.year, let month = components.month, let day = components.day else { return .none } - return .send(.setCalendarDate(TXCalendarDate(year: year, month: month, day: day))) + return .send(.internal(.setCalendarDate(TXCalendarDate(year: year, month: month, day: day)))) - case let .setCalendarSheetPresented(isPresented): + case let .internal(.setCalendarSheetPresented(isPresented)): state.isCalendarSheetPresented = isPresented if isPresented { state.calendarSheetDate = state.calendarDate } return .none - case let .navigationBarAction(action): + case let .view(.navigationBarAction(action)): switch action { case .refreshTapped: let now = state.nowDate @@ -142,12 +165,12 @@ extension HomeReducer { ) if date == state.calendarDate { state.isLoading = true - return .send(.fetchGoals) + return .send(.internal(.fetchGoals)) } - return .send(.setCalendarDate(date)) + return .send(.internal(.setCalendarDate(date))) case .subTitleTapped: - return .send(.setCalendarSheetPresented(true)) + return .send(.internal(.setCalendarSheetPresented(true))) case .alertTapped: return .send(.delegate(.goToNotification)) @@ -161,11 +184,11 @@ extension HomeReducer { return .none } - case .monthCalendarConfirmTapped: + case .view(.monthCalendarConfirmTapped): state.isCalendarSheetPresented = false - return .send(.setCalendarDate(state.calendarSheetDate)) + return .send(.internal(.setCalendarDate(state.calendarSheetDate))) - case let .goalCheckButtonTapped(id, isChecked): + case let .view(.goalCheckButtonTapped(id, isChecked)): guard let item = state.items.first(where: { $0.id == id }) else { return .none } @@ -198,14 +221,14 @@ extension HomeReducer { } else { return .run { send in let isAuthorized = await captureSessionClient.fetchIsAuthorized() - await send(.authorizationCompleted(id: id, isAuthorized: isAuthorized)) + await send(.response(.authorizationCompleted(id: id, isAuthorized: isAuthorized))) } } } return .none - case .modalConfirmTapped: + case .view(.modalConfirmTapped): guard let pendingGoalID = state.pendingDeleteGoalID, let pendingPhotologID = state.pendingDeletePhotologID else { return .none @@ -215,22 +238,22 @@ extension HomeReducer { return .run { send in do { try await photoLogClient.deletePhotoLog(pendingPhotologID) - await send(.deletePhotoLogCompleted(goalId: pendingGoalID)) + await send(.response(.deletePhotoLogCompleted(goalId: pendingGoalID))) } catch { - await send(.deletePhotoLogFailed) + await send(.response(.deletePhotoLogFailed)) } } - case let .yourCardTapped(card): + case let .view(.yourCardTapped(card)): if !card.yourCard.isSelected { if let item = state.items.first(where: { $0.id == card.id }), case .completed = item.goal.status { - return .send(.showToast(.warning(message: "끝난 목표는 인증이 불가능해요!"))) + return .send(.presentation(.showToast(.warning(message: "끝난 목표는 인증이 불가능해요!")))) } // 쿨다운 확인 (3시간 이내 재요청 방지) if let remaining = PokeCooldownManager.remainingCooldown(goalId: card.id) { let timeText = PokeCooldownManager.formatRemainingTime(remaining) - return .send(.showToast(.warning(message: "\(timeText) 뒤에 다시 찌를 수 있어요"))) + return .send(.presentation(.showToast(.warning(message: "\(timeText) 뒤에 다시 찌를 수 있어요")))) } // 상대방 미인증 시 찌르기 API 호출 let goalId = card.id @@ -238,13 +261,13 @@ extension HomeReducer { return .run { send in PokeCooldownManager.recordPoke(goalId: goalId) do { - try await goalClient.pokePartner(goalId) - await send(.setPokeButtonDisabled(goalId: goalId, true, date: pokeDate)) - await send(.showToast(.poke(message: "상대방을 찔렀어요!"))) + try await goalClient.pokePartner(goalId, PokeRequestDTO(date: pokeDate.formattedAPIDateString())) + await send(.internal(.setPokeButtonDisabled(goalId: goalId, true, date: pokeDate))) + await send(.presentation(.showToast(.poke(message: "상대방을 찔렀어요!")))) } catch { PokeCooldownManager.removePoke(goalId: goalId) - await send(.setPokeButtonDisabled(goalId: goalId, false, date: pokeDate)) - await send(.showToast(.warning(message: "찌르기에 실패했어요"))) + await send(.internal(.setPokeButtonDisabled(goalId: goalId, false, date: pokeDate))) + await send(.presentation(.showToast(.warning(message: "찌르기에 실패했어요")))) } } .debounce(id: PokeCancelID.poke(goalId), for: .milliseconds(300), scheduler: DispatchQueue.main) @@ -261,48 +284,48 @@ extension HomeReducer { ) } - case let .myCardTapped(card): + case let .view(.myCardTapped(card)): let verificationDate = TXCalendarUtil.apiDateString(for: state.calendarDate) return .send(.delegate(.goToGoalDetail(id: card.id, owner: .mySelf, verificationDate: verificationDate))) - case let .headerTapped(card): - return .send(.delegate(.goToStatsDetail(id: card.id))) + case let .view(.headerTapped(card)): + return .send(.delegate(.goToStatsDetail(id: card.id, calendarDate: state.calendarDate))) - case .floatingButtonTapped: + case .view(.floatingButtonTapped): state.isAddGoalPresented = true return .none - case let .addGoalButtonTapped(category): + case let .view(.addGoalButtonTapped(category)): state.isAddGoalPresented = false analyticsClient.logEvent(HomeAnalyticsEvent.selectGoalClicked(kind: category.rawValue)) return .send(.delegate(.goToMakeGoal(category))) - case .editButtonTapped: + case .view(.editButtonTapped): return .send(.delegate(.goToEditGoalList(date: state.calendarDate))) - case let .weekCalendarSwipe(swipe): + case let .view(.weekCalendarSwipe(swipe)): switch swipe { case .next: - guard let nextWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let nextWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: 1 ) else { return .none } - return .send(.setCalendarDate(nextWeekDate)) + return .send(.internal(.setCalendarDate(nextWeekDate))) case .previous: - guard let previousWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let previousWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: -1 ) else { return .none } - return .send(.setCalendarDate(previousWeekDate)) + return .send(.internal(.setCalendarDate(previousWeekDate))) } // MARK: - Update State - case let .fetchGoalsCompleted(goalList, date): + case let .response(.fetchGoalsCompleted(goalList, date)): let cacheKey = TXCalendarUtil.apiDateString(for: date) let items = goalList.goals .map(HomeGoalItem.init(goal:)) @@ -315,19 +338,22 @@ extension HomeReducer { } state.isLoading = false + state.isFetchFailed = false if state.items != items { state.items = items } return .none - case .fetchGoalsFailed: + case let .response(.fetchGoalsFailed(date)): + guard date == state.calendarDate else { return .none } state.isLoading = false - return .send(.showToast(.warning(message: "목표 조회에 실패했어요"))) + state.isFetchFailed = true + return .none - case let .setCalendarDate(date): + case let .internal(.setCalendarDate(date)): guard date != state.calendarDate else { return .none } - + let now = state.nowDate let today = TXCalendarDate( year: now.year, @@ -351,9 +377,9 @@ extension HomeReducer { } state.isLoading = true - return .send(.fetchGoals) + return .send(.internal(.fetchGoals)) - case .fetchGoals: + case .internal(.fetchGoals): let date = state.calendarDate let cacheKey = TXCalendarUtil.apiDateString(for: date) if let cachedItems = state.goalsCache[cacheKey] { @@ -364,25 +390,26 @@ extension HomeReducer { } else { state.isLoading = true } + state.isFetchFailed = false return .run { send in // 읽지 않은 알림 여부 체크 if let hasUnread = try? await notificationClient.fetchUnread() { - await send(.fetchUnreadResponse(hasUnread)) + await send(.response(.fetchUnreadResponse(hasUnread))) } do { let goalList = try await goalClient.fetchGoals(cacheKey) - await send(.fetchGoalsCompleted(goalList, date: date)) + await send(.response(.fetchGoalsCompleted(goalList, date: date))) } catch { - await send(.fetchGoalsFailed) + await send(.response(.fetchGoalsFailed(date: date))) } } - case let .showToast(toast): + case let .presentation(.showToast(toast)): state.toast = toast return .none - case let .setPokeButtonDisabled(goalId, isDisabled, date): + case let .internal(.setPokeButtonDisabled(goalId, isDisabled, date)): if date == state.calendarDate { updatePokeButtonDisabled(in: &state.items, goalId: goalId, isDisabled: isDisabled) } @@ -394,7 +421,7 @@ extension HomeReducer { state.goalsCache[cacheKey] = cachedItems return .none - case let .authorizationCompleted(id, isAuthorized): + case let .response(.authorizationCompleted(id, isAuthorized)): if !isAuthorized { state.isCameraPermissionAlertPresented = true return .none @@ -406,7 +433,7 @@ extension HomeReducer { state.isProofPhotoPresented = true return .none - case .cameraPermissionAlertDismissed: + case .view(.cameraPermissionAlertDismissed): state.isCameraPermissionAlertPresented = false return .none @@ -440,14 +467,14 @@ extension HomeReducer { refreshPokeCooldownStates(state: &state) return .none - case .proofPhotoDismissed: + case .view(.proofPhotoDismissed): state.proofPhoto = nil return .none case .proofPhoto: return .none - case let .deletePhotoLogCompleted(goalId): + case let .response(.deletePhotoLogCompleted(goalId)): guard let index = state.items.firstIndex(where: { $0.id == goalId }) else { return .none } @@ -471,12 +498,12 @@ extension HomeReducer { ) state.items[index].updateGoal(updatedGoal) refreshPokeCooldownStates(state: &state) - return .send(.showToast(.delete(message: "인증이 해제되었어요"))) + return .send(.presentation(.showToast(.delete(message: "인증이 해제되었어요")))) - case .deletePhotoLogFailed: - return .send(.showToast(.warning(message: "해제에 실패했어요"))) + case .response(.deletePhotoLogFailed): + return .send(.presentation(.showToast(.warning(message: "해제에 실패했어요")))) - case let .fetchUnreadResponse(hasUnread): + case let .response(.fetchUnreadResponse(hasUnread)): state.hasUnreadNotification = hasUnread return .none diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 932e9e26..4d11a77f 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -9,8 +9,8 @@ import SwiftUI import ComposableArchitecture import FeatureHomeInterface -import FeatureProofPhotoInterface import SharedDesignSystem +import SharedPerfTestingSupport /// 홈 화면을 렌더링하는 View입니다. /// @@ -24,12 +24,21 @@ import SharedDesignSystem /// } /// ) /// ``` +/// +/// ## Read-set split (Pass 3 Commit 3) +/// +/// The view is decomposed into sibling sub-view structs so SwiftUI's +/// `@ObservableState` observation tracking can isolate which fields cause +/// which sub-view to re-render. Each sub-view's body only reads the fields +/// it actually uses, so a change to one field only invalidates the views +/// that observe it. Presentation modifiers (sheets / modal / fullScreenCover +/// / alert) move into `HomePresentationLayer`, a ViewModifier whose body +/// reads the presentation bindings — keeping that read-set off the parent +/// `HomeView.body`. public struct HomeView: View { @Bindable public var store: StoreOf - @Dependency(\.proofPhotoFactory) var proofPhotoFactory - @State private var emptyScrollHeight: CGFloat = 0 - + /// HomeView를 생성합니다. /// /// ## 사용 예시 @@ -39,231 +48,40 @@ public struct HomeView: View { public init(store: StoreOf) { self.store = store } - + public var body: some View { VStack(spacing: 0) { - navigationBar - calendar - if store.hasCards { - content + // PERF probe harness — activated only for probe scenarios + // (`-UITEST_PROBE_SCENARIO`). Reading store.toast / store.calendarDate + // inside the harness adds an artificial read to the parent body; + // this is acceptable because probe scenarios are not the + // authoritative rendering metric. + if UITestMode.isProbeScenario { + HomePerfActionHarness(store: store) + PerfRebuildProxyPing("home.view.rebuild.proxy") + } + HomeNavigationBarSection(store: store) + HomeCalendarSection(store: store) + // The branch reads presentation booleans so it stays in the parent + // body. Each section owns the rest of its read-set. + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else if store.hasCards { + HomeContentSection(store: store) } else if store.isEmptyVisible { - emptyContent + HomeEmptyContentSection(store: store) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .modifier(PerfToastPresentationHarness(toast: $store.presentation.toast)) + .modifier(PerfHomeCounterMarkersHarness()) + .modifier(HomePresentationLayer(store: store)) .onAppear { - store.send(.onAppear) - } - .txBottomSheet( - isPresented: $store.isAddGoalPresented, - showDragIndicator: true, - sheetContent: { - AddGoalListView { category in - store.send(.addGoalButtonTapped(category)) - } - } - ) - .txBottomSheet( - isPresented: $store.isCalendarSheetPresented, - sheetContent: { - TXCalendarBottomSheet( - selectedDate: $store.calendarSheetDate, - completeButtonText: "완료", - onComplete: { - store.send(.monthCalendarConfirmTapped) - } - ) - } - ) - .txModal( - item: $store.modal, - onAction: { action in - if action == .confirm { - store.send(.modalConfirmTapped) - } - } - ) - .transaction { transaction in - transaction.disablesAnimations = false + store.send(.view(.onAppear)) } - .fullScreenCover( - isPresented: $store.isProofPhotoPresented, - onDismiss: { store.send(.proofPhotoDismissed) }, - ) { - IfLetStore(store.scope(state: \.proofPhoto, action: \.proofPhoto)) { store in - proofPhotoFactory.makeView(store) - } - } - .cameraPermissionAlert( - isPresented: $store.isCameraPermissionAlertPresented, - onDismiss: { store.send(.cameraPermissionAlertDismissed) } - ) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) } } - -// MARK: - SubViews -private extension HomeView { - var navigationBar: some View { - TXNavigationBar( - style: .home( - .init( - subTitle: store.calendarMonthTitle, - mainTitle: store.mainTitle, - isHiddenRefresh: store.isRefreshHidden, - isRemainedAlarm: store.hasUnreadNotification - ) - ), onAction: { action in - store.send(.navigationBarAction(action)) - } - ) - } - - var calendar: some View { - TXCalendar( - mode: .weekly, - currentDate: $store.calendarDate, - weeks: store.calendarWeeks, - config: .init( - dateStyle: .init(lastDateTextColor: Color.Gray.gray500) - ), - onSelect: { item in - store.send(.calendarDateSelected(item)) - }, - onSwipe: { swipe in - store.send(.weekCalendarSwipe(swipe)) - } - ) - .frame(maxWidth: .infinity, maxHeight: 76) - } - - var content: some View { - ScrollView { - Group { - headerRow - cardList - } - .padding(.horizontal, 20) - .padding(.top, 16) - .padding(.bottom, 103) - } - .refreshable { - store.send(.fetchGoals) - } - } - - var emptyContent: some View { - VStack(spacing: 0) { - headerRow - .padding(.horizontal, 20) - .padding(.top, 16) - - ScrollView { - goalEmptyView - // 실제 가시 영역 기준으로 중앙 정렬되도록 탭바 높이만큼 차감 - .frame(maxWidth: .infinity, minHeight: max(0, emptyScrollHeight - 58)) - .padding(.bottom, 58) - } - .scrollIndicators(.hidden) - .refreshable { - store.send(.fetchGoals) - } - .overlay(alignment: .bottomTrailing) { - emptyArrow - } - .frame(maxHeight: .infinity) - .background { - GeometryReader { geo in - Color.clear - .onAppear { emptyScrollHeight = geo.size.height } - .onChange(of: geo.size.height) { _, newValue in - emptyScrollHeight = newValue - } - } - } - } - } - - var headerRow: some View { - HStack(spacing: 0) { - Text(store.goalSectionTitle) - .typography(.b1_14b) - - Spacer() - - Button { - store.send(.editButtonTapped) - } label: { - Text("편집") - .typography(.b1_14b) - .foregroundStyle(Color.Gray.gray500) - } - } - .frame(height: 24) - } - - var cardList: some View { - LazyVStack(spacing: 16) { - ForEach(store.items) { item in - goalCard(for: item) - } - } - .padding(.top, 12) - } - - func goalCard(for item: HomeGoalItem) -> some View { - GoalCardView( - item: item.card, - onHeaderTapped: { store.send(.headerTapped(item.card)) }, - onCheckButtonTapped: { - store.send(.goalCheckButtonTapped( - id: item.id, - isChecked: item.card.myCard.isSelected - )) - }, - actionLeft: { store.send(.myCardTapped(item.card)) }, - actionRight: { store.send(.yourCardTapped(item.card)) } - ) - } - - @ViewBuilder - var goalEmptyView: some View { - Group { - if store.hadFirstGoal == true { - VStack(spacing: 8) { - Image.Illustration.scare - .resizable() - .frame(width: 164, height: 164) - - Text("이 날은 목표가 없어요!") - .typography(.t2_16b) - .foregroundStyle(Color.Gray.gray400) - } - } else if store.hadFirstGoal == false { - VStack(spacing: 0) { - Image.Illustration.emptyPoke - .frame(height: 116) - - Text("첫 목표를 세워볼까요?") - .typography(.t2_16b) - .foregroundStyle(Color.Gray.gray400) - .padding(.top, 16) - - Text("+ 버튼을 눌러 목표를 추가해보세요") - .typography(.c1_12r) - .foregroundStyle(Color.Gray.gray300) - .padding(.top, 4) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - var emptyArrow: some View { - Image.Illustration.arrow - .padding(.bottom, 71 + 58) - .padding(.trailing, 86) - .ignoresSafeArea() - } -} diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift index 7f5ac325..12dc9b3b 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift @@ -29,14 +29,14 @@ extension HomeCoordinator { // swiftlint:disable:next closure_body_length let reducer = Reduce { state, action in switch action { - case let .home(.delegate(.goToGoalDetail(id, owner, verificationDate))): - state.routes.append(.detail) - state.goalDetail = .init( - currentUser: owner, - id: id, - verificationDate: verificationDate + case let .home(.delegate(.goToGoalDetail(id, owner, date))): + return .send( + .navigateToGoalDetail( + id: id, + owner: owner, + date: date + ) ) - return .none case let .home(.delegate(.goToMakeGoal(category))): state.routes.append(.makeGoal) @@ -58,9 +58,9 @@ extension HomeCoordinator { state.notification = .init() return .none - case let .home(.delegate(.goToStatsDetail(id))): + case let .home(.delegate(.goToStatsDetail(id, date))): state.routes.append(.statsDetail) - state.statsDetail = .init(goalId: id) + state.statsDetail = .init(goalId: id, initialMonth: date) return .none case .statsDetail(.delegate(.navigateBack)): @@ -72,7 +72,7 @@ extension HomeCoordinator { state.makeGoal = .init(mode: .edit(goalData)) return .none - case .statsDetail(.onDisappear): + case .statsDetail(.view(.onDisappear)): if !state.routes.contains(.statsDetail) { state.statsDetail = nil } @@ -85,7 +85,7 @@ extension HomeCoordinator { popLastRoute(&state.routes) return .none - case .goalDetail(.onDisappear): + case .goalDetail(.view(.onDisappear)): state.goalDetail = nil return .none @@ -93,13 +93,13 @@ extension HomeCoordinator { popLastRoute(&state.routes) return .none - case .editGoalList(.onDisappear): + case .editGoalList(.view(.onDisappear)): if !state.routes.contains(.editGoalList) { state.editGoalList = nil } return .none - case .makeGoal(.onDisappear): + case .makeGoal(.view(.onDisappear)): state.makeGoal = nil return .none @@ -185,13 +185,20 @@ extension HomeCoordinator { return .none case let .navigateToGoalDetail(id, owner, date): - state.routes.append(.detail) + let isAlreadyOnDetail = state.routes.last == .detail + + if !isAlreadyOnDetail { + state.routes.append(.detail) + } + state.goalDetail = .init( currentUser: owner, id: id, verificationDate: date ) - return .none + return isAlreadyOnDetail + ? .send(.goalDetail(.view(.onAppear))) + : .none case .delegate: return .none diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift index e80eae05..3640d315 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift @@ -14,6 +14,7 @@ import FeatureNotificationInterface import FeatureMakeGoalInterface import FeatureSettingsInterface import FeatureStatsInterface +import SharedPerfTestingSupport /// Home Feature의 NavigationStack을 제공하는 Root View입니다. /// @@ -51,26 +52,28 @@ public struct HomeCoordinatorView: View { .navigationDestination(for: HomeRoute.self) { route in switch route { case .detail: - IfLetStore(store.scope(state: \.goalDetail, action: \.goalDetail)) { store in - goalDetailFactory.makeView(store) + if let goalDetailStore = store.scope(state: \.goalDetail, action: \.goalDetail) { + goalDetailFactory.makeView(goalDetailStore) .toolbar(.hidden, for: .tabBar) + .perfReadyMarker("home-to-goal-detail") } case .statsDetail: - IfLetStore(store.scope(state: \.statsDetail, action: \.statsDetail)) { store in - statsDetailFactory.makeView(store) + if let statsDetailStore = store.scope(state: \.statsDetail, action: \.statsDetail) { + statsDetailFactory.makeView(statsDetailStore) .toolbar(.hidden, for: .tabBar) + .perfReadyMarker("home-to-stats-detail") } case .editGoalList: - IfLetStore(store.scope(state: \.editGoalList, action: \.editGoalList)) { store in - EditGoalListView(store: store) + if let editGoalListStore = store.scope(state: \.editGoalList, action: \.editGoalList) { + EditGoalListView(store: editGoalListStore) .toolbar(.hidden, for: .tabBar) } case .makeGoal: - IfLetStore(store.scope(state: \.makeGoal, action: \.makeGoal)) { store in - makeGoalFactory.makeView(store) + if let makeGoalStore = store.scope(state: \.makeGoal, action: \.makeGoal) { + makeGoalFactory.makeView(makeGoalStore) .toolbar(.hidden, for: .tabBar) } diff --git a/Projects/Feature/Home/Testing/Sources/Source.swift b/Projects/Feature/Home/Testing/Sources/Source.swift index 147ab9c1..02f65c13 100644 --- a/Projects/Feature/Home/Testing/Sources/Source.swift +++ b/Projects/Feature/Home/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 01/26/26. // -/// Remove Or Edit +/// Stable perf seed names for the Home example app. +public enum HomePerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift b/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift index 2e49e85f..a5ea26da 100644 --- a/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift +++ b/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift @@ -6,12 +6,19 @@ // import SwiftUI +import SharedPerfTestingSupport @main struct MainTabExampleApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { MainTabExampleView() + .perfRoot("main-tab") + .perfReadyMarker("main-tab") } } } diff --git a/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift b/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift index 0cefbeeb..b941a8b4 100644 --- a/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift +++ b/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift @@ -5,34 +5,163 @@ // Created by 정지훈 on 1/28/26. // +import AVFoundation import SwiftUI import ComposableArchitecture import Feature import CoreCaptureSession +import CoreCaptureSessionInterface import DomainGoalInterface import FeatureMakeGoal import FeatureMakeGoalInterface +import SharedDesignSystem +import SharedPerfTestingSupport struct MainTabExampleView: View { var body: some View { - MainTabView( - store: Store( - initialState: MainTabReducer.State(), - reducer: { - MainTabReducer() - }, withDependencies: { - $0.goalClient = .previewValue - $0.captureSessionClient = .liveValue - $0.proofPhotoFactory = .liveValue - $0.goalDetailFactory = .liveValue - $0.makeGoalFactory = .liveValue - } + if ProcessInfo.processInfo.arguments.contains("-UITEST_DESIGN_SYSTEM_BOTTOM_SHEET_SCENARIO") { + DesignSystemBottomSheetScenarioView() + } else { + MainTabView( + store: Store( + initialState: MainTabReducer.State(), + reducer: { + MainTabReducer() + }, withDependencies: { + $0.goalClient = .previewValue + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue + $0.proofPhotoFactory = .liveValue + $0.goalDetailFactory = .liveValue + $0.makeGoalFactory = .liveValue + } + ) ) - ) + } } } #Preview { MainTabExampleView() } + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) +} + +private struct DesignSystemBottomSheetScenarioView: View { + @State private var selectedTab: TXTabItem = .home + @State private var selectedDate = TXCalendarDate(year: 2026, month: 5, day: 28) + @State private var isBottomSheetPresented = false + @State private var completedCount = 0 + @State private var selfRunStep = 0 + @State private var hasStartedSelfRun = false + + var body: some View { + TXTabBarContainer(selectedItem: $selectedTab) { + scenarioContent(title: "홈") + .tag(TXTabItem.home) + + scenarioContent(title: "통계") + .tag(TXTabItem.statistics) + } + .txBottomSheet( + isPresented: $isBottomSheetPresented, + showDragIndicator: true + ) { + TXCalendarBottomSheet( + selectedDate: $selectedDate, + completeButtonText: "완료", + onComplete: { + completedCount += 1 + isBottomSheetPresented = false + } + ) + .overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier( + "example.bottom-sheet.calendar-month.\(selectedDate.formattedYearDashMonth)" + ) + } + } + .overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("example.bottom-sheet.completed-count.\(completedCount)") + } + .overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("example.bottom-sheet.self-run-step.\(selfRunStep)") + } + .task { + await runCalendarBottomSheetSelfRunIfNeeded() + } + } + + private func scenarioContent(title: String) -> some View { + VStack(spacing: Spacing.spacing6) { + Text(title) + .typography(.t1_18eb) + .foregroundStyle(Color.Gray.gray500) + + Button("캘린더 바텀시트 열기") { + isBottomSheetPresented = true + } + .accessibilityIdentifier("example.bottom-sheet.present-button") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.Common.white) + .overlay(alignment: .topLeading) { + Button { + triggerQuickRepresentRace() + } label: { + Color.clear + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("example.bottom-sheet.quick-represent-button") + } + } + + private func triggerQuickRepresentRace() { + isBottomSheetPresented = true + + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + isBottomSheetPresented = false + try? await Task.sleep(for: .milliseconds(50)) + isBottomSheetPresented = true + } + } + + @MainActor + private func runCalendarBottomSheetSelfRunIfNeeded() async { + guard UITestMode.isSwiftUISelfRunCalendarBottomSheet, !hasStartedSelfRun else { return } + hasStartedSelfRun = true + + try? await Task.sleep(for: .milliseconds(900)) + for iteration in 1...4 { + selfRunStep = (iteration * 10) + 1 + isBottomSheetPresented = true + try? await Task.sleep(for: .milliseconds(650)) + + selfRunStep = (iteration * 10) + 2 + selectedDate.goToNextMonth() + try? await Task.sleep(for: .milliseconds(250)) + + selfRunStep = (iteration * 10) + 3 + isBottomSheetPresented = false + try? await Task.sleep(for: .milliseconds(450)) + } + + selfRunStep = 999 + } +} diff --git a/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift b/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift new file mode 100644 index 00000000..b70047d5 --- /dev/null +++ b/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift @@ -0,0 +1,137 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class MainTabExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("main-tab") + } + + func testCalendarBottomSheetCoversCustomTabBarAndCompletes() { + let app = launchDesignSystemBottomSheetScenario() + waitForFeatureReady("main-tab") + + let homeTab = app.buttons[DesignSystemBottomSheetScenarioID.homeTab] + let statisticsTab = app.buttons[DesignSystemBottomSheetScenarioID.statisticsTab] + XCTAssertTrue(homeTab.waitForExistence(timeout: 5)) + XCTAssertTrue(statisticsTab.waitForExistence(timeout: 5)) + let tabBarFrame = homeTab.frame.union(statisticsTab.frame) + attachScreenshot(named: "01-tabbar-baseline") + + app.buttons[DesignSystemBottomSheetScenarioID.presentButton].tap() + + let sheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(sheet.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarSheet].exists) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.dragArea].exists) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarMonth("2026-05")].exists) + XCTAssertLessThan(sheet.frame.minY, tabBarFrame.maxY) + XCTAssertGreaterThanOrEqual(sheet.frame.maxY, tabBarFrame.maxY - 1) + attachScreenshot(named: "02-sheet-over-tabbar") + + app.buttons[DesignSystemBottomSheetScenarioID.calendarNextButton].tap() + XCTAssertTrue( + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarMonth("2026-06")] + .waitForExistence(timeout: 2) + ) + attachScreenshot(named: "03-calendar-next-month") + + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completeButton].tap() + XCTAssertTrue(sheet.waitForNonExistence(timeout: 3)) + XCTAssertTrue(homeTab.waitForExistence(timeout: 3)) + XCTAssertTrue(statisticsTab.waitForExistence(timeout: 3)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completedCount(1)].exists) + attachScreenshot(named: "04-dismissed-tabbar-restored") + } + + func testBottomSheetBackdropDismissesAndCanPresentRepeatedly() { + let app = launchDesignSystemBottomSheetScenario() + waitForFeatureReady("main-tab") + + let presentButton = app.buttons[DesignSystemBottomSheetScenarioID.presentButton] + XCTAssertTrue(presentButton.waitForExistence(timeout: 5)) + + presentButton.tap() + let firstSheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(firstSheet.waitForExistence(timeout: 5)) + attachScreenshot(named: "01-first-presentation") + + let backdrop = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.backdrop] + XCTAssertTrue(backdrop.waitForExistence(timeout: 2)) + backdrop.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap() + XCTAssertTrue(firstSheet.waitForNonExistence(timeout: 3)) + attachScreenshot(named: "02-backdrop-dismissed") + + presentButton.tap() + let secondSheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(secondSheet.waitForExistence(timeout: 5)) + attachScreenshot(named: "03-second-presentation") + + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completeButton].tap() + XCTAssertTrue(secondSheet.waitForNonExistence(timeout: 3)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completedCount(1)].exists) + attachScreenshot(named: "04-second-dismissed") + } + + func testBottomSheetQuickRepresentDuringDismissKeepsSheetVisible() { + let app = launchDesignSystemBottomSheetScenario() + waitForFeatureReady("main-tab") + + let quickRepresentButton = app.buttons[DesignSystemBottomSheetScenarioID.quickRepresentButton] + XCTAssertTrue(quickRepresentButton.waitForExistence(timeout: 5)) + + quickRepresentButton.tap() + let sheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(sheet.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarSheet].exists) + attachScreenshot(named: "01-quick-represent-sheet-visible") + + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completeButton].tap() + XCTAssertTrue(sheet.waitForNonExistence(timeout: 3)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completedCount(1)].exists) + attachScreenshot(named: "02-quick-represent-dismissed") + } + + private func launchDesignSystemBottomSheetScenario() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append(contentsOf: [ + "-UITEST", + "-UITEST_SEED", "default", + "-UITEST_WAIT_READY", + "-UITEST_DISABLE_ANIMATIONS", + "-UITEST_DESIGN_SYSTEM_BOTTOM_SHEET_SCENARIO" + ]) + app.launch() + return app + } + + private func attachScreenshot(named name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} + +private enum DesignSystemBottomSheetScenarioID { + static let presentButton = "example.bottom-sheet.present-button" + static let quickRepresentButton = "example.bottom-sheet.quick-represent-button" + static let completedCountPrefix = "example.bottom-sheet.completed-count" + static let calendarMonthPrefix = "example.bottom-sheet.calendar-month" + static let sheetContent = "tx.bottom-sheet.content" + static let backdrop = "tx.bottom-sheet.backdrop" + static let dragArea = "tx.bottom-sheet.drag-area" + static let calendarSheet = "tx.calendar-bottom-sheet" + static let completeButton = "tx.calendar-bottom-sheet.complete-button" + static let calendarNextButton = "tx.calendar.month-navigation.next-button" + static let homeTab = "tx.tab-bar.item.home" + static let statisticsTab = "tx.tab-bar.item.statistics" + + static func completedCount(_ count: Int) -> String { + "\(completedCountPrefix).\(count)" + } + + static func calendarMonth(_ yearDashMonth: String) -> String { + "\(calendarMonthPrefix).\(yearDashMonth)" + } +} diff --git a/Projects/Feature/MainTab/Project.swift b/Projects/Feature/MainTab/Project.swift index 4ae1062c..6102e681 100644 --- a/Projects/Feature/MainTab/Project.swift +++ b/Projects/Feature/MainTab/Project.swift @@ -37,9 +37,11 @@ let project = Project.makeModule( ), dependencies: [ .feature, - .core(implements: .captureSession) + .core(implements: .captureSession), + .shared(implements: .designSystem) ] ) - ) + ), + .feature(exampleUITests: .mainTab) ] ) diff --git a/Projects/Feature/MainTab/Sources/Reducer/MainTabReducer.swift b/Projects/Feature/MainTab/Sources/Reducer/MainTabReducer.swift index 54a9ddf5..e9520c27 100644 --- a/Projects/Feature/MainTab/Sources/Reducer/MainTabReducer.swift +++ b/Projects/Feature/MainTab/Sources/Reducer/MainTabReducer.swift @@ -71,27 +71,30 @@ public struct MainTabReducer { /// /// ## 사용 예시 /// ```swift - /// store.send(.selectedTabChanged(.home)) + /// store.send(.view(.selectedTabChanged(.home))) /// ``` public enum Action: BindableAction { case binding(BindingAction) - // MARK: - Child Action - case home(HomeCoordinator.Action) - - // MARK: - User Action - case selectedTabChanged(TXTabItem) - case notificationDeepLinkReceived(NotificationDeepLink) - case stats(StatsCoordinator.Action) + // MARK: - View + public enum View: Equatable { + case selectedTabChanged(TXTabItem) + case notificationDeepLinkReceived(NotificationDeepLink) + } // MARK: - Delegate - case delegate(Delegate) - public enum Delegate: Equatable { case logoutCompleted case withdrawCompleted case sessionExpired } + + // MARK: - Child Action + case home(HomeCoordinator.Action) + case stats(StatsCoordinator.Action) + + case view(View) + case delegate(Delegate) } /// 기본 구성의 MainTabReducer를 생성합니다. @@ -134,66 +137,16 @@ public struct MainTabReducer { Reduce { state, action in switch action { - // MARK: - User Action - case .selectedTabChanged: - switch state.selectedTab { - case .home: - state.isTabBarHidden = !state.home.routes.isEmpty - || state.home.home.isCalendarSheetPresented - - case .statistics, .couple: - state.isTabBarHidden = false - } - return .none - - case let .notificationDeepLinkReceived(deepLink): - state.selectedTab = .home - state.home.routes = [] - analyticsClient.logEvent(MainTabAnalyticsEvent.openedByPush(deepLink: deepLink)) - - let notificationIdString = deepLink.notificationId - let markAsReadEffect: Effect = .run { [notificationClient] _ in - guard let notificationId = Int64(notificationIdString) else { return } - try? await notificationClient.markAsRead(notificationId) - } - - switch deepLink { - case let .poke(_, goalId, date): - guard let id = Int64(goalId) else { return markAsReadEffect } - return .merge( - markAsReadEffect, - .send(.home(.navigateToGoalDetail(id: id, owner: .mySelf, date: date))) - ) - - case let .goalCompleted(_, goalId, date): - guard let id = Int64(goalId) else { return markAsReadEffect } - return .merge( - markAsReadEffect, - .send(.home(.navigateToGoalDetail(id: id, owner: .you, date: date))) - ) - - case let .reaction(_, goalId, date): - guard let id = Int64(goalId) else { return markAsReadEffect } - return .merge( - markAsReadEffect, - .send(.home(.navigateToGoalDetail(id: id, owner: .mySelf, date: date))) - ) - - case .partnerConnected, .dailyGoalAchieved, .marketing: - return markAsReadEffect - - case .goalEnded: - // TODO: 통계 탭 → 종료된 목표로 이동 - return markAsReadEffect - } + case .view(let viewAction): + return reduceView(state: &state, action: viewAction) // MARK: - Child Action (Home) case .home(.delegate(.logoutCompleted)): return .send(.delegate(.logoutCompleted)) - + case .home(.delegate(.withdrawCompleted)): return .send(.delegate(.withdrawCompleted)) - + case .home(.delegate(.sessionExpired)): return .send(.delegate(.sessionExpired)) @@ -224,17 +177,17 @@ public struct MainTabReducer { // TODO: 통계 탭 → 종료된 목표로 이동 return .none } - + case .home(.delegate(.goToCompletedStats)): state.selectedTab = .statistics - state.stats.stats.isOngoing = false + state.stats.stats.ui.isOngoing = false state.stats.routes = [] return .none case .home: state.isTabBarHidden = !state.home.routes.isEmpty return .none - + case .stats: state.isTabBarHidden = !state.stats.routes.isEmpty return .none @@ -248,3 +201,66 @@ public struct MainTabReducer { } } } + +// MARK: - View + +private extension MainTabReducer { + func reduceView( + state: inout State, + action: Action.View + ) -> Effect { + switch action { + case .selectedTabChanged: + switch state.selectedTab { + case .home: + state.isTabBarHidden = !state.home.routes.isEmpty + || state.home.home.presentation.isCalendarSheetPresented + + case .statistics, .couple: + state.isTabBarHidden = false + } + return .none + + case let .notificationDeepLinkReceived(deepLink): + state.selectedTab = .home + state.home.routes = [] + analyticsClient.logEvent(MainTabAnalyticsEvent.openedByPush(deepLink: deepLink)) + + let notificationIdString = deepLink.notificationId + let markAsReadEffect: Effect = .run { [notificationClient] _ in + guard let notificationId = Int64(notificationIdString) else { return } + try? await notificationClient.markAsRead(notificationId) + } + + switch deepLink { + case let .poke(_, goalId, date): + guard let id = Int64(goalId) else { return markAsReadEffect } + return .merge( + markAsReadEffect, + .send(.home(.navigateToGoalDetail(id: id, owner: .mySelf, date: date))) + ) + + case let .goalCompleted(_, goalId, date): + guard let id = Int64(goalId) else { return markAsReadEffect } + return .merge( + markAsReadEffect, + .send(.home(.navigateToGoalDetail(id: id, owner: .you, date: date))) + ) + + case let .reaction(_, goalId, date): + guard let id = Int64(goalId) else { return markAsReadEffect } + return .merge( + markAsReadEffect, + .send(.home(.navigateToGoalDetail(id: id, owner: .mySelf, date: date))) + ) + + case .partnerConnected, .dailyGoalAchieved, .marketing: + return markAsReadEffect + + case .goalEnded: + // TODO: 통계 탭 → 종료된 목표로 이동 + return markAsReadEffect + } + } + } +} diff --git a/Projects/Feature/MainTab/Sources/View/MainTabView.swift b/Projects/Feature/MainTab/Sources/View/MainTabView.swift index c319edb9..cd0c8616 100644 --- a/Projects/Feature/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Feature/MainTab/Sources/View/MainTabView.swift @@ -63,8 +63,8 @@ public struct MainTabView: View { } } .txToast( - item: $store.home.home.toast, - customPadding: Constants.tabBarHeight + item: $store.home.home.presentation.toast, + customPadding: TXTabBarLayout.height ) .txLoading(isPresented: isTabLoading) } @@ -72,8 +72,8 @@ public struct MainTabView: View { private extension MainTabView { var isTabLoading: Bool { - (store.selectedTab == .home && store.home.routes.isEmpty && store.home.home.isLoading) || - (store.selectedTab == .statistics && store.stats.routes.isEmpty && store.stats.stats.isLoading) + (store.selectedTab == .home && store.home.routes.isEmpty && store.home.home.ui.isLoading) || + (store.selectedTab == .statistics && store.stats.routes.isEmpty && store.stats.stats.ui.isLoading) } } @@ -85,7 +85,7 @@ private extension MainTabView { size: .m, state: .standard ), - onTap: { store.send(.home(.home(.floatingButtonTapped))) } + onTap: { store.send(.home(.home(.view(.floatingButtonTapped)))) } ) .outsideBorder( Color.Gray.gray300, @@ -94,13 +94,7 @@ private extension MainTabView { ) .shadow(color: .black.opacity(0.16), radius: 20, x: 2, y: 1) .padding(.trailing, 16) - .padding(.bottom, 12 + Constants.tabBarHeight) - } -} - -private extension MainTabView { - enum Constants { - static let tabBarHeight: CGFloat = 58 + .padding(.bottom, 12 + TXTabBarLayout.height) } } diff --git a/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift b/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift index 15d74e86..6e054739 100644 --- a/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift +++ b/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift @@ -1,17 +1,29 @@ -// -// MakeGoalView.swift -// -// -// Created by Jihun on 02/22/26. -// - +import ComposableArchitecture +import DomainGoalInterface +import FeatureMakeGoal +import FeatureMakeGoalInterface +import SharedPerfTestingSupport import SwiftUI @main struct MakeGoalApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { - Text("Hello Twix") + MakeGoalView( + store: Store( + initialState: MakeGoalReducer.State(mode: .add(.book)), + reducer: { MakeGoalReducer() }, + withDependencies: { + $0.goalClient = .previewValue + } + ) + ) + .perfRoot("make-goal") + .perfReadyMarker("make-goal") } } } diff --git a/Projects/Feature/MakeGoal/ExampleUITests/Sources/MakeGoalExampleSmokeTests.swift b/Projects/Feature/MakeGoal/ExampleUITests/Sources/MakeGoalExampleSmokeTests.swift new file mode 100644 index 00000000..9527ddb3 --- /dev/null +++ b/Projects/Feature/MakeGoal/ExampleUITests/Sources/MakeGoalExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class MakeGoalExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("make-goal") + } +} diff --git a/Projects/Feature/MakeGoal/Interface/Sources/MakeGoalReducer.swift b/Projects/Feature/MakeGoal/Interface/Sources/MakeGoalReducer.swift index feb72f0d..948098ae 100644 --- a/Projects/Feature/MakeGoal/Interface/Sources/MakeGoalReducer.swift +++ b/Projects/Feature/MakeGoal/Interface/Sources/MakeGoalReducer.swift @@ -176,45 +176,55 @@ public struct MakeGoalReducer { /// /// ## 사용 예시 /// ```swift - /// store.send(.completeButtonTapped) + /// store.send(.view(.completeButtonTapped)) /// ``` public enum Action: BindableAction { case binding(BindingAction) - - // MARK: - LifeCycle - case onAppear - case onDisappear - // MARK: - Update State - case createGoalFailed - case updateGoalFailed + // MARK: - View + public enum View: Equatable { + case onAppear + case onDisappear + case emojiButtonTapped + case goalTitleFocusChanged(Bool) + case dismissKeyboard + case periodTabSelected(PeriodItem) + case periodSelected + case periodSheetWeeklyTapped + case periodSheetMonthlyTapped + case periodSheetMinusTapped + case periodSheetPlusTapped + case periodSheetCompleteTapped + case startDateTapped + case endDateTapped + case monthCalendarConfirmTapped + case completeButtonTapped + case navigationBackButtonTapped + case modalConfirmTapped(Int) + } + + // MARK: - Response + public enum Response: Equatable { + case createGoalFailed + case updateGoalFailed + } - // MARK: - User Action - case emojiButtonTapped - case goalTitleFocusChanged(Bool) - case dismissKeyboard - case periodTabSelected(PeriodItem) - case periodSelected - case periodSheetWeeklyTapped - case periodSheetMonthlyTapped - case periodSheetMinusTapped - case periodSheetPlusTapped - case periodSheetCompleteTapped - case startDateTapped - case endDateTapped - case monthCalendarConfirmTapped - case completeButtonTapped - case navigationBackButtonTapped - case modalConfirmTapped(Int) - case showToast(TXToastType) + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(TXToastType) + } // MARK: - Delegate case delegate(Delegate) - + /// MakeGoalReducer 화면에서 외부로 전달하는 이벤트입니다. public enum Delegate { case navigateBack } + + case view(View) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입한 Reduce로 MakeGoalReducer를 구성합니다. diff --git a/Projects/Feature/MakeGoal/Project.swift b/Projects/Feature/MakeGoal/Project.swift index 3e67707c..35d276ac 100644 --- a/Projects/Feature/MakeGoal/Project.swift +++ b/Projects/Feature/MakeGoal/Project.swift @@ -60,6 +60,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .makeGoal) ] ) diff --git a/Projects/Feature/MakeGoal/Sources/MakeGoalReducer+Impl.swift b/Projects/Feature/MakeGoal/Sources/MakeGoalReducer+Impl.swift index 418aa6b4..1d985f33 100644 --- a/Projects/Feature/MakeGoal/Sources/MakeGoalReducer+Impl.swift +++ b/Projects/Feature/MakeGoal/Sources/MakeGoalReducer+Impl.swift @@ -22,24 +22,24 @@ extension MakeGoalReducer { let reducer = Reduce { state, action in switch action { // MARK: - LifeCycle - case .onAppear: + case .view(.onAppear): return .none - case .onDisappear: + case .view(.onDisappear): return .none - case .createGoalFailed: + case .response(.createGoalFailed): state.isLoading = false state.submitMessage = nil - return .send(.showToast(.warning(message: "목표 생성에 실패했어요"))) + return .send(.presentation(.showToast(.warning(message: "목표 생성에 실패했어요")))) - case .updateGoalFailed: + case .response(.updateGoalFailed): state.isLoading = false state.submitMessage = nil - return .send(.showToast(.warning(message: "이미 완료한 목표입니다!"))) + return .send(.presentation(.showToast(.warning(message: "이미 완료한 목표입니다!")))) // MARK: - User Action - case .emojiButtonTapped: + case .view(.emojiButtonTapped): state.isGoalTitleFocused = false state.modal = .selection( title: "아이콘 변경", @@ -49,36 +49,36 @@ extension MakeGoalReducer { ) return .none - case let .modalConfirmTapped(index): + case let .view(.modalConfirmTapped(index)): state.goalData.icon = state.icons[index] return .none - case let .goalTitleFocusChanged(isFocused): + case let .view(.goalTitleFocusChanged(isFocused)): state.isGoalTitleFocused = isFocused return .none - case .dismissKeyboard: + case .view(.dismissKeyboard): state.isGoalTitleFocused = false return .none - case let .periodTabSelected(item): + case let .view(.periodTabSelected(item)): state.goalData.repeatCycle = item.repeatCycle return .none - case .periodSelected: + case .view(.periodSelected): state.isGoalTitleFocused = false state.isPeriodSheetPresented = true return .none - case .periodSheetWeeklyTapped: + case .view(.periodSheetWeeklyTapped): state.goalData.repeatCycle = .weekly return .none - case .periodSheetMonthlyTapped: + case .view(.periodSheetMonthlyTapped): state.goalData.repeatCycle = .monthly return .none - case .periodSheetMinusTapped: + case .view(.periodSheetMinusTapped): switch state.goalData.repeatCycle { case .daily: return .none @@ -94,7 +94,7 @@ extension MakeGoalReducer { return .none - case .periodSheetPlusTapped: + case .view(.periodSheetPlusTapped): switch state.goalData.repeatCycle { case .daily: return .none @@ -110,18 +110,18 @@ extension MakeGoalReducer { return .none - case .periodSheetCompleteTapped: + case .view(.periodSheetCompleteTapped): state.isPeriodSheetPresented = false return .none - case .startDateTapped: + case .view(.startDateTapped): state.isGoalTitleFocused = false state.calendarTarget = .startDate state.calendarSheetDate = state.goalData.startDate state.isCalendarSheetPresented = true return .none - case .endDateTapped: + case .view(.endDateTapped): state.isGoalTitleFocused = false state.calendarTarget = .endDate if state.goalData.endDate < state.goalData.startDate { @@ -131,7 +131,7 @@ extension MakeGoalReducer { state.isCalendarSheetPresented = true return .none - case .monthCalendarConfirmTapped: + case .view(.monthCalendarConfirmTapped): guard let target = state.calendarTarget else { state.isCalendarSheetPresented = false return .none @@ -151,10 +151,10 @@ extension MakeGoalReducer { state.isCalendarSheetPresented = false return .none - case .completeButtonTapped: + case .view(.completeButtonTapped): guard !state.isLoading else { return .none } guard !state.completeButtonDisabled else { - return .send(.showToast(.warning(message: "목표 이름은 14글자 이내로 입력해 주세요!"))) + return .send(.presentation(.showToast(.warning(message: "목표 이름은 14글자 이내로 입력해 주세요!")))) } state.isLoading = true @@ -182,7 +182,7 @@ extension MakeGoalReducer { ) await send(.delegate(.navigateBack)) } catch { - await send(.createGoalFailed) + await send(.response(.createGoalFailed)) } } @@ -191,7 +191,7 @@ extension MakeGoalReducer { guard let goalId = state.goalData.goalId else { state.isLoading = false state.submitMessage = nil - return .send(.showToast(.warning(message: "목표 수정에 실패했어요"))) + return .send(.presentation(.showToast(.warning(message: "목표 수정에 실패했어요")))) } let request = GoalUpdateRequestDTO( goalName: state.goalData.title, @@ -205,15 +205,15 @@ extension MakeGoalReducer { _ = try await goalClient.updateGoal(goalId, request) await send(.delegate(.navigateBack)) } catch { - await send(.updateGoalFailed) + await send(.response(.updateGoalFailed)) } } } - case .navigationBackButtonTapped: + case .view(.navigationBackButtonTapped): return .send(.delegate(.navigateBack)) - case let .showToast(toast): + case let .presentation(.showToast(toast)): state.toast = toast return .none diff --git a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift index 6e26984f..7f94a323 100644 --- a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift +++ b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift @@ -44,12 +44,12 @@ public struct MakeGoalView: View { .padding(.horizontal, 20) .ignoresSafeArea(.keyboard) .toolbar(.hidden, for: .navigationBar) - .onAppear { store.send(.onAppear) } - .onDisappear { store.send(.onDisappear) } - .onTapGesture { store.send(.dismissKeyboard) } + .onAppear { store.send(.view(.onAppear)) } + .onDisappear { store.send(.view(.onDisappear)) } + .onTapGesture { store.send(.view(.dismissKeyboard)) } .onChange(of: isGoalTitleTextFieldFocused) { _, newValue in guard store.isGoalTitleFocused != newValue else { return } - store.send(.goalTitleFocusChanged(newValue)) + store.send(.view(.goalTitleFocusChanged(newValue))) } .onChange(of: store.isGoalTitleFocused) { _, newValue in guard isGoalTitleTextFieldFocused != newValue else { return } @@ -61,7 +61,7 @@ public struct MakeGoalView: View { TXCalendarBottomSheet( selectedDate: $store.calendarSheetDate, completeButtonText: "완료", - onComplete: { store.send(.monthCalendarConfirmTapped) }, + onComplete: { store.send(.view(.monthCalendarConfirmTapped)) }, isDateEnabled: store.isCalendarDateEnabled ) } @@ -74,7 +74,7 @@ public struct MakeGoalView: View { item: $store.modal, onAction: { action in if case let .confirmWithIndex(index) = action { - store.send(.modalConfirmTapped(index)) + store.send(.view(.modalConfirmTapped(index))) } } ) @@ -92,7 +92,7 @@ private extension MakeGoalView { title: store.mode.title, type: .back ), onAction: { _ in - store.send(.navigationBackButtonTapped) + store.send(.view(.navigationBackButtonTapped)) } ) } @@ -108,7 +108,7 @@ private extension MakeGoalView { shape: .circle, lineWidth: LineWidth.m ) - .onTapGesture { store.send(.emojiButtonTapped) } + .onTapGesture { store.send(.view(.emojiButtonTapped)) } .overlay(alignment: .bottomTrailing) { TXButton( shape: .circle( @@ -122,7 +122,7 @@ private extension MakeGoalView { backgroundColor: Color.Common.white ) ), - onTap: { } + onTap: { store.send(.view(.emojiButtonTapped)) } ) .insideBorder( Color.Gray.gray500, @@ -171,14 +171,15 @@ private extension MakeGoalView { TXTab( style: .button(PeriodItem.allCases), selectedItem: selectedPeriodItem, - onSelect: { store.send(.periodTabSelected($0)) } + onSelect: { store.send(.view(.periodTabSelected($0))) } ) Spacer() if store.showPeriodCount { - valueText(store.periodCountText) - dropDownButton { store.send(.periodSelected) } + dropDownButton(text: store.periodCountText) { + store.send(.view(.periodSelected)) + } } } } @@ -191,8 +192,9 @@ private extension MakeGoalView { Spacer() - valueText(store.startDateText) - dropDownButton { store.send(.startDateTapped) } + dropDownButton(text: store.startDateText) { + store.send(.view(.startDateTapped)) + } } .frame(height: 32) .padding(.vertical, 16) @@ -216,8 +218,9 @@ private extension MakeGoalView { Spacer() - valueText(store.endDateText) - dropDownButton { store.send(.endDateTapped) } + dropDownButton(text: store.endDateText) { + store.send(.view(.endDateTapped)) + } } .padding(.vertical, 21.5) } @@ -229,7 +232,7 @@ private extension MakeGoalView { size: .l, state: store.completeButtonDisabled ? .disabled : .standard ) - ) { store.send(.completeButtonTapped) } + ) { store.send(.view(.completeButtonTapped)) } } var divider: some View { @@ -239,12 +242,19 @@ private extension MakeGoalView { .padding(.vertical, -1) } - func dropDownButton(_ action: @escaping () -> Void) -> some View { - Button { - action() - } label: { + func dropDownButton( + text: String, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: 0) { + Text(text) + .typography(.b2_14r) + .foregroundStyle(Color.Gray.gray500) Image.Icon.Symbol.arrow2Down } + .onTapGesture { + action() + } } func sectionTitleText(_ text: String) -> some View { @@ -267,7 +277,7 @@ private extension MakeGoalView { TXButton( shape: .rect(style: .basic(text: "완료"), size: .l, state: .standard), - onTap: { store.send(.periodSheetCompleteTapped) } + onTap: { store.send(.view(.periodSheetCompleteTapped)) } ) .padding(.top, 32) .padding(.horizontal, 20) @@ -283,7 +293,7 @@ private extension MakeGoalView { size: .s, state: store.goalData.repeatCycle == .weekly ? .standard : .line ), - onTap: { store.send(.periodSheetWeeklyTapped) } + onTap: { store.send(.view(.periodSheetWeeklyTapped)) } ) TXButton( @@ -292,7 +302,7 @@ private extension MakeGoalView { size: .s, state: store.goalData.repeatCycle == .monthly ? .standard : .line ), - onTap: { store.send(.periodSheetMonthlyTapped) } + onTap: { store.send(.view(.periodSheetMonthlyTapped)) } ) } } @@ -308,7 +318,7 @@ private extension MakeGoalView { ), state: store.isMinusEnable ? .standard : .disabled ), - onTap: { store.send(.periodSheetMinusTapped) } + onTap: { store.send(.view(.periodSheetMinusTapped)) } ) .disabled(!store.isMinusEnable) @@ -323,7 +333,7 @@ private extension MakeGoalView { ), state: store.isPlusEnable ? .standard : .disabled ), - onTap: { store.send(.periodSheetPlusTapped) } + onTap: { store.send(.view(.periodSheetPlusTapped)) } ) .disabled(!store.isPlusEnable) } diff --git a/Projects/Feature/MakeGoal/Testing/Sources/Source.swift b/Projects/Feature/MakeGoal/Testing/Sources/Source.swift index bc1712c4..f7da3195 100644 --- a/Projects/Feature/MakeGoal/Testing/Sources/Source.swift +++ b/Projects/Feature/MakeGoal/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 02/22/26. // -/// Remove Or Edit +/// Stable perf seed names for the MakeGoal example app. +public enum MakeGoalPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/Notification/Example/Sources/NotificationApp.swift b/Projects/Feature/Notification/Example/Sources/NotificationApp.swift index 3d216b9d..c8af5da8 100644 --- a/Projects/Feature/Notification/Example/Sources/NotificationApp.swift +++ b/Projects/Feature/Notification/Example/Sources/NotificationApp.swift @@ -9,10 +9,15 @@ import ComposableArchitecture import FeatureNotification import FeatureNotificationInterface import SharedDesignSystem +import SharedPerfTestingSupport import SwiftUI @main struct NotificationApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { NotificationView( @@ -25,6 +30,8 @@ struct NotificationApp: App { } ) ) + .perfRoot("notification") + .perfReadyMarker("notification") } } } @@ -32,6 +39,8 @@ struct NotificationApp: App { // MARK: - Mock Data extension NotificationApp { + static let referenceDate = Date(timeIntervalSince1970: 1_772_496_000) + static let mockNotifications: IdentifiedArrayOf = [ NotificationItem( id: 1, @@ -40,7 +49,7 @@ extension NotificationApp { message: "닉네임길어도될까님과 연결됐어요!", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 2, @@ -49,7 +58,7 @@ extension NotificationApp { message: "닉네임길어도될까님의 오늘 하루가 등록됐어요. 확인해 볼까요?", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 3, @@ -58,7 +67,7 @@ extension NotificationApp { message: "닉네임길어도될까님이 찔렀어요! 오늘 하루도 파이팅~", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 4, @@ -67,7 +76,7 @@ extension NotificationApp { message: "닉네임길어도될까님이 끝냄 인증샷을 올렸네요! 보러 가봐요!", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 5, @@ -76,7 +85,7 @@ extension NotificationApp { message: "닉네임길어도될까님이 내게 반응을 남겼어요. 보러 가봐요!", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -1, to: referenceDate) ?? referenceDate ), NotificationItem( id: 6, @@ -85,7 +94,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -2, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -2, to: referenceDate) ?? referenceDate ), NotificationItem( id: 7, @@ -94,7 +103,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -3, to: referenceDate) ?? referenceDate ), NotificationItem( id: 8, @@ -103,7 +112,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -5, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -5, to: referenceDate) ?? referenceDate ), NotificationItem( id: 9, @@ -112,7 +121,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -7, to: referenceDate) ?? referenceDate ), NotificationItem( id: 10, @@ -121,7 +130,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -10, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -10, to: referenceDate) ?? referenceDate ), // 14일 초과 - 필터링되어 표시되지 않음 NotificationItem( @@ -131,7 +140,7 @@ extension NotificationApp { message: "이 알림은 15일 전이라 표시되지 않아요", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -15, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -15, to: referenceDate) ?? referenceDate ), NotificationItem( id: 12, @@ -140,7 +149,7 @@ extension NotificationApp { message: "이 알림은 20일 전이라 표시되지 않아요", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -20, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -20, to: referenceDate) ?? referenceDate ) ] } diff --git a/Projects/Feature/Notification/ExampleUITests/Sources/NotificationExampleSmokeTests.swift b/Projects/Feature/Notification/ExampleUITests/Sources/NotificationExampleSmokeTests.swift new file mode 100644 index 00000000..bd4835ca --- /dev/null +++ b/Projects/Feature/Notification/ExampleUITests/Sources/NotificationExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class NotificationExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("notification") + } +} diff --git a/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift b/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift index c99c3373..76f116ec 100644 --- a/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift +++ b/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift @@ -19,6 +19,7 @@ public struct NotificationReducer { public struct State: Equatable { public var notifications: IdentifiedArrayOf public var isLoading: Bool + public var isFetchFailed: Bool public var isLoadingMore: Bool public var hasNext: Bool public var lastId: Int64? @@ -26,12 +27,14 @@ public struct NotificationReducer { public init( notifications: IdentifiedArrayOf = [], isLoading: Bool = false, + isFetchFailed: Bool = false, isLoadingMore: Bool = false, hasNext: Bool = false, lastId: Int64? = nil ) { self.notifications = notifications self.isLoading = isLoading + self.isFetchFailed = isFetchFailed self.isLoadingMore = isLoadingMore self.hasNext = hasNext self.lastId = lastId @@ -42,26 +45,31 @@ public struct NotificationReducer { public enum Action: BindableAction { case binding(BindingAction) - // MARK: - User Action - case backButtonTapped - case notificationTapped(NotificationItem) - case loadMore - - // MARK: - Lifecycle - case onAppear + // MARK: - View + public enum View: Equatable { + case onAppear + case backButtonTapped + case notificationTapped(NotificationItem) + case loadMore + case dataRetryTapped + } - // MARK: - Internal - case fetchListResponse(Result) - case fetchMoreResponse(Result) - case markAsReadResponse(NotificationItem, Result) + // MARK: - Response + public enum Response { + case fetchListResponse(Result) + case fetchMoreResponse(Result) + case markAsReadResponse(NotificationItem, Result) + } // MARK: - Delegate - case delegate(Delegate) - public enum Delegate: Equatable { case navigateBack case notificationSelected(NotificationItem) } + + case view(View) + case response(Response) + case delegate(Delegate) } public init(reducer: Reduce) { diff --git a/Projects/Feature/Notification/Project.swift b/Projects/Feature/Notification/Project.swift index e4b68425..a18600d4 100644 --- a/Projects/Feature/Notification/Project.swift +++ b/Projects/Feature/Notification/Project.swift @@ -37,6 +37,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .notification) ] ) diff --git a/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift b/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift index 2ffcef48..84b01950 100644 --- a/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift +++ b/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift @@ -24,7 +24,6 @@ extension NotificationReducer { // MARK: - Core Reduce Logic -// swiftlint:disable:next function_body_length private func reduceCore( state: inout NotificationReducer.State, action: NotificationReducer.Action, @@ -34,11 +33,52 @@ private func reduceCore( case .binding: return .none + case .view(let viewAction): + return reduceView(state: &state, action: viewAction, notificationClient: notificationClient) + + case .response(let responseAction): + return reduceResponse(state: &state, action: responseAction) + + case .delegate: + return .none + } +} + +// MARK: - View + +private func reduceView( + state: inout NotificationReducer.State, + action: NotificationReducer.Action.View, + notificationClient: NotificationClient +) -> Effect { + switch action { case .onAppear: return handleOnAppear(state: &state, notificationClient: notificationClient) + case .backButtonTapped: + return .send(.delegate(.navigateBack)) + + case .notificationTapped(let item): + return handleNotificationTapped(item: item, notificationClient: notificationClient) + + case .loadMore: + return handleLoadMore(state: &state, notificationClient: notificationClient) + + case .dataRetryTapped: + return handleOnAppear(state: &state, notificationClient: notificationClient) + } +} + +// MARK: - Response + +private func reduceResponse( + state: inout NotificationReducer.State, + action: NotificationReducer.Action.Response +) -> Effect { + switch action { case .fetchListResponse(.success(let result)): state.isLoading = false + state.isFetchFailed = false state.notifications = IdentifiedArray( uniqueElements: result.notifications.map { NotificationItem(from: $0) } ) @@ -48,11 +88,9 @@ private func reduceCore( case .fetchListResponse(.failure): state.isLoading = false + state.isFetchFailed = true return .none - case .loadMore: - return handleLoadMore(state: &state, notificationClient: notificationClient) - case .fetchMoreResponse(.success(let result)): state.isLoadingMore = false let newItems = result.notifications.map { NotificationItem(from: $0) } @@ -67,21 +105,12 @@ private func reduceCore( state.isLoadingMore = false return .none - case .backButtonTapped: - return .send(.delegate(.navigateBack)) - - case .notificationTapped(let item): - return handleNotificationTapped(item: item, notificationClient: notificationClient) - case .markAsReadResponse(let item, .success): state.notifications.remove(id: item.id) return .send(.delegate(.notificationSelected(item))) case .markAsReadResponse(let item, .failure): return .send(.delegate(.notificationSelected(item))) - - case .delegate: - return .none } } @@ -93,14 +122,15 @@ private func handleOnAppear( ) -> Effect { guard !state.isLoading else { return .none } state.isLoading = true + state.isFetchFailed = false return .run { send in do { let result = try await notificationClient.fetchList(nil, 10) - await send(.fetchListResponse(.success(result))) + await send(.response(.fetchListResponse(.success(result)))) try? await notificationClient.markAllAsRead() } catch { - await send(.fetchListResponse(.failure(error))) + await send(.response(.fetchListResponse(.failure(error)))) } } } @@ -119,9 +149,9 @@ private func handleLoadMore( return .run { send in do { let result = try await notificationClient.fetchList(lastId, 20) - await send(.fetchMoreResponse(.success(result))) + await send(.response(.fetchMoreResponse(.success(result)))) } catch { - await send(.fetchMoreResponse(.failure(error))) + await send(.response(.fetchMoreResponse(.failure(error)))) } } } @@ -137,9 +167,9 @@ private func handleNotificationTapped( return .run { send in do { try await notificationClient.markAsRead(item.id) - await send(.markAsReadResponse(item, .success(()))) + await send(.response(.markAsReadResponse(item, .success(())))) } catch { - await send(.markAsReadResponse(item, .failure(error))) + await send(.response(.markAsReadResponse(item, .failure(error)))) } } } diff --git a/Projects/Feature/Notification/Sources/NotificationView.swift b/Projects/Feature/Notification/Sources/NotificationView.swift index 3ccd68b3..2c536162 100644 --- a/Projects/Feature/Notification/Sources/NotificationView.swift +++ b/Projects/Feature/Notification/Sources/NotificationView.swift @@ -23,19 +23,26 @@ public struct NotificationView: View { VStack(spacing: 0) { navigationBar - ZStack { - if filteredNotifications.isEmpty { - emptyView - } else { - contentView + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) } + } else { + ZStack { + if filteredNotifications.isEmpty { + emptyView + } else { + contentView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } .ignoresSafeArea(.container, edges: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.Common.white) .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .toolbar(.hidden, for: .navigationBar) .txLoading(isPresented: store.isLoading && store.notifications.isEmpty) @@ -49,7 +56,7 @@ private extension NotificationView { TXNavigationBar(style: .subTitle(title: "알림", type: .back)) { action in switch action { case .backTapped: - store.send(.backButtonTapped) + store.send(.view(.backButtonTapped)) default: break @@ -118,7 +125,7 @@ private extension NotificationView { .onAppear { // 마지막 3개 아이템 중 하나가 보이면 미리 로드 if index >= filteredNotifications.count - 3 { - store.send(.loadMore) + store.send(.view(.loadMore)) } } } @@ -133,7 +140,7 @@ private extension NotificationView { func notificationListItem(_ item: NotificationItem, isLast: Bool) -> some View { Button { - store.send(.notificationTapped(item)) + store.send(.view(.notificationTapped(item))) } label: { HStack(spacing: Spacing.spacing9) { HStack(alignment: .firstTextBaseline, spacing: 0) { diff --git a/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift b/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift index b1070814..749ccf87 100644 --- a/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift +++ b/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift @@ -9,6 +9,7 @@ import ComposableArchitecture import DomainNotificationInterface import DomainOnboardingInterface import FeatureOnboarding +import SharedPerfTestingSupport import SwiftUI @main @@ -16,6 +17,7 @@ struct OnboardingApp: App { let store: StoreOf init() { + UITestMode.configureApplication() self.store = Store( initialState: OnboardingCoordinator.State( myInviteCode: "KDJ34923" @@ -31,6 +33,8 @@ struct OnboardingApp: App { var body: some Scene { WindowGroup { OnboardingCoordinatorView(store: store) + .perfRoot("onboarding") + .perfReadyMarker("onboarding") .onOpenURL { url in if let code = parseInviteCode(from: url) { store.send(.deepLinkReceived(code: code)) diff --git a/Projects/Feature/Onboarding/ExampleUITests/Sources/OnboardingExampleSmokeTests.swift b/Projects/Feature/Onboarding/ExampleUITests/Sources/OnboardingExampleSmokeTests.swift new file mode 100644 index 00000000..1c54e9ad --- /dev/null +++ b/Projects/Feature/Onboarding/ExampleUITests/Sources/OnboardingExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class OnboardingExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("onboarding") + } +} diff --git a/Projects/Feature/Onboarding/Project.swift b/Projects/Feature/Onboarding/Project.swift index 6cf71a21..b755e4ce 100644 --- a/Projects/Feature/Onboarding/Project.swift +++ b/Projects/Feature/Onboarding/Project.swift @@ -54,6 +54,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .onboarding) ] ) diff --git a/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputReducer.swift b/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputReducer.swift index 213678c4..f996851c 100644 --- a/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputReducer.swift +++ b/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputReducer.swift @@ -51,24 +51,30 @@ public struct OnboardingCodeInputReducer { // MARK: - Binding case binding(BindingAction) - // MARK: - User Action - case backButtonTapped - case codeInputChanged(String) - case copyMyCodeButtonTapped - case pasteCodeButtonTapped - case completeButtonTapped - case codeFieldTapped - - // MARK: - API Response - case connectCoupleResponse(Result) - - // MARK: - Delegate - case delegate(Delegate) + // MARK: - View (사용자 이벤트) + public enum View: Equatable { + case backButtonTapped + case codeInputChanged(String) + case copyMyCodeButtonTapped + case pasteCodeButtonTapped + case completeButtonTapped + case codeFieldTapped + } + + // MARK: - Response (비동기 응답) + public enum Response { + case connectCoupleResponse(Result) + } + // MARK: - Delegate (부모에게 알림) public enum Delegate: Equatable { case navigateBack case coupleConnected } + + case view(View) + case response(Response) + case delegate(Delegate) } public init() {} @@ -80,77 +86,105 @@ public struct OnboardingCodeInputReducer { case .binding: return .none - case .backButtonTapped: - return .send(.delegate(.navigateBack)) - - case let .codeInputChanged(newCode): - let filtered = newCode.filter { $0.isNumber || $0.isLetter } - let uppercased = String(filtered.prefix(State.codeLength)).uppercased() - state.receivedCode = uppercased + case .view(let viewAction): + return reduceView(state: &state, action: viewAction) - if uppercased.count < State.codeLength { - state.focusedIndex = uppercased.count - } else { - state.focusedIndex = nil - } - return .none + case .response(let responseAction): + return reduceResponse(state: &state, action: responseAction) - case .copyMyCodeButtonTapped: - UIPasteboard.general.string = state.myInviteCode - state.toast = .check(message: "초대 코드가 복사되었어요") + case .delegate: return .none + } + } + } +} - case .pasteCodeButtonTapped: - guard let pastedString = UIPasteboard.general.string else { return .none } - let filtered = pastedString.filter { $0.isNumber || $0.isLetter } - let uppercased = String(filtered.prefix(State.codeLength)).uppercased() - state.receivedCode = uppercased - if uppercased.count < State.codeLength { - state.focusedIndex = uppercased.count - } else { - state.focusedIndex = nil +// MARK: - View + +private extension OnboardingCodeInputReducer { + func reduceView( + state: inout State, + action: Action.View + ) -> Effect { + switch action { + case .backButtonTapped: + return .send(.delegate(.navigateBack)) + + case let .codeInputChanged(newCode): + let filtered = newCode.filter { $0.isNumber || $0.isLetter } + let uppercased = String(filtered.prefix(State.codeLength)).uppercased() + state.receivedCode = uppercased + + if uppercased.count < State.codeLength { + state.focusedIndex = uppercased.count + } else { + state.focusedIndex = nil + } + return .none + + case .copyMyCodeButtonTapped: + UIPasteboard.general.string = state.myInviteCode + state.toast = .check(message: "초대 코드가 복사되었어요") + return .none + + case .pasteCodeButtonTapped: + guard let pastedString = UIPasteboard.general.string else { return .none } + let filtered = pastedString.filter { $0.isNumber || $0.isLetter } + let uppercased = String(filtered.prefix(State.codeLength)).uppercased() + state.receivedCode = uppercased + if uppercased.count < State.codeLength { + state.focusedIndex = uppercased.count + } else { + state.focusedIndex = nil + } + return .none + + case .completeButtonTapped: + guard state.isCodeComplete, !state.isLoading else { return .none } + state.isLoading = true + let inviteCode = state.receivedCode + return .run { send in + do { + try await onboardingClient.connectCouple(inviteCode) + await send(.response(.connectCoupleResponse(.success(())))) + } catch { + await send(.response(.connectCoupleResponse(.failure(error)))) } - return .none + } - case .completeButtonTapped: - guard state.isCodeComplete, !state.isLoading else { return .none } - state.isLoading = true - let inviteCode = state.receivedCode - return .run { send in - do { - try await onboardingClient.connectCouple(inviteCode) - await send(.connectCoupleResponse(.success(()))) - } catch { - await send(.connectCoupleResponse(.failure(error))) - } - } + case .codeFieldTapped: + state.focusedIndex = min(state.receivedCode.count, State.codeLength - 1) + return .none + } + } +} - case .connectCoupleResponse(.success): - state.isLoading = false - return .send(.delegate(.coupleConnected)) - - case let .connectCoupleResponse(.failure(error)): - state.isLoading = false - if let onboardingError = error as? OnboardingError { - switch onboardingError { - case .inviteCodeNotFound: - state.toast = .fit(message: "초대 코드를 찾을 수 없어요") - - default: - state.toast = .fit(message: "연결에 실패했어요. 다시 시도해주세요") - } - } else { +// MARK: - Response + +private extension OnboardingCodeInputReducer { + func reduceResponse( + state: inout State, + action: Action.Response + ) -> Effect { + switch action { + case .connectCoupleResponse(.success): + state.isLoading = false + return .send(.delegate(.coupleConnected)) + + case let .connectCoupleResponse(.failure(error)): + state.isLoading = false + if let onboardingError = error as? OnboardingError { + switch onboardingError { + case .inviteCodeNotFound: + state.toast = .fit(message: "초대 코드를 찾을 수 없어요") + + default: state.toast = .fit(message: "연결에 실패했어요. 다시 시도해주세요") } - return .none - - case .codeFieldTapped: - state.focusedIndex = min(state.receivedCode.count, State.codeLength - 1) - return .none - - case .delegate: - return .none + } else { + state.toast = .fit(message: "연결에 실패했어요. 다시 시도해주세요") } + return .none } } } diff --git a/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputView.swift b/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputView.swift index f515d498..7f54c39a 100644 --- a/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputView.swift +++ b/Projects/Feature/Onboarding/Sources/CodeInput/OnboardingCodeInputView.swift @@ -23,7 +23,7 @@ public struct OnboardingCodeInputView: View { VStack(spacing: 0) { TXNavigationBar(style: .iconOnly(.back)) { action in if action == .backTapped { - store.send(.backButtonTapped) + store.send(.view(.backButtonTapped)) } } @@ -106,7 +106,7 @@ private extension OnboardingCodeInputView { var copyButton: some View { Button { - store.send(.copyMyCodeButtonTapped) + store.send(.view(.copyMyCodeButtonTapped)) } label: { Image.Icon.Symbol.copy .resizable() @@ -150,11 +150,11 @@ private extension OnboardingCodeInputView { .overlay { EditMenuOverlay( onTap: { - store.send(.codeFieldTapped) + store.send(.view(.codeFieldTapped)) isTextFieldFocused = true }, onPaste: { - store.send(.pasteCodeButtonTapped) + store.send(.view(.pasteCodeButtonTapped)) } ) } @@ -166,7 +166,7 @@ private extension OnboardingCodeInputView { "", text: Binding( get: { store.receivedCode }, - set: { store.send(.codeInputChanged($0)) } + set: { store.send(.view(.codeInputChanged($0))) } ) ) .keyboardType(.asciiCapable) @@ -219,7 +219,7 @@ private extension OnboardingCodeInputView { size: .l, state: store.isCodeComplete ? .standard : .disabled ), - onTap: { store.send(.completeButtonTapped) } + onTap: { store.send(.view(.completeButtonTapped)) } ) .disabled(!store.isCodeComplete) } diff --git a/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectReducer.swift b/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectReducer.swift index 122a27c7..e03dd48b 100644 --- a/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectReducer.swift +++ b/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectReducer.swift @@ -42,23 +42,24 @@ public struct OnboardingConnectReducer { // MARK: - Binding case binding(BindingAction) - // MARK: - User Action - case directConnectCardTapped - case sendInvitationButtonTapped - case logoutButtonTapped - case restoreCoupleButtonTapped - - // MARK: - Update State - case shareSheetDismissed - case restoreCoupleSheetDismissed - - // MARK: - Delegate - case delegate(Delegate) + // MARK: - View (사용자 이벤트) + public enum View: Equatable { + case directConnectCardTapped + case sendInvitationButtonTapped + case logoutButtonTapped + case restoreCoupleButtonTapped + case shareSheetDismissed + case restoreCoupleSheetDismissed + } + // MARK: - Delegate (부모에게 알림) public enum Delegate: Equatable { case navigateToCodeInput case logoutRequested } + + case view(View) + case delegate(Delegate) } public init() {} @@ -70,31 +71,45 @@ public struct OnboardingConnectReducer { case .binding: return .none - case .directConnectCardTapped: - return .send(.delegate(.navigateToCodeInput)) + case .view(let viewAction): + return reduceView(state: &state, action: viewAction) - case .sendInvitationButtonTapped: - state.isShareSheetPresented = true + case .delegate: return .none + } + } + } +} - case .logoutButtonTapped: - return .send(.delegate(.logoutRequested)) +// MARK: - View - case .restoreCoupleButtonTapped: - state.isRestoreCoupleSheetPresented = true - return .none +private extension OnboardingConnectReducer { + func reduceView( + state: inout State, + action: Action.View + ) -> Effect { + switch action { + case .directConnectCardTapped: + return .send(.delegate(.navigateToCodeInput)) - case .shareSheetDismissed: - state.isShareSheetPresented = false - return .none + case .sendInvitationButtonTapped: + state.isShareSheetPresented = true + return .none - case .restoreCoupleSheetDismissed: - state.isRestoreCoupleSheetPresented = false - return .none + case .logoutButtonTapped: + return .send(.delegate(.logoutRequested)) - case .delegate: - return .none - } + case .restoreCoupleButtonTapped: + state.isRestoreCoupleSheetPresented = true + return .none + + case .shareSheetDismissed: + state.isShareSheetPresented = false + return .none + + case .restoreCoupleSheetDismissed: + state.isRestoreCoupleSheetPresented = false + return .none } } } diff --git a/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectView.swift b/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectView.swift index 9c6efc9d..a4954063 100644 --- a/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectView.swift +++ b/Projects/Feature/Onboarding/Sources/Connect/OnboardingConnectView.swift @@ -25,7 +25,7 @@ public struct OnboardingConnectView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.Common.white) .sheet(isPresented: $store.isShareSheetPresented) { - store.send(.shareSheetDismissed) + store.send(.view(.shareSheetDismissed)) } content: { ShareSheet(activityItems: [store.shareContent]) .presentationDetents([.medium, .large]) @@ -84,7 +84,7 @@ private extension OnboardingConnectView { var topAppBar: some View { HStack { Button { - store.send(.logoutButtonTapped) + store.send(.view(.logoutButtonTapped)) } label: { Image.Icon.Symbol.logout .resizable() @@ -131,7 +131,7 @@ private extension OnboardingConnectView { var sendInvitationButton: some View { Button { - store.send(.sendInvitationButtonTapped) + store.send(.view(.sendInvitationButtonTapped)) } label: { Text("초대장 보내기") .typography(.t2_16b) @@ -146,7 +146,7 @@ private extension OnboardingConnectView { var directConnectCard: some View { Button { - store.send(.directConnectCardTapped) + store.send(.view(.directConnectCardTapped)) } label: { HStack(spacing: 3) { VStack(alignment: .leading, spacing: 3) { @@ -195,7 +195,7 @@ private extension OnboardingConnectView { var restoreCoupleButton: some View { Button { - store.send(.restoreCoupleButtonTapped) + store.send(.view(.restoreCoupleButtonTapped)) } label: { HStack(spacing: 0) { Text("해지한 커플 복구하려면?") diff --git a/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayReducer.swift b/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayReducer.swift index 12c1f84a..f9e17424 100644 --- a/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayReducer.swift +++ b/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayReducer.swift @@ -49,34 +49,38 @@ public struct OnboardingDdayReducer { // MARK: - Binding case binding(BindingAction) - // MARK: - LifeCycle - case onAppear - - // MARK: - User Action - case backButtonTapped - case dateSelectorTapped - case completeButtonTapped - - // MARK: - Update State - case calendarCompleted - - // MARK: - API Response - case setAnniversaryResponse(Result) + // MARK: - View + public enum View: Equatable { + case onAppear + case backButtonTapped + case dateSelectorTapped + case completeButtonTapped + case modalConfirmTapped + case calendarCompleted + } - // MARK: - Partner Polling - case pollingTick - case pollingResult(Result) + // MARK: - Internal + public enum Internal: Equatable { + case calendarCompleted + case pollingTick + } - // MARK: - Modal - case modalConfirmTapped + // MARK: - Response + public enum Response { + case setAnniversaryResponse(Result) + case pollingResult(Result) + } // MARK: - Delegate - case delegate(Delegate) - public enum Delegate: Equatable { case navigateBack case ddayCompleted } + + case view(View) + case `internal`(Internal) + case response(Response) + case delegate(Delegate) } public init() {} @@ -88,72 +92,111 @@ public struct OnboardingDdayReducer { case .binding: return .none - case .onAppear: - return .run { [clock] send in - for await _ in clock.timer(interval: .seconds(3)) { - await send(.pollingTick) - } - } - .cancellable(id: CancelID.polling, cancelInFlight: true) + case .view(let viewAction): + return reduceView(state: &state, action: viewAction) - case .backButtonTapped: - return .send(.delegate(.navigateBack)) + case .internal(let internalAction): + return reduceInternal(state: &state, action: internalAction) - case .dateSelectorTapped: - state.showCalendarSheet = true - return .none + case .response(let responseAction): + return reduceResponse(state: &state, action: responseAction) - case .calendarCompleted: - state.showCalendarSheet = false + case .delegate: return .none + } + } + } +} - case .completeButtonTapped: - guard let date = state.selectedDate.date, !state.isLoading else { return .none } - state.isLoading = true - return .merge( - .cancel(id: CancelID.polling), - .run { send in - do { - try await onboardingClient.setAnniversary(date) - await send(.setAnniversaryResponse(.success(()))) - } catch { - await send(.setAnniversaryResponse(.failure(error))) - } - } - ) - - case .setAnniversaryResponse(.success): - state.isLoading = false - return .send(.delegate(.ddayCompleted)) - - case let .setAnniversaryResponse(.failure(error)): - state.isLoading = false - if let onboardingError = error as? OnboardingError, - onboardingError == .alreadyOnboarded { - state.modal = .info( - image: .Icon.Illustration.heart, - title: "메이트가 기념일을 등록했어요!", - subtitle: "이미 우리의 기념일이 저장됐어요.\n이제 함께 시작해봐요 :)", - leftButtonText: "확인", - rightButtonText: "시작하기" - ) - return .cancel(id: CancelID.polling) +// MARK: - View + +private extension OnboardingDdayReducer { + func reduceView( + state: inout State, + action: Action.View + ) -> Effect { + switch action { + case .onAppear: + return .run { [clock] send in + for await _ in clock.timer(interval: .seconds(3)) { + await send(.internal(.pollingTick)) } - state.toast = .fit(message: "기념일 등록에 실패했어요. 다시 시도해주세요") - return .none + } + .cancellable(id: CancelID.polling, cancelInFlight: true) + + case .backButtonTapped: + return .send(.delegate(.navigateBack)) - case .pollingTick: - return .run { [onboardingClient] send in + case .dateSelectorTapped: + state.showCalendarSheet = true + return .none + + case .completeButtonTapped: + guard let date = state.selectedDate.date, !state.isLoading else { return .none } + state.isLoading = true + return .merge( + .cancel(id: CancelID.polling), + .run { send in do { - let status = try await onboardingClient.fetchStatus() - await send(.pollingResult(.success(status))) + try await onboardingClient.setAnniversary(date) + await send(.response(.setAnniversaryResponse(.success(())))) } catch { - await send(.pollingResult(.failure(error))) + await send(.response(.setAnniversaryResponse(.failure(error)))) } } + ) + + case .modalConfirmTapped: + state.modal = nil + return .send(.delegate(.ddayCompleted)) - case let .pollingResult(.success(status)): - guard status == .completed else { return .none } + case .calendarCompleted: + return .send(.internal(.calendarCompleted)) + } + } +} + +// MARK: - Internal + +private extension OnboardingDdayReducer { + func reduceInternal( + state: inout State, + action: Action.Internal + ) -> Effect { + switch action { + case .calendarCompleted: + state.showCalendarSheet = false + return .none + + case .pollingTick: + return .run { [onboardingClient] send in + do { + let status = try await onboardingClient.fetchStatus() + await send(.response(.pollingResult(.success(status)))) + } catch { + await send(.response(.pollingResult(.failure(error)))) + } + } + } + } +} + +// MARK: - Response + +private extension OnboardingDdayReducer { + func reduceResponse( + state: inout State, + action: Action.Response + ) -> Effect { + switch action { + case .setAnniversaryResponse(.success): + state.isLoading = false + return .send(.delegate(.ddayCompleted)) + + case let .setAnniversaryResponse(.failure(error)): + state.isLoading = false + if let onboardingError = error as? OnboardingError, + onboardingError == .alreadyOnboarded { state.modal = .info( image: .Icon.Illustration.heart, title: "메이트가 기념일을 등록했어요!", @@ -162,17 +205,23 @@ public struct OnboardingDdayReducer { rightButtonText: "시작하기" ) return .cancel(id: CancelID.polling) - - case .pollingResult(.failure): - return .none - - case .modalConfirmTapped: - state.modal = nil - return .send(.delegate(.ddayCompleted)) - - case .delegate: - return .none } + state.toast = .fit(message: "기념일 등록에 실패했어요. 다시 시도해주세요") + return .none + + case let .pollingResult(.success(status)): + guard status == .completed else { return .none } + state.modal = .info( + image: .Icon.Illustration.heart, + title: "메이트가 기념일을 등록했어요!", + subtitle: "이미 우리의 기념일이 저장됐어요.\n이제 함께 시작해봐요 :)", + leftButtonText: "확인", + rightButtonText: "시작하기" + ) + return .cancel(id: CancelID.polling) + + case .pollingResult(.failure): + return .none } } } diff --git a/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift b/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift index 55b6faf1..383b7e82 100644 --- a/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift +++ b/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift @@ -21,7 +21,7 @@ public struct OnboardingDdayView: View { VStack(spacing: 0) { TXNavigationBar(style: .iconOnly(.back)) { action in if action == .backTapped { - store.send(.backButtonTapped) + store.send(.view(.backButtonTapped)) } } @@ -49,7 +49,7 @@ public struct OnboardingDdayView: View { ) { TXCalendarBottomSheet( selectedDate: $store.selectedDate, - onComplete: { store.send(.calendarCompleted) }, + onComplete: { store.send(.view(.calendarCompleted)) }, isDateEnabled: { item in guard let components = item.dateComponents, let date = Calendar.current.date(from: components) else { @@ -60,11 +60,11 @@ public struct OnboardingDdayView: View { ) } .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .txLoading(isPresented: store.isLoading) .txModal(item: $store.modal) { _ in - store.send(.modalConfirmTapped) + store.send(.view(.modalConfirmTapped)) } } } @@ -83,7 +83,7 @@ private extension OnboardingDdayView { var dateSelectorSection: some View { Button { - store.send(.dateSelectorTapped) + store.send(.view(.dateSelectorTapped)) } label: { dateSelectorContent } @@ -142,7 +142,7 @@ private extension OnboardingDdayView { size: .l, state: store.isDateSelected ? .standard : .disabled ), - onTap: { store.send(.completeButtonTapped) } + onTap: { store.send(.view(.completeButtonTapped)) } ) } } diff --git a/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileReducer.swift b/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileReducer.swift index b5a0709d..5d32f04a 100644 --- a/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileReducer.swift +++ b/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileReducer.swift @@ -42,20 +42,26 @@ public struct OnboardingProfileReducer { // MARK: - Binding case binding(BindingAction) - // MARK: - User Action - case backButtonTapped - case completeButtonTapped - - // MARK: - API Response - case registerProfileResponse(Result) + // MARK: - View (사용자 이벤트) + public enum View: Equatable { + case backButtonTapped + case completeButtonTapped + } - // MARK: - Delegate - case delegate(Delegate) + // MARK: - Response (비동기 응답) + public enum Response { + case registerProfileResponse(Result) + } + // MARK: - Delegate (부모에게 알림) public enum Delegate: Equatable { case navigateBack case profileCompleted } + + case view(View) + case response(Response) + case delegate(Delegate) } public init() {} @@ -67,52 +73,80 @@ public struct OnboardingProfileReducer { case .binding: return .none - case .backButtonTapped: - return .send(.delegate(.navigateBack)) + case .view(let viewAction): + return reduceView(state: &state, action: viewAction) - case .completeButtonTapped: - guard !state.isLoading else { return .none } + case .response(let responseAction): + return reduceResponse(state: &state, action: responseAction) - // 비속어 체크 - if state.containsProfanity { - state.toast = .fit(message: "닉네임에 비속어가 포함되어 있습니다.") - return .none - } + case .delegate: + return .none + } + } + } +} - // 길이 체크 - guard state.isNicknameLengthValid else { - state.toast = .fit(message: "2자에서 8자 이내로 닉네임을 입력해주세요.") - return .none - } +// MARK: - View - state.isLoading = true - let nickname = state.nickname - return .run { send in - do { - try await onboardingClient.registerProfile(nickname) - await send(.registerProfileResponse(.success(()))) - } catch { - await send(.registerProfileResponse(.failure(error))) - } - } +private extension OnboardingProfileReducer { + func reduceView( + state: inout State, + action: Action.View + ) -> Effect { + switch action { + case .backButtonTapped: + return .send(.delegate(.navigateBack)) - case .registerProfileResponse(.success): - state.isLoading = false - return .send(.delegate(.profileCompleted)) + case .completeButtonTapped: + guard !state.isLoading else { return .none } - case let .registerProfileResponse(.failure(error)): - state.isLoading = false - // 이미 온보딩이 완료된 경우 (G4000), 성공과 동일하게 처리 - if let onboardingError = error as? OnboardingError, - onboardingError == .alreadyOnboarded { - return .send(.delegate(.profileCompleted)) - } - state.toast = .fit(message: "프로필 등록에 실패했어요. 다시 시도해주세요") + // 비속어 체크 + if state.containsProfanity { + state.toast = .fit(message: "닉네임에 비속어가 포함되어 있습니다.") return .none + } - case .delegate: + // 길이 체크 + guard state.isNicknameLengthValid else { + state.toast = .fit(message: "2자에서 8자 이내로 닉네임을 입력해주세요.") return .none } + + state.isLoading = true + let nickname = state.nickname + return .run { send in + do { + try await onboardingClient.registerProfile(nickname) + await send(.response(.registerProfileResponse(.success(())))) + } catch { + await send(.response(.registerProfileResponse(.failure(error)))) + } + } + } + } +} + +// MARK: - Response + +private extension OnboardingProfileReducer { + func reduceResponse( + state: inout State, + action: Action.Response + ) -> Effect { + switch action { + case .registerProfileResponse(.success): + state.isLoading = false + return .send(.delegate(.profileCompleted)) + + case let .registerProfileResponse(.failure(error)): + state.isLoading = false + // 이미 온보딩이 완료된 경우 (G4000), 성공과 동일하게 처리 + if let onboardingError = error as? OnboardingError, + onboardingError == .alreadyOnboarded { + return .send(.delegate(.profileCompleted)) + } + state.toast = .fit(message: "프로필 등록에 실패했어요. 다시 시도해주세요") + return .none } } } diff --git a/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileView.swift b/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileView.swift index e7a93492..71c3c2a7 100644 --- a/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileView.swift +++ b/Projects/Feature/Onboarding/Sources/Profile/OnboardingProfileView.swift @@ -89,7 +89,7 @@ private extension OnboardingProfileView { size: .l, state: store.isNicknameValid ? .standard : .disabled ), - onTap: { store.send(.completeButtonTapped) } + onTap: { store.send(.view(.completeButtonTapped)) } ) } } diff --git a/Projects/Feature/Onboarding/Testing/Sources/Source.swift b/Projects/Feature/Onboarding/Testing/Sources/Source.swift index 2edc37e7..adceace1 100644 --- a/Projects/Feature/Onboarding/Testing/Sources/Source.swift +++ b/Projects/Feature/Onboarding/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 12/29/25. // -/// Remove Or Edit Or Edit +/// Stable perf seed names for the Onboarding example app. +public enum OnboardingPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/ProofPhoto/Example/Resources/proof-photo-prefilled-large-second.jpg b/Projects/Feature/ProofPhoto/Example/Resources/proof-photo-prefilled-large-second.jpg new file mode 100644 index 00000000..b3058d8c Binary files /dev/null and b/Projects/Feature/ProofPhoto/Example/Resources/proof-photo-prefilled-large-second.jpg differ diff --git a/Projects/Feature/ProofPhoto/Example/Resources/proof-photo-prefilled-large.jpg b/Projects/Feature/ProofPhoto/Example/Resources/proof-photo-prefilled-large.jpg new file mode 100644 index 00000000..6c9e648e Binary files /dev/null and b/Projects/Feature/ProofPhoto/Example/Resources/proof-photo-prefilled-large.jpg differ diff --git a/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift b/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift index 381ce2cd..8478ef8d 100644 --- a/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift +++ b/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift @@ -1,17 +1,258 @@ -// -// ProofPhotoView.swift -// -// -// Created by Jihun on 01/25/26. -// - +import AVFoundation +import ComposableArchitecture +import CoreCaptureSession +import CoreCaptureSessionInterface +import CoreCrashlyticsInterface +import DomainPhotoLogInterface +import FeatureProofPhoto +import FeatureProofPhotoInterface +import SharedPerfTestingSupport import SwiftUI +import UIKit + +/// Maps a `-UITEST_SEED` value to the initial fixture used by P4-0 +/// rendering scenarios. Loading happens before the trace window via the +/// production `.view(.galleryPhotoLoaded)` action so generation/disk-read cost +/// is not measured inside the trace. +private enum ProofPhotoFixture { + case procedural1024 + case bundledLarge + case bundledLargeSecond + + static func forSeed(_ seedName: String?) -> ProofPhotoFixture? { + switch seedName { + case "proof-photo-prefilled": return .procedural1024 + case "proof-photo-prefilled-large": return .bundledLarge + default: return nil + } + } + + /// Stable identifier exposed via `feature.proof-photo.marker.image-ingested.`. + var source: String { + switch self { + case .procedural1024: return "fixture" + case .bundledLarge: return "fixture-large" + case .bundledLargeSecond: return "fixture-large-second" + } + } + + var bundledResourceName: String? { + switch self { + case .procedural1024: return nil + case .bundledLarge: return "proof-photo-prefilled-large" + case .bundledLargeSecond: return "proof-photo-prefilled-large-second" + } + } + + func data() -> Data { + if let resourceName = bundledResourceName, + let url = Bundle.main.url(forResource: resourceName, withExtension: "jpg"), + let data = try? Data(contentsOf: url) { + return data + } + return ProofPhotoFixture.procedural1024Data() + } + + private static func procedural1024Data() -> Data { + let size = CGSize(width: 1024, height: 1024) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { context in + let cg = context.cgContext + for yPosition in stride(from: 0, to: Int(size.height), by: 4) { + let progress = CGFloat(yPosition) / size.height + let color = UIColor( + red: 0.20 + progress * 0.55, + green: 0.40, + blue: 0.80 - progress * 0.45, + alpha: 1.0 + ) + cg.setFillColor(color.cgColor) + cg.fill(CGRect(x: 0, y: yPosition, width: Int(size.width), height: 4)) + } + cg.setStrokeColor(UIColor.white.withAlphaComponent(0.35).cgColor) + cg.setLineWidth(1) + let step: CGFloat = 64 + for offset in stride(from: -size.height, through: size.width, by: step) { + cg.move(to: CGPoint(x: offset, y: 0)) + cg.addLine(to: CGPoint(x: offset + size.height, y: size.height)) + } + cg.strokePath() + } + return image.jpegData(compressionQuality: 0.9) ?? Data() + } +} @main struct ProofPhotoApp: App { + /// Stored at App level so it survives `body` re-evaluations. The seed + /// branching only injects fixture data — no captureSession / network + /// changes — so we keep a single Store instance for the whole scene. + private let store: StoreOf + + init() { + UITestMode.configureApplication() + self.store = Store( + initialState: ProofPhotoReducer.State( + goalId: 1, + verificationDate: "2026-02-07" + ), + reducer: { ProofPhotoReducer() }, + withDependencies: { + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue + $0.photoLogClient = .perfMock + $0.crashlyticsClient = .previewValue + } + ) + } + var body: some Scene { WindowGroup { - Text("Hello Twix") + ExampleHost(store: store) + } + } +} + +/// Example-only host. Owns markers + reselect harness so that ProofPhotoView +/// stays close to production behavior. Only `perfStateMarker`-style additions +/// to ProofPhotoView are inside `#if PERF_TESTING`, matching the Pass 3 +/// pattern. +private struct ExampleHost: View { + let store: StoreOf + + @State private var ingestedSource: String = "none" + @State private var reselectCount: Int = 0 + /// Pass 4-S retry — flips to `"true"` once the launch-mode self-run typing + /// sequence has dispatched all 5 `.commentTextChanged` actions. Used as a + /// SwiftUI Template trace marker so trace analysis can isolate the + /// self-run window. Example/perf-only. + @State private var swiftUISelfRunDone: String = "false" + + var body: some View { + ProofPhotoView(store: store) + .perfRoot("proof-photo") + .perfReadyMarker("proof-photo") + .perfStateMarker( + slug: "proof-photo", + key: "image-ingested", + value: ingestedSource + ) + .perfStateMarker( + slug: "proof-photo", + key: "reselect", + value: "\(reselectCount)" + ) + .perfStateMarker( + slug: "proof-photo", + key: "swiftui-selfrun", + value: swiftUISelfRunDone + ) + .overlay(alignment: .top) { reselectTestHarness } + .onAppear { + performInitialIngestion() + if UITestMode.isEnabled, UITestMode.isSwiftUISelfRunTyping { + performSwiftUISelfRunTyping() + } + } + .onChange(of: store.imageData) { oldValue, newValue in + if oldValue != nil, newValue != nil { + reselectCount += 1 + } + } + } + + /// Dispatches the initial fixture via the production `.view(.galleryPhotoLoaded)` + /// action. Same code path a real gallery selection takes. Runs before the + /// xctrace window opens because the driver waits for + /// `feature.proof-photo.marker.image-ingested.` to appear. + private func performInitialIngestion() { + guard UITestMode.isEnabled, + let fixture = ProofPhotoFixture.forSeed(UITestMode.seedName) else { + return } + let data = fixture.data() + store.send(.view(.galleryPhotoLoaded(imageData: data))) + ingestedSource = fixture.source } + + /// Pass 4-S retry — feasibility experiment. + /// + /// SwiftUI Template attach-mode produces 0 rows on this device/OS, so an + /// XCUITest-driven typing scenario cannot be attributed at the SwiftUI + /// layer. This self-run mode dispatches the same `.commentTextChanged` + /// action that the production `TXCommentCircle` `TextField` binding + /// emits, with realistic 150 ms inter-keystroke pacing. It does not fake + /// preview/image state, does not bypass the reducer, and does not use + /// any private API. + /// + /// Result is state-driven self-run: same reducer pathway, no real + /// keyboard or focus event. Treat captured SwiftUI rows as evidence of + /// state-mutation-driven invalidation, NOT as proof that the production + /// typing path's full cost is reproduced. + private func performSwiftUISelfRunTyping() { + let preRunDelayNanos: UInt64 = 1_000_000_000 + let keystrokeIntervalNanos: UInt64 = 150_000_000 + let keystrokes = ["a", "ab", "abc", "abcd", "abcde"] + Task { @MainActor in + try? await Task.sleep(nanoseconds: preRunDelayNanos) + for text in keystrokes { + store.send(.view(.commentTextChanged(text))) + try? await Task.sleep(nanoseconds: keystrokeIntervalNanos) + } + swiftUISelfRunDone = "true" + } + } + + /// Hidden test-only harness. Exposes a tappable Color.clear region with + /// an accessibility identifier. Tap dispatches the production + /// `.view(.galleryPhotoLoaded(imageData:))` action with a second fixture so the + /// reselect scenario measures the real image-replacement path, not a + /// synthetic state mutation. + @ViewBuilder + private var reselectTestHarness: some View { + if UITestMode.isEnabled, UITestMode.seedName == "proof-photo-prefilled-large" { + Color.clear + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .onTapGesture { + let fixture = ProofPhotoFixture.bundledLargeSecond + store.send(.view(.galleryPhotoLoaded(imageData: fixture.data()))) + ingestedSource = fixture.source + } + .accessibilityIdentifier("feature.proof-photo.test.reselect-button") + } else { + EmptyView() + } + } +} + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) +} + +private extension PhotoLogClient { + static let perfMock = Self( + fetchUploadURL: { _ in .init(uploadUrl: "", fileName: "") }, + uploadImageData: { _, _ in }, + createPhotoLog: { request in + .init( + photologId: 1, + goalId: request.goalId, + imageUrl: "", + comment: request.comment, + verificationDate: request.verificationDate + ) + }, + updateReaction: { _, request in + .init(photologId: 1, reaction: request.reaction) + }, + updatePhotoLog: { _, _ in }, + deletePhotoLog: { _ in } + ) } diff --git a/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleRenderingTests.swift b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleRenderingTests.swift new file mode 100644 index 00000000..13df1f4c --- /dev/null +++ b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleRenderingTests.swift @@ -0,0 +1,243 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITests for FeatureProofPhotoExample. +/// +/// These tests are NOT benchmarks. They drive deterministic UI activity so +/// that a real-device xctrace recording (Time Profiler + Animation Hitches) +/// captures the ProofPhoto preview + comment rendering path BEFORE the +/// upload step. XCTest pass/fail is not the metric. +/// +/// ## Intended use +/// +/// 1. Launch on a real device with seed `proof-photo-prefilled`. The +/// `ProofPhotoApp` injects a deterministic 1024×1024 JPEG fixture via +/// the production `.galleryPhotoLoaded` action so `store.imageData` +/// is populated without invoking the OS Photos picker. The fixture +/// image is generated procedurally at runtime — no binary asset in +/// the repo. +/// 2. Attach `xcrun xctrace record --attach FeatureProofPhotoExample` +/// once `feature.proof-photo.ready` exists. +/// 3. Stop the trace when the test reports completion. +/// +/// ## Scope +/// +/// - Measures the local preview + comment rendering path only. +/// - Does NOT use the real Photos picker. +/// - Does NOT use the camera. +/// - Does NOT trigger server upload (`photoLogClient` is a local no-op +/// mock injected by `ProofPhotoApp`). +/// - Does NOT change the image pipeline (no downsampling, no compression +/// refactor) — those are Phase 2 follow-up if needed. +/// +/// ## Scenarios +/// +/// - `testRendering_proofPhotoPreviewWithFixtureImage` — Pass 3 baseline, +/// 1024×1024 procedural fixture, preview render + 6s idle window. +/// - `testRendering_proofPhotoCommentTyping` — Pass 3 baseline, +/// 1024×1024 fixture, 5 ASCII keystroke window. +/// - `testRendering_proofPhotoPreviewWithLargeFixtureImage` — Pass 4 large +/// fixture (bundled 4032×3024 JPEG), preview render + 6s idle. +/// - `testRendering_proofPhotoCommentTypingWithLargeFixtureImage` — Pass 4 +/// large fixture, 5 ASCII keystroke window. +/// - `testRendering_proofPhotoReselectFixtureImage` — Pass 4 large fixture, +/// dispatch a second large fixture via the production +/// `.galleryPhotoLoaded` action through the test harness button. +final class ProofPhotoExampleRenderingTests: XCTestCase { + + /// Drives preview render + 6s idle. Use Instruments to compare + /// before/after image-decode / SwiftUI image-render cost. + func testRendering_proofPhotoPreviewWithFixtureImage() { + let app = XCUIApplication.launchForPerf( + seed: "proof-photo-prefilled", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("proof-photo", timeout: 30) + + let preview = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.preview") + .firstMatch + XCTAssertTrue( + preview.waitForExistence(timeout: 10), + "feature.proof-photo.preview not visible — fixture image probably not loaded" + ) + + Thread.sleep(forTimeInterval: 6.0) + } + + /// Focuses the comment circle and types 5 ASCII characters. Each + /// character is delivered separately so the trace covers the + /// per-keystroke rendering path (commentText mutation → text circle + /// re-render + cursor TimelineView tick). + func testRendering_proofPhotoCommentTyping() { + let app = XCUIApplication.launchForPerf( + seed: "proof-photo-prefilled", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("proof-photo", timeout: 30) + + // Wait for the preview, which is the gate for the comment overlay + // to be visible (`shouldShowCommentOverlay = (captureSession != nil + // || hasImage) && rectFrame != .zero`). + let preview = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.preview") + .firstMatch + XCTAssertTrue(preview.waitForExistence(timeout: 10), "preview missing") + + let commentCircle = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.comment-circle") + .firstMatch + XCTAssertTrue( + commentCircle.waitForExistence(timeout: 10), + "feature.proof-photo.comment-circle not visible" + ) + commentCircle.tap() + + // Type 5 ASCII characters via the focused TextField inside the + // TXCommentCircle. ASCII chosen over 한글 to avoid IME instability + // on simulator / device localization differences. + for character in "abcde" { + app.typeText(String(character)) + } + + // Verify the typed text actually reached `store.commentText`. + // `perfStateMarker` exposes this only in PERF_TESTING builds; on a + // real device whose current keyboard input mode is not ASCII the + // typeText() calls above may be absorbed by the IME — the test + // must fail honestly in that case so the trace is not collected + // against an empty / wrong commentText. NOT optional. + let typedMarker = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.marker.comment-text.abcde") + .firstMatch + XCTAssertTrue( + typedMarker.waitForExistence(timeout: 10), + "store.commentText never became 'abcde' — typing did not reach the field (likely IME / keyboard input mode). Scenario is not baseline-ready until this passes on the target device." + ) + + Thread.sleep(forTimeInterval: 2.0) + } + + // MARK: - Pass 4 large-fixture scenarios + + /// Pass 4 preview render with 4032×3024 bundled JPEG (~7.5 MiB). Waits + /// for `image-ingested.fixture-large` so xctrace attach happens after + /// disk-read + initial ingestion is complete (fixture load cost stays + /// out of the trace window per Pass 4 plan §A invalidation rule). + func testRendering_proofPhotoPreviewWithLargeFixtureImage() { + let app = XCUIApplication.launchForPerf( + seed: "proof-photo-prefilled-large", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("proof-photo", timeout: 30) + + awaitIngested(app, source: "fixture-large", timeout: 30) + awaitPreviewReady(app, timeout: 10) + + Thread.sleep(forTimeInterval: 6.0) + } + + /// Pass 4 comment typing with 4032×3024 fixture rendered. Tests whether + /// keystroke-induced body re-eval re-decodes the preview image (plan §P4-2 + /// entry-condition check). + func testRendering_proofPhotoCommentTypingWithLargeFixtureImage() { + let app = XCUIApplication.launchForPerf( + seed: "proof-photo-prefilled-large", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("proof-photo", timeout: 30) + + awaitIngested(app, source: "fixture-large", timeout: 30) + awaitPreviewReady(app, timeout: 10) + + let commentCircle = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.comment-circle") + .firstMatch + XCTAssertTrue( + commentCircle.waitForExistence(timeout: 10), + "feature.proof-photo.comment-circle not visible" + ) + commentCircle.tap() + + for character in "abcde" { + app.typeText(String(character)) + } + + let typedMarker = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.marker.comment-text.abcde") + .firstMatch + XCTAssertTrue( + typedMarker.waitForExistence(timeout: 10), + "store.commentText never became 'abcde' — typing did not reach the field (likely IME / keyboard input mode). Scenario is not baseline-ready until this passes on the target device." + ) + + Thread.sleep(forTimeInterval: 2.0) + } + + /// Pass 4 reselect. Dispatches a second large fixture via the production + /// `.galleryPhotoLoaded` action (through the example harness button) so + /// the trace captures the real pre-upload image-replacement render path. + /// Verifies the reselect.1 marker AND the second image's + /// image-ingested marker per plan §E. + func testRendering_proofPhotoReselectFixtureImage() { + let app = XCUIApplication.launchForPerf( + seed: "proof-photo-prefilled-large", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("proof-photo", timeout: 30) + + awaitIngested(app, source: "fixture-large", timeout: 30) + awaitPreviewReady(app, timeout: 10) + + let reselectButton = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.test.reselect-button") + .firstMatch + XCTAssertTrue( + reselectButton.waitForExistence(timeout: 10), + "reselect harness button not present — seed/harness wiring broken" + ) + reselectButton.tap() + + awaitIngested(app, source: "fixture-large-second", timeout: 10) + + let reselectMarker = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.marker.reselect.1") + .firstMatch + XCTAssertTrue( + reselectMarker.waitForExistence(timeout: 5), + "reselect counter never became 1 — production .galleryPhotoLoaded dispatch failed" + ) + + Thread.sleep(forTimeInterval: 4.0) + } + + // MARK: - Helpers + + private func awaitIngested( + _ app: XCUIApplication, + source: String, + timeout: TimeInterval + ) { + let marker = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.marker.image-ingested.\(source)") + .firstMatch + XCTAssertTrue( + marker.waitForExistence(timeout: timeout), + "image-ingested.\(source) marker not present within \(Int(timeout))s — fixture loading failed or seed wiring broken" + ) + } + + private func awaitPreviewReady(_ app: XCUIApplication, timeout: TimeInterval) { + let marker = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.marker.preview-ready.true") + .firstMatch + XCTAssertTrue( + marker.waitForExistence(timeout: timeout), + "preview-ready.true marker not present — image branch of photoPreview did not render" + ) + } +} diff --git a/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleSmokeTests.swift b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleSmokeTests.swift new file mode 100644 index 00000000..e48c8541 --- /dev/null +++ b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class ProofPhotoExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("proof-photo") + } +} diff --git a/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift b/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift index 2464ce68..7eedd7fc 100644 --- a/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift +++ b/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift @@ -28,6 +28,12 @@ public struct ProofPhotoReducer { public var scopeText: String = "1x" public var captureSession: AVCaptureSession? public var imageData: Data? + /// P4-2: decoded preview representation prepared once at ingestion. + /// Renders the preview branch in `ProofPhotoView` without per-body + /// `UIImage(data:)`. `imageData` remains the upload source of truth. + /// Auto-derived `Equatable` uses NSObject pointer equality for + /// `UIImage?` — stable between ingestions since we set it once. + public var previewImage: UIImage? public var selectedPhotoItem: PhotosPickerItem? public var isFront: Bool = false public var isFlashOn: Bool = false @@ -65,41 +71,52 @@ public struct ProofPhotoReducer { /// ProofPhoto 화면에서 발생하는 액션입니다. public enum Action: BindableAction { case binding(BindingAction) - - // MARK: - LifeCycle - case onAppear - - // MARK: - Action - case closeButtonTapped - case captureButtonTapped - case switchButtonTapped - case flashButtonTapped - case returnButtonTapped - case focusChanged(Bool) - case uploadButtonTapped - case dimmedBackgroundTapped - - // MARK: - Update State - case commentTextChanged(String) - case setupCaptureSessionCompleted(session: AVCaptureSession) - case captureCompleted(imageData: Data) - case captureFailed - case galleryPhotoLoaded(imageData: Data) - case cameraSwitched - case showToast(TXToastType) - case uploadFailed + + // MARK: - View + public enum View: Equatable { + case onAppear + case closeButtonTapped + case captureButtonTapped + case switchButtonTapped + case flashButtonTapped + case returnButtonTapped + case focusChanged(Bool) + case uploadButtonTapped + case dimmedBackgroundTapped + case commentTextChanged(String) + case galleryPhotoLoaded(imageData: Foundation.Data) + } + + // MARK: - Response + public enum Response { + case setupCaptureSessionCompleted(session: AVCaptureSession) + case captureCompleted(imageData: Foundation.Data) + case captureFailed + case galleryPhotoLoaded(imageData: Foundation.Data) + case cameraSwitched + case uploadFailed + } + + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(TXToastType) + } // MARK: - Delegate case delegate(Delegate) - + /// ProofPhoto 화면에서 외부로 전달하는 이벤트입니다. public enum Delegate { case closeProofPhoto case completedUploadPhoto( myPhotoLog: GoalDetail.CompletedGoal.PhotoLog, - editedImageData: Data? + editedImageData: Foundation.Data? ) } + + case view(View) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입된 Reduce로 리듀서를 구성합니다. diff --git a/Projects/Feature/ProofPhoto/Project.swift b/Projects/Feature/ProofPhoto/Project.swift index 08d967ee..d66a99d4 100644 --- a/Projects/Feature/ProofPhoto/Project.swift +++ b/Projects/Feature/ProofPhoto/Project.swift @@ -26,6 +26,7 @@ let project = Project.makeModule( .domain(interface: .goal), .domain(interface: .photoLog), .shared(implements: .designSystem), + .shared(implements: .perfTestingSupport), .shared(implements: .util), .external(dependency: .ComposableArchitecture) ] @@ -51,9 +52,15 @@ let project = Project.makeModule( example: .proofPhoto, config: .init( dependencies: [ - .feature(interface: .proofPhoto) + .feature(interface: .proofPhoto), + .feature(implements: .proofPhoto), + .core(implements: .captureSession), + .core(interface: .crashlytics), + .domain(interface: .photoLog), + .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .proofPhoto) ] ) diff --git a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift index 3df807c4..fd66f089 100644 --- a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift +++ b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift @@ -37,19 +37,19 @@ extension ProofPhotoReducer { switch action { // MARK: - Life Cycle - case .onAppear: + case .view(.onAppear): return .run { [isFlashOn = state.isFlashOn] send in captureSessionClient.setFlashEnabled(isFlashOn) let session = await captureSessionClient.setUpCaptureSession(.back) analyticsClient.logEvent(ProofPhotoAnalyticsEvent.opened) - await send(.setupCaptureSessionCompleted(session: session)) + await send(.response(.setupCaptureSessionCompleted(session: session))) } // MARK: - Action - case .closeButtonTapped: + case .view(.closeButtonTapped): return .send(.delegate(.closeProofPhoto)) - case .captureButtonTapped: + case .view(.captureButtonTapped): guard !state.isCapturing else { return .none } captureSessionClient.setFlashEnabled(state.isFlashOn) state.isCapturing = true @@ -57,7 +57,7 @@ extension ProofPhotoReducer { do { let imageData = try await captureSessionClient.capturePhoto() - await send(.captureCompleted(imageData: imageData)) + await send(.response(.captureCompleted(imageData: imageData))) captureSessionClient.stopRunning() } catch { crashlytics.record( @@ -66,30 +66,34 @@ extension ProofPhotoReducer { errorType: String(describing: error) ) ) - await send(.captureFailed) + await send(.response(.captureFailed)) } } - case .switchButtonTapped: + case .view(.switchButtonTapped): return .run { [isFront = state.isFront, isFlashOn = state.isFlashOn] send in let isFront = !isFront await captureSessionClient.switchCamera(isFront) captureSessionClient.setFlashEnabled(isFlashOn) - await send(.cameraSwitched) + await send(.response(.cameraSwitched)) } - case .flashButtonTapped: + case .view(.flashButtonTapped): state.isFlashOn.toggle() captureSessionClient.setFlashEnabled(state.isFlashOn) return .none - case let .commentTextChanged(text): + case let .view(.commentTextChanged(text)): state.commentText = String(text.prefix(5)) return .none + + case let .view(.galleryPhotoLoaded(imageData)): + return .send(.response(.galleryPhotoLoaded(imageData: imageData))) - case .returnButtonTapped: + case .view(.returnButtonTapped): state.imageData = nil + state.previewImage = nil state.selectedPhotoItem = nil state.isCapturing = false let position: AVCaptureDevice.Position = state.isFront ? .front : .back @@ -97,19 +101,19 @@ extension ProofPhotoReducer { return .run { [isFlashOn = state.isFlashOn] send in let session = await captureSessionClient.setUpCaptureSession(position) captureSessionClient.setFlashEnabled(isFlashOn) - await send(.setupCaptureSessionCompleted(session: session)) + await send(.response(.setupCaptureSessionCompleted(session: session))) } - case let .focusChanged(isFocused): + case let .view(.focusChanged(isFocused)): state.isCommentFocused = isFocused return .none - case .uploadButtonTapped: + case .view(.uploadButtonTapped): guard !state.isUploading else { return .none } // 코멘트는 비워도 되지만, 입력할 경우 5글자여야 함 let commentCount = state.commentText.count if commentCount > 0 && commentCount < 5 { - return .send(.showToast(.fit(message: "코멘트는 5글자로 입력해주세요!"))) + return .send(.presentation(.showToast(.fit(message: "코멘트는 5글자로 입력해주세요!")))) } else { guard let imageData = state.imageData else { return .none @@ -214,54 +218,60 @@ extension ProofPhotoReducer { originalImageBytes: originalSize ) ) - await send(.uploadFailed) + await send(.response(.uploadFailed)) } } } - case .dimmedBackgroundTapped: - return .send(.focusChanged(false)) + case .view(.dimmedBackgroundTapped): + return .send(.view(.focusChanged(false))) // MARK: - Update State - case let .setupCaptureSessionCompleted(session): + case let .response(.setupCaptureSessionCompleted(session)): state.captureSession = session return .none - case .cameraSwitched: + case .response(.cameraSwitched): state.isFront.toggle() return .none case .binding(\.selectedPhotoItem): guard let selectedPhotoItem = state.selectedPhotoItem else { state.imageData = nil + state.previewImage = nil return .none } return .run { send in if let imageData = try? await selectedPhotoItem.loadTransferable(type: Data.self) { - await send(.galleryPhotoLoaded(imageData: imageData)) + await send(.response(.galleryPhotoLoaded(imageData: imageData))) captureSessionClient.stopRunning() } } - case let .galleryPhotoLoaded(imageData): + case let .response(.galleryPhotoLoaded(imageData)): state.imageData = imageData + // P4-2: decode once at ingestion. `imageData` remains the + // upload source-of-truth; preview branch renders from + // `previewImage` so body re-evals never re-decode. + state.previewImage = UIImage(data: imageData) return .none - - case let .captureCompleted(imageData: imageData): + + case let .response(.captureCompleted(imageData: imageData)): state.imageData = imageData + state.previewImage = UIImage(data: imageData) state.isCapturing = false return .none - case .captureFailed: + case .response(.captureFailed): state.isCapturing = false - return .send(.showToast(.warning(message: "사진 촬영에 실패했어요"))) + return .send(.presentation(.showToast(.warning(message: "사진 촬영에 실패했어요")))) - case .uploadFailed: + case .response(.uploadFailed): state.isUploading = false - return .send(.showToast(.warning(message: "사진 업로드에 실패했어요"))) + return .send(.presentation(.showToast(.warning(message: "사진 업로드에 실패했어요")))) - case let .showToast(toast): + case let .presentation(.showToast(toast)): state.toast = toast return .none diff --git a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift index 2b9f2d54..25066860 100644 --- a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift +++ b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift @@ -11,6 +11,7 @@ import SwiftUI import ComposableArchitecture import FeatureProofPhotoInterface import SharedDesignSystem +import SharedPerfTestingSupport import SharedUtil /// 인증샷 화면을 렌더링하는 View입니다. @@ -70,8 +71,23 @@ public struct ProofPhotoView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .observeKeyboardFrame($keyboardFrame) .background(Color.Gray.gray500) + .perfStateMarker( + slug: "proof-photo", + key: "comment-text", + value: store.commentText + ) + // P4-2: `preview-ready.true` now reflects the **decoded preview + // representation** (`store.previewImage != nil`), not merely + // `imageData != nil`. Plan §D semantics tightened — distinguishes + // "decoded preview prepared and renderable" from "imageData set + // but decode failed or pending". + .perfStateMarker( + slug: "proof-photo", + key: "preview-ready", + value: store.previewImage != nil ? "true" : "false" + ) .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .txToast(item: $store.toast, customPadding: 75) .txLoading(item: store.isUploading ? "업로드 중..." : nil) @@ -105,7 +121,7 @@ private extension ProofPhotoView { Spacer() Button { - store.send(.closeButtonTapped) + store.send(.view(.closeButtonTapped)) } label: { Image.Icon.Symbol.closeM .resizable() @@ -127,30 +143,33 @@ private extension ProofPhotoView { @ViewBuilder var photoPreview: some View { - if store.hasImage, - let imageData = store.imageData, - let image = UIImage(data: imageData) { - previewContainer { - Image(uiImage: image) - .resizable() - .scaledToFill() - } - } else if let session = store.captureSession { - previewContainer { - CameraPreview(session: session) + Group { + // P4-2: render from pre-decoded `previewImage` instead of + // recreating `UIImage(data: imageData)` per body re-eval. + if let image = store.previewImage { + previewContainer { + Image(uiImage: image) + .resizable() + .scaledToFill() + } + } else if let session = store.captureSession { + previewContainer { + CameraPreview(session: session) + } + } else { + Rectangle() + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 76)) } - } else { - Rectangle() - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 76)) } + .accessibilityIdentifier("feature.proof-photo.preview") } var previewTopControls: some View { HStack { Button { - store.send(.flashButtonTapped) + store.send(.view(.flashButtonTapped)) } label: { flashIcon .renderingMode(.template) @@ -202,7 +221,7 @@ private extension ProofPhotoView { backgroundColor: Color.Gray.gray400 ) ), - onTap: { store.send(.switchButtonTapped) } + onTap: { store.send(.view(.switchButtonTapped)) } ) } } @@ -210,7 +229,7 @@ private extension ProofPhotoView { var uploadControls: some View { HStack(spacing: Spacing.spacing6) { Button { - store.send(.returnButtonTapped) + store.send(.view(.returnButtonTapped)) } label: { Image.Icon.Symbol.icReturn .resizable() @@ -226,7 +245,7 @@ private extension ProofPhotoView { size: .m, state: .standard ), - onTap: { store.send(.uploadButtonTapped) } + onTap: { store.send(.view(.uploadButtonTapped)) } ) Color.clear @@ -252,7 +271,7 @@ private extension ProofPhotoView { var captureButton: some View { Button { - store.send(.captureButtonTapped) + store.send(.view(.captureButtonTapped)) } label: { Circle() .fill(.white) @@ -279,7 +298,7 @@ private extension ProofPhotoView { .transition(.opacity) .animation(.easeInOut, value: store.isCommentFocused) .onTapGesture { - store.send(.dimmedBackgroundTapped) + store.send(.view(.dimmedBackgroundTapped)) } } } @@ -328,7 +347,7 @@ private extension ProofPhotoView { } .padding(.bottom, 28) .frame(width: rectFrame.width, height: rectFrame.height, alignment: .bottom) - .offset(x: posX, y: posY) + .offset(x: posX, y: posY - keyboardInset) .animation(.easeOut(duration: 0.25), value: keyboardInset) } } @@ -343,12 +362,12 @@ private extension ProofPhotoView { TXCommentCircle( commentText: $store.commentText, isEditable: true, - keyboardInset: keyboardInset, isFocused: $store.isCommentFocused, onFocused: { isFocused in - store.send(.focusChanged(isFocused)) + store.send(.view(.focusChanged(isFocused))) } ) + .accessibilityIdentifier("feature.proof-photo.comment-circle") } } diff --git a/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift b/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift index 408f31ec..e5e32790 100644 --- a/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift +++ b/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 01/25/26. // -/// Remove Or Edit +/// Stable perf seed names for the ProofPhoto example app. +public enum ProofPhotoPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/Settings/Example/Sources/SettingsApp.swift b/Projects/Feature/Settings/Example/Sources/SettingsApp.swift index 3d73a502..7cb1aa51 100644 --- a/Projects/Feature/Settings/Example/Sources/SettingsApp.swift +++ b/Projects/Feature/Settings/Example/Sources/SettingsApp.swift @@ -11,10 +11,15 @@ import DomainOnboardingInterface import FeatureSettings import FeatureSettingsInterface import SharedDesignSystem +import SharedPerfTestingSupport import SwiftUI @main struct SettingsApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { SettingsView( @@ -32,6 +37,8 @@ struct SettingsApp: App { } ) ) + .perfRoot("settings") + .perfReadyMarker("settings") } } } diff --git a/Projects/Feature/Settings/ExampleUITests/Sources/SettingsExampleSmokeTests.swift b/Projects/Feature/Settings/ExampleUITests/Sources/SettingsExampleSmokeTests.swift new file mode 100644 index 00000000..90b0dd94 --- /dev/null +++ b/Projects/Feature/Settings/ExampleUITests/Sources/SettingsExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class SettingsExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("settings") + } +} diff --git a/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift b/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift index 3bda7be1..87c66a31 100644 --- a/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift +++ b/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift @@ -34,12 +34,14 @@ public struct SettingsReducer { public var originalNickname: String public var isEditing: Bool public var isLoading: Bool + public var isProfileFetchFailed: Bool // Language public var selectedLanguage: TXLanguage // Account public var coupleCode: String + public var isCoupleCodeFetchFailed: Bool public var modal: TXModalStyle? public var modalPurpose: ModalPurpose? @@ -55,6 +57,7 @@ public struct SettingsReducer { public var isMarketingPushEnabled: Bool public var isNightMarketingPushEnabled: Bool public var isNotificationSettingsLoading: Bool + public var isNotificationSettingsFetchFailed: Bool public var isSystemNotificationEnabled: Bool public static let minLength = 2 @@ -100,8 +103,10 @@ public struct SettingsReducer { self.originalNickname = nickname self.isEditing = isEditing self.isLoading = false + self.isProfileFetchFailed = false self.selectedLanguage = selectedLanguage self.coupleCode = coupleCode + self.isCoupleCodeFetchFailed = false self.modalPurpose = nil self.appVersion = appVersion self.storeVersion = storeVersion @@ -109,6 +114,7 @@ public struct SettingsReducer { self.isMarketingPushEnabled = isMarketingPushEnabled self.isNightMarketingPushEnabled = isNightMarketingPushEnabled self.isNotificationSettingsLoading = false + self.isNotificationSettingsFetchFailed = false self.isSystemNotificationEnabled = true } } @@ -117,49 +123,57 @@ public struct SettingsReducer { public enum Action: BindableAction { case binding(BindingAction) - // MARK: - User Action - case backButtonTapped - case subViewBackButtonTapped - case editButtonTapped - case clearButtonTapped - case languageSettingTapped - case accountTapped - case infoTapped - case inquiryTapped - case notificationSettingTapped - case privacyPolicyTapped - - // MARK: - Lifecycle - case onAppear + // MARK: - View + public enum View: Equatable { + case onAppear + case backButtonTapped + case subViewBackButtonTapped + case editButtonTapped + case clearButtonTapped + case nicknameEditingEnded + case languageSettingTapped + case languageConfirmed(Int) + case accountTapped + case infoTapped + case inquiryTapped + case notificationSettingTapped + case privacyPolicyTapped + case logoutTapped + case disconnectCoupleTapped + case withdrawTapped + case modalConfirmTapped + case notificationSettingsOnAppear + case settingsDataRetryTapped + case notificationSettingsDataRetryTapped + case pokePushToggled(Bool) + case marketingPushToggled(Bool) + case nightPushToggled(Bool) + case enableNotificationBannerTapped + } // MARK: - Internal - case nicknameEditingEnded - case languageConfirmed(Int) - case storeVersionResponse(String?) - - // MARK: - Account Actions - case logoutTapped - case disconnectCoupleTapped - case withdrawTapped - case modalConfirmTapped - - // MARK: - API Response - case updateNicknameResponse(Result) - case fetchMyProfileResponse(Result) - case fetchCoupleCodeResponse(Result) - case logoutResponse(Result) - case withdrawResponse(Result) - case showToast(TXToastType) - - // MARK: - Notification Settings - case notificationSettingsOnAppear - case pokePushToggled(Bool) - case marketingPushToggled(Bool) - case nightPushToggled(Bool) - case fetchNotificationSettingsResponse(Result) - case updateNotificationSettingResponse(Result) - case enableNotificationBannerTapped - case checkSystemNotificationResponse(Bool) + public enum Internal: Equatable { + case nicknameEditingEnded + case languageConfirmed(Int) + } + + // MARK: - Response + public enum Response { + case storeVersionResponse(String?) + case updateNicknameResponse(Result) + case fetchMyProfileResponse(Result) + case fetchCoupleCodeResponse(Result) + case logoutResponse(Result) + case withdrawResponse(Result) + case fetchNotificationSettingsResponse(Result) + case updateNotificationSettingResponse(Result) + case checkSystemNotificationResponse(Bool) + } + + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(TXToastType) + } // MARK: - Delegate case delegate(Delegate) @@ -175,6 +189,11 @@ public struct SettingsReducer { case withdrawCompleted case sessionExpired } + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입된 Reduce로 리듀서를 구성합니다. @@ -216,4 +235,8 @@ extension SettingsReducer.State { public var isNicknameValid: Bool { isNicknameLengthValid && !containsProfanity } + + public var isSettingsFetchFailed: Bool { + isProfileFetchFailed || isCoupleCodeFetchFailed + } } diff --git a/Projects/Feature/Settings/Project.swift b/Projects/Feature/Settings/Project.swift index 2b8ff29c..cd0b84de 100644 --- a/Projects/Feature/Settings/Project.swift +++ b/Projects/Feature/Settings/Project.swift @@ -45,6 +45,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .settings) ] -) \ No newline at end of file +) diff --git a/Projects/Feature/Settings/Sources/Account/AccountView.swift b/Projects/Feature/Settings/Sources/Account/AccountView.swift index c34d63f4..1f40dc7c 100644 --- a/Projects/Feature/Settings/Sources/Account/AccountView.swift +++ b/Projects/Feature/Settings/Sources/Account/AccountView.swift @@ -18,10 +18,16 @@ struct AccountView: View { VStack(spacing: 0) { navigationBar - ScrollView { - accountList - .padding(.top, Spacing.spacing8) - .padding(.horizontal, Spacing.spacing8) + if store.isSettingsFetchFailed { + DataRetryView { + store.send(.view(.settingsDataRetryTapped)) + } + } else { + ScrollView { + accountList + .padding(.top, Spacing.spacing8) + .padding(.horizontal, Spacing.spacing8) + } } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -29,7 +35,7 @@ struct AccountView: View { .navigationBarBackButtonHidden(true) .txModal(item: $store.modal) { action in if action == .confirm { - store.send(.modalConfirmTapped) + store.send(.view(.modalConfirmTapped)) } } .txLoading(isPresented: store.isLoading) @@ -42,7 +48,7 @@ private extension AccountView { var navigationBar: some View { TXNavigationBar(style: .subTitle(title: "계정", type: .back)) { action in if action == .backTapped { - store.send(.subViewBackButtonTapped) + store.send(.view(.subViewBackButtonTapped)) } } } @@ -70,7 +76,7 @@ private extension AccountView { var logoutItem: some View { listItem(title: "로그아웃") { - store.send(.logoutTapped) + store.send(.view(.logoutTapped)) } } @@ -85,13 +91,13 @@ private extension AccountView { var disconnectCoupleItem: some View { listItem(title: "커플 끊기") { - store.send(.disconnectCoupleTapped) + store.send(.view(.disconnectCoupleTapped)) } } var withdrawItem: some View { listItem(title: "탈퇴하기") { - store.send(.withdrawTapped) + store.send(.view(.withdrawTapped)) } } diff --git a/Projects/Feature/Settings/Sources/Info/InfoView.swift b/Projects/Feature/Settings/Sources/Info/InfoView.swift index a3d3cc37..417850ef 100644 --- a/Projects/Feature/Settings/Sources/Info/InfoView.swift +++ b/Projects/Feature/Settings/Sources/Info/InfoView.swift @@ -28,7 +28,7 @@ struct InfoView: View { .background(Color.Common.white) .navigationBarBackButtonHidden(true) .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } } } @@ -39,7 +39,7 @@ private extension InfoView { var navigationBar: some View { TXNavigationBar(style: .subTitle(title: "정보", type: .back)) { action in if action == .backTapped { - store.send(.subViewBackButtonTapped) + store.send(.view(.subViewBackButtonTapped)) } } } @@ -65,7 +65,7 @@ private extension InfoView { var privacyPolicyItem: some View { listItem(title: "개인정보 처리방침") { - store.send(.privacyPolicyTapped) + store.send(.view(.privacyPolicyTapped)) } } diff --git a/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift b/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift index dbcb79cb..2b983e75 100644 --- a/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift +++ b/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift @@ -19,14 +19,20 @@ struct NotificationSettingsView: View { VStack(spacing: 0) { navigationBar - ZStack { - if !store.isSystemNotificationEnabled { - disabledView - } else { - ScrollView { - notificationList - .padding(.top, Spacing.spacing8) - .padding(.horizontal, Spacing.spacing8) + if store.isNotificationSettingsFetchFailed { + DataRetryView { + store.send(.view(.notificationSettingsDataRetryTapped)) + } + } else { + ZStack { + if !store.isSystemNotificationEnabled { + disabledView + } else { + ScrollView { + notificationList + .padding(.top, Spacing.spacing8) + .padding(.horizontal, Spacing.spacing8) + } } } } @@ -35,11 +41,11 @@ struct NotificationSettingsView: View { .background(Color.Common.white) .navigationBarBackButtonHidden(true) .onAppear { - store.send(.notificationSettingsOnAppear) + store.send(.view(.notificationSettingsOnAppear)) } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { - store.send(.notificationSettingsOnAppear) + store.send(.view(.notificationSettingsOnAppear)) } } .txLoading(isPresented: store.isNotificationSettingsLoading) @@ -52,7 +58,7 @@ private extension NotificationSettingsView { var navigationBar: some View { TXNavigationBar(style: .subTitle(title: "알림 설정", type: .back)) { action in if action == .backTapped { - store.send(.subViewBackButtonTapped) + store.send(.view(.subViewBackButtonTapped)) } } } @@ -73,7 +79,7 @@ private extension NotificationSettingsView { var enableNotificationBanner: some View { Button { - store.send(.enableNotificationBannerTapped) + store.send(.view(.enableNotificationBannerTapped)) } label: { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { @@ -125,7 +131,7 @@ private extension NotificationSettingsView { toggleItem( title: "찌르기 푸쉬알림", isOn: store.isPokePushEnabled, - onToggle: { store.send(.pokePushToggled($0)) } + onToggle: { store.send(.view(.pokePushToggled($0))) } ) } @@ -133,7 +139,7 @@ private extension NotificationSettingsView { toggleItem( title: "마케팅 정보 푸쉬알림", isOn: store.isMarketingPushEnabled, - onToggle: { store.send(.marketingPushToggled($0)) } + onToggle: { store.send(.view(.marketingPushToggled($0))) } ) } @@ -141,7 +147,7 @@ private extension NotificationSettingsView { toggleItem( title: "야간 마케팅 정보 푸쉬알림", isOn: store.isNightMarketingPushEnabled, - onToggle: { store.send(.nightPushToggled($0)) } + onToggle: { store.send(.view(.nightPushToggled($0))) } ) } diff --git a/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift b/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift index c86b0423..b1e09695 100644 --- a/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift +++ b/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift @@ -55,62 +55,67 @@ private func reduceCore( case .binding: return .none - case .onAppear: + case .view(.onAppear): @Dependency(\.authClient) var authClient @Dependency(\.onboardingClient) var onboardingClient state.appVersion = AppVersionProvider.currentVersion + state.isProfileFetchFailed = false + state.isCoupleCodeFetchFailed = false return .merge( .run { send in let storeVersion = await AppVersionProvider.fetchStoreVersion() - await send(.storeVersionResponse(storeVersion)) + await send(.response(.storeVersionResponse(storeVersion))) }, .run { send in do { let profile = try await authClient.fetchMyProfile() - await send(.fetchMyProfileResponse(.success(profile.name))) + await send(.response(.fetchMyProfileResponse(.success(profile.name)))) } catch { - await send(.fetchMyProfileResponse(.failure(error))) + await send(.response(.fetchMyProfileResponse(.failure(error)))) } }, .run { send in do { let coupleCode = try await onboardingClient.fetchInviteCode() - await send(.fetchCoupleCodeResponse(.success(coupleCode))) + await send(.response(.fetchCoupleCodeResponse(.success(coupleCode)))) } catch { - await send(.fetchCoupleCodeResponse(.failure(error))) + await send(.response(.fetchCoupleCodeResponse(.failure(error)))) } } ) - case .storeVersionResponse(let version): + case .response(.storeVersionResponse(let version)): state.storeVersion = version ?? "-" return .none - case .backButtonTapped: + case .view(.backButtonTapped): return .send(.delegate(.navigateBack)) - case .subViewBackButtonTapped: + case .view(.subViewBackButtonTapped): return .send(.delegate(.navigateBackFromSubView)) - case .editButtonTapped: + case .view(.editButtonTapped): state.isEditing = true return .none - case .clearButtonTapped: + case .view(.clearButtonTapped): state.nickname = "" return .none - case .nicknameEditingEnded: + case .view(.nicknameEditingEnded): + return .send(.internal(.nicknameEditingEnded)) + + case .internal(.nicknameEditingEnded): return handleNicknameEditingEnded(state: &state) - case .updateNicknameResponse(.success): + case .response(.updateNicknameResponse(.success)): state.isLoading = false state.originalNickname = state.nickname state.isEditing = false return .none - case .updateNicknameResponse(.failure(let error)): + case .response(.updateNicknameResponse(.failure(let error))): state.isLoading = false state.nickname = state.originalNickname state.isEditing = false @@ -120,7 +125,7 @@ private func reduceCore( } return .none - case .languageSettingTapped: + case .view(.languageSettingTapped): state.modal = .selectList( title: "언어 설정", subtitle: "이미 앱 내에 저장된 언어는 변경되지 않아요", @@ -131,7 +136,10 @@ private func reduceCore( ) return .none - case let .languageConfirmed(index): + case let .view(.languageConfirmed(index)): + return .send(.internal(.languageConfirmed(index))) + + case let .internal(.languageConfirmed(index)): guard SettingsReducer.State.languageOptions.indices.contains(index) else { return .none } @@ -139,13 +147,13 @@ private func reduceCore( // TODO: 언어 설정 저장 로직 구현 return .none - case .accountTapped: + case .view(.accountTapped): return .send(.delegate(.navigateToAccount)) - case .infoTapped: + case .view(.infoTapped): return .send(.delegate(.navigateToInfo)) - case .logoutTapped: + case .view(.logoutTapped): guard !state.isLoading else { return .none } @Dependency(\.authClient) var authClient @Dependency(\.pushClient) var pushClient @@ -160,13 +168,13 @@ private func reduceCore( do { try await authClient.signOut() - await send(.logoutResponse(.success(()))) + await send(.response(.logoutResponse(.success(())))) } catch { - await send(.logoutResponse(.failure(error))) + await send(.response(.logoutResponse(.failure(error)))) } } - case .disconnectCoupleTapped: + case .view(.disconnectCoupleTapped): state.modalPurpose = .disconnectCouple state.modal = .info( image: .Icon.Illustration.modalWarning, @@ -182,7 +190,7 @@ private func reduceCore( ) return .none - case .withdrawTapped: + case .view(.withdrawTapped): state.modalPurpose = .withdraw state.modal = .info( image: .Icon.Illustration.modalWarning, @@ -196,7 +204,7 @@ private func reduceCore( ) return .none - case .modalConfirmTapped: + case .view(.modalConfirmTapped): guard !state.isLoading else { return .none } @Dependency(\.authClient) var authClient @@ -206,9 +214,9 @@ private func reduceCore( return .run { send in do { try await authClient.withdraw() - await send(.withdrawResponse(.success(()))) + await send(.response(.withdrawResponse(.success(())))) } catch { - await send(.withdrawResponse(.failure(error))) + await send(.response(.withdrawResponse(.failure(error)))) } } case .withdraw: @@ -216,9 +224,9 @@ private func reduceCore( return .run { send in do { try await authClient.withdraw() - await send(.withdrawResponse(.success(()))) + await send(.response(.withdrawResponse(.success(())))) } catch { - await send(.withdrawResponse(.failure(error))) + await send(.response(.withdrawResponse(.failure(error)))) } } default: @@ -226,67 +234,74 @@ private func reduceCore( } return .none - case .privacyPolicyTapped: + case .view(.privacyPolicyTapped): if let url = URL(string: "https://incongruous-sweatshirt-b32.notion.site/Keepliuv-3024eb2e10638051824ef9ac7f9a522f") { return .send(.delegate(.navigateToWebView(url: url, title: "개인정보 처리방침"))) } return .none - case .notificationSettingTapped: + case .view(.notificationSettingTapped): return .send(.delegate(.navigateToNotificationSettings)) - case .fetchMyProfileResponse(.success(let name)): + case .view(.settingsDataRetryTapped): + return .send(.view(.onAppear)) + + case .response(.fetchMyProfileResponse(.success(let name))): state.nickname = name state.originalNickname = name + state.isProfileFetchFailed = false return .none - case .fetchMyProfileResponse(.failure(let error)): + case .response(.fetchMyProfileResponse(.failure(let error))): if let networkError = error as? NetworkError, networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } + state.isProfileFetchFailed = true return .none - case .fetchCoupleCodeResponse(.success(let coupleCode)): + case .response(.fetchCoupleCodeResponse(.success(let coupleCode))): state.coupleCode = coupleCode + state.isCoupleCodeFetchFailed = false return .none - case .fetchCoupleCodeResponse(.failure(let error)): + case .response(.fetchCoupleCodeResponse(.failure(let error))): if let networkError = error as? NetworkError, networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } + state.isCoupleCodeFetchFailed = true return .none - case .logoutResponse(.success): + case .response(.logoutResponse(.success)): state.isLoading = false return .send(.delegate(.logoutCompleted)) - case .logoutResponse(.failure(let error)): + case .response(.logoutResponse(.failure(let error))): state.isLoading = false if let networkError = error as? NetworkError, networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } - return .send(.showToast(.warning(message: "로그아웃에 실패했어요"))) + return .send(.presentation(.showToast(.warning(message: "로그아웃에 실패했어요")))) - case .withdrawResponse(.success): + case .response(.withdrawResponse(.success)): state.isLoading = false return .send(.delegate(.withdrawCompleted)) - case .withdrawResponse(.failure(let error)): + case .response(.withdrawResponse(.failure(let error))): state.isLoading = false if let networkError = error as? NetworkError, networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } - return .send(.showToast(.warning(message: "회원 탈퇴에 실패했어요"))) + return .send(.presentation(.showToast(.warning(message: "회원 탈퇴에 실패했어요")))) - case let .showToast(toast): + case let .presentation(.showToast(toast)): state.toast = toast return .none - case .inquiryTapped: + case .view(.inquiryTapped): @Dependency(\.openURL) var openURL guard let url = URL(string: "http://pf.kakao.com/_znAzX/chat") else { return .none @@ -300,13 +315,13 @@ private func reduceCore( // MARK: - Notification Settings - case .notificationSettingsOnAppear: + case .view(.notificationSettingsOnAppear): @Dependency(\.notificationClient) var notificationClient let checkPermissionEffect: Effect = .run { send in let settings = await UNUserNotificationCenter.current().notificationSettings() let isEnabled = settings.authorizationStatus == .authorized - await send(.checkSystemNotificationResponse(isEnabled)) + await send(.response(.checkSystemNotificationResponse(isEnabled))) } guard !state.isNotificationSettingsLoading else { @@ -314,92 +329,98 @@ private func reduceCore( } state.isNotificationSettingsLoading = true + state.isNotificationSettingsFetchFailed = false return .merge( checkPermissionEffect, .run { send in do { let notificationSettings = try await notificationClient.fetchSettings() - await send(.fetchNotificationSettingsResponse(.success(notificationSettings))) + await send(.response(.fetchNotificationSettingsResponse(.success(notificationSettings)))) } catch { - await send(.fetchNotificationSettingsResponse(.failure(error))) + await send(.response(.fetchNotificationSettingsResponse(.failure(error)))) } } ) - case .fetchNotificationSettingsResponse(.success(let settings)): + case .response(.fetchNotificationSettingsResponse(.success(let settings))): state.isNotificationSettingsLoading = false + state.isNotificationSettingsFetchFailed = false state.isPokePushEnabled = settings.isPushEnabled state.isMarketingPushEnabled = settings.isMarketingEnabled state.isNightMarketingPushEnabled = settings.isNightEnabled return .none - case .fetchNotificationSettingsResponse(.failure(let error)): + case .response(.fetchNotificationSettingsResponse(.failure(let error))): state.isNotificationSettingsLoading = false if let networkError = error as? NetworkError, networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } + state.isNotificationSettingsFetchFailed = true return .none - case .pokePushToggled(let enabled): + case .view(.notificationSettingsDataRetryTapped): + return .send(.view(.notificationSettingsOnAppear)) + + case .view(.pokePushToggled(let enabled)): @Dependency(\.notificationClient) var notificationClient // 낙관적 업데이트 state.isPokePushEnabled = enabled return .run { send in do { let settings = try await notificationClient.updatePokeSetting(enabled) - await send(.updateNotificationSettingResponse(.success(settings))) + await send(.response(.updateNotificationSettingResponse(.success(settings)))) } catch { - await send(.updateNotificationSettingResponse(.failure(error))) + await send(.response(.updateNotificationSettingResponse(.failure(error)))) } }.cancellable(id: "pokePushToggle", cancelInFlight: true) - case .marketingPushToggled(let enabled): + case .view(.marketingPushToggled(let enabled)): @Dependency(\.notificationClient) var notificationClient // 낙관적 업데이트 state.isMarketingPushEnabled = enabled return .run { send in do { let settings = try await notificationClient.updateMarketingSetting(enabled) - await send(.updateNotificationSettingResponse(.success(settings))) + await send(.response(.updateNotificationSettingResponse(.success(settings)))) } catch { - await send(.updateNotificationSettingResponse(.failure(error))) + await send(.response(.updateNotificationSettingResponse(.failure(error)))) } }.cancellable(id: "marketingPushToggle", cancelInFlight: true) - case .nightPushToggled(let enabled): + case .view(.nightPushToggled(let enabled)): @Dependency(\.notificationClient) var notificationClient // 낙관적 업데이트 state.isNightMarketingPushEnabled = enabled return .run { send in do { let settings = try await notificationClient.updateNightSetting(enabled) - await send(.updateNotificationSettingResponse(.success(settings))) + await send(.response(.updateNotificationSettingResponse(.success(settings)))) } catch { - await send(.updateNotificationSettingResponse(.failure(error))) + await send(.response(.updateNotificationSettingResponse(.failure(error)))) } }.cancellable(id: "nightPushToggle", cancelInFlight: true) - case .updateNotificationSettingResponse(.success(let settings)): + case .response(.updateNotificationSettingResponse(.success(let settings))): // 서버 응답으로 상태 동기화 state.isPokePushEnabled = settings.isPushEnabled state.isMarketingPushEnabled = settings.isMarketingEnabled state.isNightMarketingPushEnabled = settings.isNightEnabled return .none - case .updateNotificationSettingResponse(.failure(let error)): + case .response(.updateNotificationSettingResponse(.failure(let error))): // 실패 시 서버에서 다시 가져오기 if let networkError = error as? NetworkError, networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } - return .send(.notificationSettingsOnAppear) + return .send(.view(.notificationSettingsOnAppear)) - case let .checkSystemNotificationResponse(isEnabled): + case let .response(.checkSystemNotificationResponse(isEnabled)): state.isSystemNotificationEnabled = isEnabled return .none - case .enableNotificationBannerTapped: + case .view(.enableNotificationBannerTapped): return .run { _ in await MainActor.run { if let url = URL(string: UIApplication.openSettingsURLString) { @@ -440,9 +461,9 @@ private func handleNicknameEditingEnded( do { try await onboardingClient.updateProfile(nickname) - await send(.updateNicknameResponse(.success(()))) + await send(.response(.updateNicknameResponse(.success(())))) } catch { - await send(.updateNicknameResponse(.failure(error))) + await send(.response(.updateNicknameResponse(.failure(error)))) } } } diff --git a/Projects/Feature/Settings/Sources/Settings/SettingsView.swift b/Projects/Feature/Settings/Sources/Settings/SettingsView.swift index 690f4069..fdb14a13 100644 --- a/Projects/Feature/Settings/Sources/Settings/SettingsView.swift +++ b/Projects/Feature/Settings/Sources/Settings/SettingsView.swift @@ -27,15 +27,21 @@ public struct SettingsView: View { VStack(spacing: 0) { navigationBar - ScrollView { - VStack(spacing: Spacing.spacing9) { - profileSection - .padding(.horizontal, Spacing.spacing8) - - settingsListSection - .padding(.horizontal, Spacing.spacing8) + if store.isSettingsFetchFailed { + DataRetryView { + store.send(.view(.settingsDataRetryTapped)) + } + } else { + ScrollView { + VStack(spacing: Spacing.spacing9) { + profileSection + .padding(.horizontal, Spacing.spacing8) + + settingsListSection + .padding(.horizontal, Spacing.spacing8) + } + .padding(.top, Spacing.spacing8) } - .padding(.top, Spacing.spacing8) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -45,21 +51,21 @@ public struct SettingsView: View { } .onChange(of: isTextFieldFocused) { _, newValue in if !newValue && store.isEditing { - store.send(.nicknameEditingEnded) + store.send(.view(.nicknameEditingEnded)) } } .txModal(item: $store.modal) { action in switch action { case let .confirmWithIndex(index): - store.send(.languageConfirmed(index)) + store.send(.view(.languageConfirmed(index))) case .confirm: - store.send(.modalConfirmTapped) + store.send(.view(.modalConfirmTapped)) default: break } } .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .toolbar(.hidden, for: .navigationBar) } @@ -76,7 +82,7 @@ private extension SettingsView { TXNavigationBar(style: .subTitle(title: "설정", type: .back)) { action in switch action { case .backTapped: - store.send(.backButtonTapped) + store.send(.view(.backButtonTapped)) default: break @@ -114,13 +120,11 @@ private extension SettingsView { .foregroundStyle(Color.Gray.gray500) Button { - store.send(.editButtonTapped) + store.send(.view(.editButtonTapped)) } label: { Image.Icon.Symbol.edit .resizable() - .renderingMode(.template) .frame(width: 24, height: 24) - .foregroundStyle(Color.Gray.gray500) } .frame(width: 44, height: 44) } @@ -133,17 +137,18 @@ private extension SettingsView { nicknameTextField .frame(maxWidth: .infinity) } + .padding(.bottom, Spacing.spacing9) } var nicknameTextField: some View { TXTextField( text: $store.nickname, placeholderText: "닉네임을 입력해 주세요.", + isFocused: $isTextFieldFocused, submitLabel: .done, tintColor: Color.Gray.gray500, subText: .init(text: "닉네임 2-8자", state: validationState) ) - .focused($isTextFieldFocused) .onAppear { isTextFieldFocused = true } @@ -187,7 +192,7 @@ private extension SettingsView { trailing: { languageTrailing } ) { dismissKeyboard() - store.send(.languageSettingTapped) + store.send(.view(.languageSettingTapped)) } } @@ -196,7 +201,7 @@ private extension SettingsView { icon: Image.Icon.Symbol.profile, title: "계정" ) { - store.send(.accountTapped) + store.send(.view(.accountTapped)) } } @@ -205,7 +210,7 @@ private extension SettingsView { icon: Image.Icon.Symbol.info, title: "정보" ) { - store.send(.infoTapped) + store.send(.view(.infoTapped)) } } @@ -215,7 +220,7 @@ private extension SettingsView { title: "문의하기", trailing: { inquiryTrailing } ) { - store.send(.inquiryTapped) + store.send(.view(.inquiryTapped)) } } @@ -224,7 +229,7 @@ private extension SettingsView { icon: Image.Icon.Symbol.alert, title: "알림 설정" ) { - store.send(.notificationSettingTapped) + store.send(.view(.notificationSettingTapped)) } } diff --git a/Projects/Feature/Settings/Sources/WebView/SettingsWebView.swift b/Projects/Feature/Settings/Sources/WebView/SettingsWebView.swift index c4dc37b5..8537a9fd 100644 --- a/Projects/Feature/Settings/Sources/WebView/SettingsWebView.swift +++ b/Projects/Feature/Settings/Sources/WebView/SettingsWebView.swift @@ -31,7 +31,7 @@ struct SettingsWebView: View { private var navigationBar: some View { TXNavigationBar(style: .subTitle(title: title, type: .back)) { action in if action == .backTapped { - store.send(.subViewBackButtonTapped) + store.send(.view(.subViewBackButtonTapped)) } } } diff --git a/Projects/Feature/Stats/Example/Sources/StatsApp.swift b/Projects/Feature/Stats/Example/Sources/StatsApp.swift index efe4574b..6a9dad27 100644 --- a/Projects/Feature/Stats/Example/Sources/StatsApp.swift +++ b/Projects/Feature/Stats/Example/Sources/StatsApp.swift @@ -18,9 +18,15 @@ import FeatureStats import FeatureStatsInterface import FeatureProofPhoto import FeatureProofPhotoInterface +import Foundation +import SharedPerfTestingSupport @main struct StatsApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { StatsCoordinatorView( @@ -37,13 +43,61 @@ struct StatsApp: App { ) }, withDependencies: { - $0.statsClient = .previewValue + $0.statsClient = StatsApp.statsClient(for: UITestMode.seedName) $0.goalDetailFactory = .liveValue $0.makeGoalFactory = .liveValue $0.goalClient = .previewValue } ) ) + .perfRoot("stats") + .perfReadyMarker("stats") + } + } +} + +private extension StatsApp { + static func statsClient(for seed: String) -> StatsClient { + guard UITestMode.isEnabled else { return .previewValue } + switch seed { + case "scroll-50": + return perfStatsClient(count: 50) + case "stats-heavy": + // 200 deterministic stats items so the rendering driver can + // exercise multiple LazyVStack materialization windows. Mirrors + // the home-heavy seed scale used for Home rendering. + return perfStatsClient(count: 200) + default: + return .previewValue + } + } + + static func perfStatsClient(count: Int) -> StatsClient { + var client = StatsClient.previewValue + client.fetchStats = { _, _ in + Stats( + myNickname: "현수", + partnerNickname: "민정", + stats: (1...count).map { index in + Stats.StatsItem( + goalId: Int64(index), + icon: index.isMultiple(of: 2) ? "ICON_BOOK" : "ICON_HEALTH", + goalName: "Perf scroll item #\(index)", + monthlyCount: index % 30, + totalCount: nil, + stamp: index.isMultiple(of: 3) ? "CLOVER" : "FLOWER", + myStamp: .init( + completedCount: index % 12, + stampColors: [.pink200, .orange400, .purple400] + ), + partnerStamp: .init( + completedCount: index % 9, + stampColors: [.green400, .orange400, .yellow400] + ) + ) + } + ) } + return client } } diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift new file mode 100644 index 00000000..33b90ee2 --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift @@ -0,0 +1,15 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleColdLaunchTests: XCTestCase { + func testColdLaunch() { + measure(metrics: [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("stats", timeout: 30) + } + } +} diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleNavigationTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleNavigationTests.swift new file mode 100644 index 00000000..30b0bb71 --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleNavigationTests.swift @@ -0,0 +1,21 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleNavigationTests: XCTestCase { + func testTappingCellPushesStatsDetail() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("stats") + + let firstCell = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.stats.cell.'")) + .firstMatch + XCTAssertTrue(firstCell.waitForExistence(timeout: 5), "no Stats cell found") + firstCell.tap() + + let destinationReady = app.descendants(matching: .any)["feature.stats-to-stats-detail.ready"] + XCTAssertTrue( + destinationReady.waitForExistence(timeout: 10), + "stats-to-stats-detail ready marker did not appear" + ) + } +} diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleRenderingTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleRenderingTests.swift new file mode 100644 index 00000000..99a7376e --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleRenderingTests.swift @@ -0,0 +1,97 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITests for FeatureStatsExample. +/// +/// These tests are NOT benchmarks. They drive deterministic UI activity so +/// that a real-device xctrace recording (Time Profiler + Animation Hitches) +/// captures the Stats rendering path. XCTest pass/fail is correctness only. +/// +/// ## Intended use +/// +/// 1. Launch on a real device with seed `stats-heavy` (200 deterministic +/// stats items). The driver launches with `-UITEST_RENDERING_SCENARIO` +/// + `disableAnimations: false` so animations behave like production. +/// 2. Attach `xcrun xctrace record --attach FeatureStatsExample` once +/// `feature.stats.ready` exists (initial render) or after the +/// `Synthesize event` log line (scroll). +/// 3. Stop the trace when the test reports completion. +/// +/// ## Scenarios +/// +/// - `testRendering_statsHeavyInitialRender` — launch + 7s idle window. +/// Captures the initial LazyVStack materialization + idle cost on a +/// 200-cell list. +/// - `testRendering_statsHeavyScroll` — coordinate-based dense drag on +/// the visible viewport. Coordinates are anchored on the window +/// (NOT on `feature.stats.feed`, whose accessibility frame reports +/// LazyVStack content size and could land drags off-screen and bleed +/// to SpringBoard — same root cause as the Home feed-scroll fix). +/// +/// ## Determinism +/// +/// - Single seed (`stats-heavy` → 200 fixed-content cells via the new +/// `perfStatsClient(count:)` branch). +/// - Fixed coordinate-based drag pattern (25 down→up + 25 up→down = 50 +/// interactions per recording window). +/// - `disableAnimations: false` to reflect production animation timing. +/// - No XCTest `measure(metrics:)`. The driver runs once per launch. +/// +/// ## Separation from existing tests +/// +/// `StatsExampleScrollTests.testScrollFiftyCells` uses `measure(metrics:)` +/// and the smaller `scroll-50` seed; it remains as a probe-style sanity +/// signal but is NOT the authoritative rendering metric. The tests in this +/// file are the authoritative driver paths for xctrace. +final class StatsExampleRenderingTests: XCTestCase { + + /// Drives heavy initial render + 7s idle window. + func testRendering_statsHeavyInitialRender() { + let app = XCUIApplication.launchForPerf( + seed: "stats-heavy", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("stats", timeout: 30) + + let feed = app.descendants(matching: .any)["feature.stats.feed"] + XCTAssertTrue( + feed.waitForExistence(timeout: 10), + "feature.stats.feed not found — stats-heavy seed probably not delivered" + ) + + Thread.sleep(forTimeInterval: 7.0) + } + + /// Drives 50 coordinate-based drags on the visible viewport. Window- + /// normalized so the drag stays inside the safe scroll area; never + /// resolves to the LazyVStack content-size frame. + func testRendering_statsHeavyScroll() { + let app = XCUIApplication.launchForPerf( + seed: "stats-heavy", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("stats", timeout: 30) + + let feed = app.descendants(matching: .any)["feature.stats.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 10), "feature.stats.feed not found") + + // IMPORTANT: anchor coordinates on `app.windows.firstMatch`, NOT on + // `feed`. The feed's accessibility frame reports LazyVStack + // content size (very tall with 200 cells) — feed-normalized dy + // 0.20/0.85 would land far below the visible viewport and the OS + // would deliver drags to SpringBoard. Window-normalized stays in + // the visible scroll area. + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "no window") + let top = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35)) + let bottom = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.80)) + for _ in 0..<25 { + bottom.press(forDuration: 0.01, thenDragTo: top) + } + for _ in 0..<25 { + top.press(forDuration: 0.01, thenDragTo: bottom) + } + } +} diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift new file mode 100644 index 00000000..aa051973 --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift @@ -0,0 +1,22 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleScrollTests: XCTestCase { + func testScrollFiftyCells() { + let app = XCUIApplication.launchForPerf(seed: "scroll-50") + waitForFeatureReady("stats", timeout: 30) + + let feed = app.descendants(matching: .any)["feature.stats.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 5), "feature.stats.feed not found") + + measure(metrics: [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + for _ in 0..<5 { + feed.swipeUp() + } + } + } +} diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleSmokeTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleSmokeTests.swift new file mode 100644 index 00000000..3acd39b1 --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("stats") + } +} diff --git a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift index 46b79833..637d5e60 100644 --- a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift @@ -18,7 +18,10 @@ import SharedDesignSystem /// ## 사용 예시 /// ```swift /// let store = Store( -/// initialState: StatsDetailReducer.State() +/// initialState: StatsDetailReducer.State( +/// goalId: 1, +/// initialMonth: TXCalendarDate() +/// ) /// ) { /// StatsDetailReducer(reducer: Reduce { _, _ in .none }) /// } @@ -33,6 +36,8 @@ public struct StatsDetailReducer { public let goalId: Int64 public var isLoading: Bool = false + public var isCalendarFetchFailed: Bool = false + public var isSummaryFetchFailed: Bool = false public var isDropdownPresented: Bool = false public var selectedDropDownItem: GoalDropList? public var currentMonth: TXCalendarDate @@ -57,6 +62,7 @@ public struct StatsDetailReducer { } public var naviBarTitle: String { statsDetail?.goalName ?? "" } public var isCompleted: Bool { statsDetail?.isCompleted == true } + public var isFetchFailed: Bool { isCalendarFetchFailed || isSummaryFetchFailed } /// 통계 요약 영역의 단일 행 정보를 표현합니다. public struct StatsSummaryInfo: Equatable { @@ -78,15 +84,17 @@ public struct StatsDetailReducer { /// /// ## 사용 예시 /// ```swift - /// let state = StatsDetailReducer.State(goalId: 1) + /// let state = StatsDetailReducer.State( + /// goalId: 1, + /// initialMonth: TXCalendarDate() + /// ) /// ``` - public init(goalId: Int64) { + public init(goalId: Int64, initialMonth: TXCalendarDate) { self.goalId = goalId - - let currentMonth = TXCalendarDate() - self.currentMonth = currentMonth + + self.currentMonth = initialMonth self.monthlyData = TXCalendarDataGenerator.generateMonthData( - for: currentMonth, + for: initialMonth, hideAdjacentDates: true ) } @@ -95,47 +103,61 @@ public struct StatsDetailReducer { /// 통계 상세 화면에서 발생 가능한 액션입니다. public enum Action: BindableAction { case binding(BindingAction) - - // MARK: - LifeCycle - case onAppear - case onDisappear - - // MARK: - User Action - case navigationBarTapped(TXNavigationBar.Action) - case previousMonthTapped - case nextMonthTapped - case calendarSwiped(TXCalendar.SwipeGesture) - case calendarCellTapped(TXCalendarDateItem) - case dropDownSelected(GoalDropList) - case backgroundTapped - case modalConfirmTapped - - // MARK: - Network - case fetchStatsDetailCalendar - case fetchStatsDetailCalendarSuccess(StatsDetail, month: String) - case fetchStatsDetailCalendarFailed - case fetchStatsDetailSummary - case fetchStatsDetailSummarySuccess(StatsDetail.Summary) - case fetchStatsDetailSummaryFailed - case patchCompleteGoal - case completeGoalSuccees - case deleteGoal - case deleteGoalSuccees - - // MARK: - Update State - case updateStatsDetail(StatsDetail) - case updateStatsSummary(StatsDetail.Summary) - case updateMonthlyDate(([StatsDetail.CompletedDate])) - case showToast(String) - + + // MARK: - View + public enum View: Equatable { + case onAppear + case onDisappear + case navigationBarTapped(TXNavigationBar.Action) + case previousMonthTapped + case nextMonthTapped + case calendarSwiped(TXCalendar.SwipeGesture) + case calendarCellTapped(TXCalendarDateItem) + case dropDownSelected(GoalDropList) + case backgroundTapped + case modalConfirmTapped + case dataRetryTapped + } + + // MARK: - Internal + public enum Internal: Equatable { + case fetchStatsDetailCalendar + case fetchStatsDetailSummary + case patchCompleteGoal + case deleteGoal + case updateStatsDetail(StatsDetail) + case updateStatsSummary(StatsDetail.Summary) + case updateMonthlyDate(([StatsDetail.CompletedDate])) + } + + // MARK: - Response + public enum Response: Equatable { + case fetchStatsDetailCalendarSuccess(StatsDetail, month: String) + case fetchStatsDetailCalendarFailed(month: String) + case fetchStatsDetailSummarySuccess(StatsDetail.Summary) + case fetchStatsDetailSummaryFailed + case completeGoalSuccees + case deleteGoalSuccees + } + + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(String) + } + // MARK: - Delegate case delegate(Delegate) - + public enum Delegate { case navigateBack case goToGoalDetail(goalId: Int64, isCompletedPartner: Bool, date: String) case goToGoalEdit(EditableGoal) } + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입된 Reduce로 StatsDetailReducer를 구성합니다. diff --git a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift index cddf4468..6e5e3951 100644 --- a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift @@ -33,6 +33,7 @@ public struct StatsReducer { public var currentMonth: TXCalendarDate = .init() public var monthTitle: String { currentMonth.formattedYearMonth } public var isLoading: Bool = false + public var isFetchFailed: Bool = false public var isOngoing: Bool = true public var isNextMonthDisabled: Bool { currentMonth >= TXCalendarDate() @@ -47,6 +48,24 @@ public struct StatsReducer { public var ongoingItemsCache: [String: [StatsCardItem]] = [:] public var toast: TXToastType? + + public struct UIState: Equatable { + public var isLoading: Bool + public var isOngoing: Bool + + public init(isLoading: Bool = false, isOngoing: Bool = true) { + self.isLoading = isLoading + self.isOngoing = isOngoing + } + } + + public var ui: UIState { + get { UIState(isLoading: isLoading, isOngoing: isOngoing) } + set { + isLoading = newValue.isLoading + isOngoing = newValue.isOngoing + } + } /// 기본 상태를 생성합니다. /// @@ -60,31 +79,45 @@ public struct StatsReducer { /// 통계 메인 화면에서 발생 가능한 액션입니다. public enum Action: BindableAction { case binding(BindingAction) - - // MARK: - LifeCycle - case onAppear - - // MARK: - User Action - case topTabBarSelected(StatsTopTabItem) - case statsCardTapped(goalId: Int64) - case previousMonthTapped - case nextMonthTapped - - // MARK: - Network - case fetchStats - case fetchedStats(stats: Stats, month: String) - case fetchStatsFailed - - // MARK: - Update State - case showToast(TXToastType) - + + // MARK: - View + public enum View: Equatable { + case onAppear + case topTabBarSelected(StatsTopTabItem) + case statsCardTapped(goalId: Int64) + case previousMonthTapped + case nextMonthTapped + case dataRetryTapped + } + + // MARK: - Internal + public enum Internal: Equatable { + case fetchStats + } + + // MARK: - Response + public enum Response: Equatable { + case fetchedStats(stats: Stats, month: String, isOngoing: Bool) + case fetchStatsFailed(month: String, isOngoing: Bool) + } + + // MARK: - Presentation + public enum Presentation: Equatable { + case showToast(TXToastType) + } + // MARK: - Delegate case delegate(Delegate) - + /// StatsReducer가 상위 Coordinator로 전달하는 이벤트입니다. public enum Delegate { - case goToStatsDetail(goalId: Int64) + case goToStatsDetail(goalId: Int64, calendarDate: TXCalendarDate) } + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) } /// 외부에서 주입된 Reduce로 StatsReducer를 구성합니다. diff --git a/Projects/Feature/Stats/Project.swift b/Projects/Feature/Stats/Project.swift index 86cb3b12..9f16849f 100644 --- a/Projects/Feature/Stats/Project.swift +++ b/Projects/Feature/Stats/Project.swift @@ -32,6 +32,7 @@ let project = Project.makeModule( .domain(interface: .stats), .core(interface: .analytics), .shared(implements: .designSystem), + .shared(implements: .perfTestingSupport), .external(dependency: .ComposableArchitecture) ] ) @@ -72,6 +73,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .stats) ] ) diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift index 3b84a15f..9e964d3a 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift @@ -33,9 +33,9 @@ extension StatsCoordinator { let reducer = Reduce { state, action in switch action { // MARK: - Child Action - case let .stats(.delegate(.goToStatsDetail(goalId))): + case let .stats(.delegate(.goToStatsDetail(goalId, calendarDate))): state.routes.append(.statsDetail) - state.statsDetail = .init(goalId: goalId) + state.statsDetail = .init(goalId: goalId, initialMonth: calendarDate) return .none case let .statsDetail(.delegate(.goToGoalDetail(goalId, isCompletedPartner, date))): @@ -60,7 +60,7 @@ extension StatsCoordinator { state.routes.removeLast() return .none - case .goalDetail(.onDisappear): + case .goalDetail(.view(.onDisappear)): if !state.routes.contains(.goalDetail) { state.goalDetail = nil } @@ -70,7 +70,7 @@ extension StatsCoordinator { state.routes.removeLast() return .none - case .makeGoal(.onDisappear): + case .makeGoal(.view(.onDisappear)): if !state.routes.contains(.makeGoal) { state.makeGoal = nil } diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift index f358ffec..7309a7c4 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import FeatureGoalDetailInterface import FeatureMakeGoalInterface import FeatureStatsInterface +import SharedPerfTestingSupport /// Stats Feature의 루트 화면을 렌더링하는 Coordinator View입니다. public struct StatsCoordinatorView: View { @@ -43,20 +44,21 @@ public struct StatsCoordinatorView: View { .navigationDestination(for: StatsRoute.self) { route in switch route { case .statsDetail: - IfLetStore(store.scope(state: \.statsDetail, action: \.statsDetail)) { store in - StatsDetailView(store: store) + if let statsDetailStore = store.scope(state: \.statsDetail, action: \.statsDetail) { + StatsDetailView(store: statsDetailStore) .toolbar(.hidden, for: .tabBar) + .perfReadyMarker("stats-to-stats-detail") } - + case .goalDetail: - IfLetStore(store.scope(state: \.goalDetail, action: \.goalDetail)) { store in - goalDetailFactory.makeView(store) + if let goalDetailStore = store.scope(state: \.goalDetail, action: \.goalDetail) { + goalDetailFactory.makeView(goalDetailStore) .toolbar(.hidden, for: .tabBar) } case .makeGoal: - IfLetStore(store.scope(state: \.makeGoal, action: \.makeGoal)) { store in - makeGoalFactory.makeView(store) + if let makeGoalStore = store.scope(state: \.makeGoal, action: \.makeGoal) { + makeGoalFactory.makeView(makeGoalStore) .toolbar(.hidden, for: .tabBar) } } diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift index 531f0c34..c1332655 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift @@ -31,22 +31,28 @@ extension StatsDetailReducer { let reducer = Reduce { state, action in switch action { // MARK: - LifeCycle - case .onAppear: + case .view(.onAppear): return .merge( - .send(.fetchStatsDetailCalendar), - .send(.fetchStatsDetailSummary) + .send(.internal(.fetchStatsDetailCalendar)), + .send(.internal(.fetchStatsDetailSummary)) ) - case .onDisappear: + case .view(.onDisappear): return .none + + case .view(.dataRetryTapped): + return .merge( + .send(.internal(.fetchStatsDetailCalendar)), + .send(.internal(.fetchStatsDetailSummary)) + ) // MARK: - User Action - case let .navigationBarTapped(action): + case let .view(.navigationBarTapped(action)): if case .backTapped = action { return .send(.delegate(.navigateBack)) } else if case .rightTapped = action { if state.isCompleted { - return .send(.dropDownSelected(.delete)) + return .send(.view(.dropDownSelected(.delete))) } else { state.isDropdownPresented = true return .none @@ -54,32 +60,32 @@ extension StatsDetailReducer { } return .none - case .previousMonthTapped: + case .view(.previousMonthTapped): state.currentMonth.goToPreviousMonth() state.monthlyData = TXCalendarDataGenerator.generateMonthData( for: state.currentMonth, hideAdjacentDates: true ) - return .send(.fetchStatsDetailCalendar) + return .send(.internal(.fetchStatsDetailCalendar)) - case .nextMonthTapped: + case .view(.nextMonthTapped): guard !state.nextMonthDisabled else { return .none } state.currentMonth.goToNextMonth() state.monthlyData = TXCalendarDataGenerator.generateMonthData( for: state.currentMonth, hideAdjacentDates: true ) - return .send(.fetchStatsDetailCalendar) + return .send(.internal(.fetchStatsDetailCalendar)) - case let .calendarSwiped(swipe): + case let .view(.calendarSwiped(swipe)): switch swipe { case .previous: - return .send(.previousMonthTapped) + return .send(.view(.previousMonthTapped)) case .next: - return .send(.nextMonthTapped) + return .send(.view(.nextMonthTapped)) } - case let .calendarCellTapped(item): + case let .view(.calendarCellTapped(item)): guard let dateComponents = item.dateComponents, let txDate = TXCalendarDate(components: dateComponents) else { return .none } @@ -97,7 +103,7 @@ extension StatsDetailReducer { ) ) - case let .dropDownSelected(item): + case let .view(.dropDownSelected(item)): guard let detail = state.statsDetail, let summary = state.statsSummary else { return .none } let goalItem = GoalEditCardItem( @@ -144,56 +150,58 @@ extension StatsDetailReducer { } return .none - case .backgroundTapped: + case .view(.backgroundTapped): state.isDropdownPresented = false return .none - case .modalConfirmTapped: + case .view(.modalConfirmTapped): guard let selectedDropDownItem = state.selectedDropDownItem else { return .none } switch selectedDropDownItem { case .edit: return .none - case .finish: return .send(.patchCompleteGoal) - case .delete: return .send(.deleteGoal) + case .finish: return .send(.internal(.patchCompleteGoal)) + case .delete: return .send(.internal(.deleteGoal)) } - return .none - // MARK: - Network - case .fetchStatsDetailCalendar: + case .internal(.fetchStatsDetailCalendar): let month = state.currentMonth.formattedYearDashMonth let goalId = state.goalId var applyCached: Effect = .none if let cached = state.completedDateCache[month] { state.isLoading = false - applyCached = .send(.updateMonthlyDate(cached)) + state.isCalendarFetchFailed = false + applyCached = .send(.internal(.updateMonthlyDate(cached))) } else { state.isLoading = true + state.isCalendarFetchFailed = false } let fetchRemote: Effect = .run { send in do { let statsDetail = try await statsClient.fetchStatsDetailCalendar(goalId, month) - await send(.fetchStatsDetailCalendarSuccess(statsDetail, month: month)) + await send(.response(.fetchStatsDetailCalendarSuccess(statsDetail, month: month))) } catch { - await send(.fetchStatsDetailCalendarFailed) + await send(.response(.fetchStatsDetailCalendarFailed(month: month))) } } return .merge(applyCached, fetchRemote) - case .fetchStatsDetailSummary: + case .internal(.fetchStatsDetailSummary): let goalId = state.goalId + state.isSummaryFetchFailed = false return .run { send in do { let summary = try await statsClient.fetchStatsDetailSummary(goalId) - await send(.fetchStatsDetailSummarySuccess(summary)) + await send(.response(.fetchStatsDetailSummarySuccess(summary))) } catch { - await send(.fetchStatsDetailSummaryFailed) + await send(.response(.fetchStatsDetailSummaryFailed)) } } - case let .fetchStatsDetailCalendarSuccess(statsDetail, month): + case let .response(.fetchStatsDetailCalendarSuccess(statsDetail, month)): state.isLoading = false + state.isCalendarFetchFailed = false state.statsDetail = statsDetail state.completedDateCache[month] = statsDetail.completedDate.filter { $0.date.hasPrefix(month) } @@ -202,53 +210,57 @@ extension StatsDetailReducer { return .none } - return .send(.updateMonthlyDate(state.completedDateCache[month] ?? [])) + return .send(.internal(.updateMonthlyDate(state.completedDateCache[month] ?? []))) - case .fetchStatsDetailCalendarFailed: + case let .response(.fetchStatsDetailCalendarFailed(month)): + guard month == state.currentMonth.formattedYearDashMonth else { return .none } state.isLoading = false + state.isCalendarFetchFailed = true return .none - case let .fetchStatsDetailSummarySuccess(summary): - return .send(.updateStatsSummary(summary)) + case let .response(.fetchStatsDetailSummarySuccess(summary)): + state.isSummaryFetchFailed = false + return .send(.internal(.updateStatsSummary(summary))) - case .fetchStatsDetailSummaryFailed: + case .response(.fetchStatsDetailSummaryFailed): + state.isSummaryFetchFailed = true return .none - case .patchCompleteGoal: + case .internal(.patchCompleteGoal): let goalId = state.goalId return .run { send in do { _ = try await goalClient.completeGoal(goalId) - await send(.completeGoalSuccees) + await send(.response(.completeGoalSuccees)) } catch { - await send(.showToast("이미 끝났습니다.")) + await send(.presentation(.showToast("이미 끝났습니다."))) } } - case .completeGoalSuccees: + case .response(.completeGoalSuccees): state.statsDetail?.isCompleted = true return .none - case .deleteGoal: + case .internal(.deleteGoal): let goalId = state.goalId return .run { send in do { try await goalClient.deleteGoal(goalId) - await send(.deleteGoalSuccees) + await send(.response(.deleteGoalSuccees)) } catch { - await send(.showToast("목표 삭제에 실패했어요")) + await send(.presentation(.showToast("목표 삭제에 실패했어요"))) } } - case .deleteGoalSuccees: + case .response(.deleteGoalSuccees): return .send(.delegate(.navigateBack)) // MARK: - Update State - case let .updateStatsDetail(statsDetail): + case let .internal(.updateStatsDetail(statsDetail)): state.statsDetail = statsDetail return .none - case let .updateStatsSummary(summary): + case let .internal(.updateStatsSummary(summary)): state.statsSummary = summary let myCountString = "\(summary.myNickname) - \(summary.myCompletedCount)/\(summary.totalCount)" let partnerCountString = "\(summary.partnerNickname) - \(summary.partnerCompltedCount)/\(summary.totalCount)" @@ -263,7 +275,7 @@ extension StatsDetailReducer { state.statsSummaryInfo = summaryInfo return .none - case let .updateMonthlyDate(completedDate): + case let .internal(.updateMonthlyDate(completedDate)): state.completedDateByKey = completedDate.reduce(into: [:]) { result, item in guard item.myImageUrl != nil || item.partnerImageUrl != nil else { return } result[item.date] = item @@ -289,7 +301,7 @@ extension StatsDetailReducer { } return .none - case let .showToast(text): + case let .presentation(.showToast(text)): state.toast = .warning(message: text) return .none diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift index e4b54617..1f46690d 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift @@ -20,19 +20,25 @@ struct StatsDetailView: View { var body: some View { VStack(spacing: 0) { navigationBar - - ScrollView { - VStack(spacing: 0) { - monthNavigation - .padding(.top, 24) - calendar - .padding(.top, 12) - statsInfoContent - .padding(.top, 44) - - Spacer() + + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else { + ScrollView { + VStack(spacing: 0) { + monthNavigation + .padding(.top, 24) + calendar + .padding(.top, 12) + statsInfoContent + .padding(.top, 44) + + Spacer() + } + .padding(.horizontal, 20) } - .padding(.horizontal, 20) } } .background(Color.Gray.gray50) @@ -41,7 +47,7 @@ struct StatsDetailView: View { TXDropdown( items: GoalDropList.allCases, onSelect: { item in - store.send(.dropDownSelected(item)) + store.send(.view(.dropDownSelected(item))) } ) .offset(x: -12, y: 65) @@ -49,18 +55,18 @@ struct StatsDetailView: View { } .toolbar(.hidden, for: .navigationBar) .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .onDisappear { - store.send(.onDisappear) + store.send(.view(.onDisappear)) } .onTapGesture { guard store.isDropdownPresented else { return } - store.send(.backgroundTapped) + store.send(.view(.backgroundTapped)) } .txModal(item: $store.modal) { action in if action == .confirm { - store.send(.modalConfirmTapped) + store.send(.view(.modalConfirmTapped)) } } .txToast(item: $store.toast) @@ -82,7 +88,7 @@ private extension StatsDetailView { ) ), onAction: { action in - store.send(.navigationBarTapped(action)) + store.send(.view(.navigationBarTapped(action))) } ) } @@ -92,8 +98,8 @@ private extension StatsDetailView { title: store.currentMonthTitle, isPreviousDisabled: store.previousMonthDisabled, isNextDisabled: store.nextMonthDisabled, - onPrevious: { store.send(.previousMonthTapped) }, - onNext: { store.send(.nextMonthTapped) } + onPrevious: { store.send(.view(.previousMonthTapped)) }, + onNext: { store.send(.view(.nextMonthTapped)) } ) } @@ -124,16 +130,16 @@ private extension StatsDetailView { ), onSelect: { item in if item.status == .completed { - store.send(.calendarCellTapped(item)) + store.send(.view(.calendarCellTapped(item))) } }, onSwipe: { swipe in - store.send(.calendarSwiped(swipe)) + store.send(.view(.calendarSwiped(swipe))) } ) .padding(.top, 24) .padding(.bottom, 32) - .background(Color.Common.white) + .background(Color.Common.white, in: RoundedRectangle(cornerRadius: 16)) .insideBorder( Color.Gray.gray500, shape: RoundedRectangle(cornerRadius: 16), @@ -156,8 +162,7 @@ private extension StatsDetailView { summaryTitle(for: summary.title) summartyContent(content: summary.content, isCompletedCount: summary.isCompletedCount) .layoutPriority(1) - - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -188,7 +193,6 @@ private extension StatsDetailView { Text(content[0]) .typography(.b4_12b) .foregroundStyle(Color.Gray.gray500) - .lineLimit(1) if isCompletedCount { Text("|") @@ -199,11 +203,9 @@ private extension StatsDetailView { Text(content[1]) .typography(.b4_12b) .foregroundStyle(Color.Gray.gray500) - .lineLimit(1) } } - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder @@ -266,7 +268,10 @@ private extension StatsDetailView { #Preview { StatsDetailView( store: Store( - initialState: StatsDetailReducer.State(goalId: 1), + initialState: StatsDetailReducer.State( + goalId: 1, + initialMonth: TXCalendarDate() + ), reducer: { StatsDetailReducer() } ) ) diff --git a/Projects/Feature/Stats/Sources/Stats/StatsPerfSupport.swift b/Projects/Feature/Stats/Sources/Stats/StatsPerfSupport.swift new file mode 100644 index 00000000..a05db76a --- /dev/null +++ b/Projects/Feature/Stats/Sources/Stats/StatsPerfSupport.swift @@ -0,0 +1,67 @@ +// +// StatsPerfSupport.swift +// FeatureStats +// + +import SwiftUI + +import SharedDesignSystem +import SharedPerfTestingSupport + +#if PERF_TESTING +/// Pass 4-S3 Stats feed self-run scroll 전용 harness입니다. +/// `StatsView`에서 ScrollViewReader / Task 상태를 분리합니다. +struct StatsSelfRunScrollHarness: View { + let items: [StatsCardItem] + private let content: Content + + /// Pass 4-S3 — guards the self-run scroll Task so it only fires once + /// per scene appearance, even if SwiftUI invalidates the view during + /// initial layout settling. + @State private var selfRunStatsScrollStarted: Bool = false + /// Pass 4-S3 — flips to `"true"` once the self-run scrollTo sequence + /// completes. Surfaced via `perfStateMarker` so trace analysis can + /// isolate the post-scroll window. + @State private var selfRunStatsScrollDone: String = "false" + + init( + items: [StatsCardItem], + @ViewBuilder content: () -> Content + ) { + self.items = items + self.content = content() + } + + var body: some View { + ScrollViewReader { proxy in + content + .perfStateMarker( + slug: "stats", + key: "swiftui-selfrun-scroll", + value: selfRunStatsScrollDone + ) + .onAppear { startSelfRunStatsScrollIfNeeded(proxy: proxy) } + } + } + + private func startSelfRunStatsScrollIfNeeded(proxy: ScrollViewProxy) { + guard !selfRunStatsScrollStarted else { return } + selfRunStatsScrollStarted = true + let allIds = items.map(\.goalId) + let stridedTargets = stride(from: 5, to: allIds.count, by: 5) + .compactMap { allIds.indices.contains($0) ? allIds[$0] : nil } + let preRollNanos: UInt64 = 1_000_000_000 + let stepIntervalNanos: UInt64 = 300_000_000 + Task { @MainActor in + try? await Task.sleep(nanoseconds: preRollNanos) + for id in stridedTargets { + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(id, anchor: .top) + } + try? await Task.sleep(nanoseconds: stepIntervalNanos) + } + selfRunStatsScrollDone = "true" + } + } +} +#endif diff --git a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift index 8f7e1c23..88430640 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift @@ -31,33 +31,37 @@ extension StatsReducer { let reducer = Reduce { state, action in switch action { // MARK: - LifeCycle - case .onAppear: + case .view(.onAppear): analyticsClient.logEvent(StatsAnalyticsEvent.viewed) - return .send(.fetchStats) + return .send(.internal(.fetchStats)) // MARK: - UserAction - case let .topTabBarSelected(item): + case let .view(.topTabBarSelected(item)): state.isOngoing = item == .ongoing - return .send(.fetchStats) + return .send(.internal(.fetchStats)) - case .previousMonthTapped: + case .view(.previousMonthTapped): state.currentMonth.goToPreviousMonth() - return .send(.fetchStats) + return .send(.internal(.fetchStats)) - case .nextMonthTapped: + case .view(.nextMonthTapped): state.currentMonth.goToNextMonth() - return .send(.fetchStats) + return .send(.internal(.fetchStats)) + + case .view(.dataRetryTapped): + return .send(.internal(.fetchStats)) - case let .statsCardTapped(goalId): - return .send(.delegate(.goToStatsDetail(goalId: goalId))) + case let .view(.statsCardTapped(goalId)): + let date = state.isOngoing ? state.currentMonth : TXCalendarDate() + return .send(.delegate(.goToStatsDetail(goalId: goalId, calendarDate: date))) // MARK: - Update State - case let .showToast(toast): + case let .presentation(.showToast(toast)): state.toast = toast return .none // MARK: - Network - case .fetchStats: + case .internal(.fetchStats): let isOngoing = state.isOngoing let month = state.currentMonth.formattedYearDashMonth @@ -65,21 +69,33 @@ extension StatsReducer { let cachedItems = state.ongoingItemsCache[month] { state.ongoingItems = cachedItems state.isLoading = false + state.isFetchFailed = false } else { state.isLoading = true + state.isFetchFailed = false } return .run { send in do { let stats = try await statsClient.fetchStats(month, isOngoing) - await send(.fetchedStats(stats: stats, month: month)) + await send(.response(.fetchedStats( + stats: stats, + month: month, + isOngoing: isOngoing)) + ) } catch { - await send(.fetchStatsFailed) + await send(.response(.fetchStatsFailed(month: month, isOngoing: isOngoing))) } } - case let .fetchedStats(stats, month): + case let .response(.fetchedStats(stats, month, isOngoing)): + guard month == state.currentMonth.formattedYearDashMonth, + isOngoing == state.isOngoing else { + return .none + } + state.isLoading = false + state.isFetchFailed = false let items = stats.stats.map { let goalCount = $0.monthlyCount ?? $0.totalCount ?? 0 @@ -104,16 +120,11 @@ extension StatsReducer { ) } - if state.isOngoing { + if isOngoing { state.ongoingItemsCache[month] = items } - // 요청 시점의 탭/월과 현재 상태가 같을 때만 화면을 업데이트합니다. - guard month == state.currentMonth.formattedYearDashMonth else { - return .none - } - - if state.isOngoing { + if isOngoing { state.ongoingItems = items } else { state.completedItems = items @@ -121,9 +132,14 @@ extension StatsReducer { return .none - case .fetchStatsFailed: + case let .response(.fetchStatsFailed(month, isOngoing)): + guard month == state.currentMonth.formattedYearDashMonth, + isOngoing == state.isOngoing else { + return .none + } state.isLoading = false - return .send(.showToast(.warning(message: "통계 조회에 실패했어요"))) + state.isFetchFailed = true + return .none case .delegate: return .none diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index 8d010adc..1ea9a743 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -10,33 +10,32 @@ import SwiftUI import ComposableArchitecture import FeatureStatsInterface import SharedDesignSystem +import SharedPerfTestingSupport struct StatsView: View { @Bindable public var store: StoreOf - + var body: some View { VStack(spacing: 0) { navigationBar topTabBar - if store.isOngoing { - monthNavigation - .padding(.top, 16) - .background(Color.Gray.gray50) - } - - if let items = store.items, !items.isEmpty { - cardList + + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else if let items = store.items { + if items.isEmpty { + statsEmptyView + } else { + cardList + } + } else { + Spacer() } - - Spacer() } .background(Color.Gray.gray50) - .overlay { - if let items = store.items, items.isEmpty { - statsEmptyView - } - } - .onAppear { store.send(.onAppear) } + .onAppear { store.send(.view(.onAppear)) } .txToast(item: $store.toast) .toolbar(.hidden, for: .tabBar) } @@ -53,7 +52,7 @@ private extension StatsView { style: .line(StatsTopTabItem.allCases), selectedItem: store.isOngoing ? .ongoing : .completed, onSelect: { item in - store.send(.topTabBarSelected(item)) + store.send(.view(.topTabBarSelected(item))) } ) .background(Color.Common.white) @@ -65,36 +64,60 @@ private extension StatsView { title: store.monthTitle, onTitleTap: { }, isNextDisabled: store.isNextMonthDisabled, - onPrevious: { store.send(.previousMonthTapped) }, - onNext: { store.send(.nextMonthTapped) } + onPrevious: { store.send(.view(.previousMonthTapped)) }, + onNext: { store.send(.view(.nextMonthTapped)) } ) } + @ViewBuilder var cardList: some View { + #if PERF_TESTING + if UITestMode.isEnabled, UITestMode.isSwiftUISelfRunStatsScroll { + StatsSelfRunScrollHarness(items: store.items ?? []) { + scrollCardList + } + } else { + scrollCardList + } + #else + scrollCardList + #endif + } + + private var scrollCardList: some View { ScrollView { + if store.isOngoing { + monthNavigation + .padding(.top, 16) + .background(Color.Gray.gray50) + } + LazyVStack(spacing: 16) { ForEach(store.items ?? [], id: \.self.goalId) { item in StatsCardView( item: item, isOngoing: store.isOngoing, onTap: { goalId in - store.send(.statsCardTapped(goalId: goalId)) + store.send(.view(.statsCardTapped(goalId: goalId))) } ) + .perfCell(slug: "stats", stableId: item.goalId) } } .padding(.top, store.isOngoing ? 12 : 20) - .padding([.horizontal, .bottom], 20) + .padding(.horizontal, 20) + .padding(.bottom, 85 + TXTabBarLayout.height) + .perfFeed("stats") } .background(Color.Gray.gray50) } - + var statsEmptyView: some View { Group { if store.isOngoing { VStack(spacing: 8) { Image.Illustration.scare - Text("아직 목표가 없어요!") + Text("이 달은 목표가 없어요!") .typography(.t2_16b) .foregroundStyle(Color.Gray.gray400) } diff --git a/Projects/Feature/Stats/Testing/Sources/Source.swift b/Projects/Feature/Stats/Testing/Sources/Source.swift index 5bf96b27..c2ec005a 100644 --- a/Projects/Feature/Stats/Testing/Sources/Source.swift +++ b/Projects/Feature/Stats/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 02/18/26. // -/// Remove Or Edit +/// Stable perf seed names for the Stats example app. +public enum StatsPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile.png b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile.png index 9b6213fd..7ef194de 100644 Binary files a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile.png and b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile.png differ diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@2x.png b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@2x.png index 3680c3c9..8312353c 100644 Binary files a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@2x.png and b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@2x.png differ diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@3x.png b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@3x.png index ba42ffe3..3c7670bc 100644 Binary files a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@3x.png and b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Illustration/icon_profile.imageset/icon_profile@3x.png differ diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/Contents.json index c596a82d..454d698d 100644 --- a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/Contents.json @@ -1,15 +1,23 @@ { "images" : [ { - "filename" : "ic_edit.svg", - "idiom" : "universal" + "filename" : "ic_edit.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_edit@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_edit@3x.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true } } diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit.png b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit.png new file mode 100644 index 00000000..f4b0064f Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit.png differ diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit.svg b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit.svg deleted file mode 100644 index 84f24af7..00000000 --- a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit@2x.png b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit@2x.png new file mode 100644 index 00000000..ee567a0b Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit@2x.png differ diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit@3x.png b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit@3x.png new file mode 100644 index 00000000..97637a2e Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Icons/Symbol/ic_edit.imageset/ic_edit@3x.png differ diff --git a/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift b/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift index eab4e17b..2fb422d9 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift @@ -23,7 +23,7 @@ struct TXTabBar: View { tabItemView(item: item) } } - .frame(height: Constants.tabBarHeight) + .frame(height: TXTabBarLayout.height) .background(Constants.backgroundColor) .insideRectEdgeBorder( width: Constants.borderWidth, @@ -53,13 +53,13 @@ private extension TXTabBar { .padding(.top, Constants.topPadding) } .buttonStyle(.plain) + .accessibilityIdentifier("tx.tab-bar.item.\(item.accessibilityIdentifier)") } } // MARK: - Constants private extension TXTabBar { enum Constants { - static let tabBarHeight: CGFloat = 58 static let iconSize: CGFloat = 24 static let iconLabelSpacing: CGFloat = 4 static let topPadding: CGFloat = 12 @@ -70,3 +70,18 @@ private extension TXTabBar { static let labelFont: TypographyToken = .c2_11b } } + +private extension TXTabItem { + var accessibilityIdentifier: String { + switch self { + case .home: + return "home" + + case .statistics: + return "statistics" + + case .couple: + return "couple" + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBarLayout.swift b/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBarLayout.swift new file mode 100644 index 00000000..d2f29d64 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBarLayout.swift @@ -0,0 +1,13 @@ +// +// TXTabBarLayout.swift +// SharedDesignSystem +// +// Created by 정지훈 on 6/1/26. +// + +import Foundation + +/// 하단 탭바와 주변 레이아웃에서 공유하는 치수입니다. +public enum TXTabBarLayout { + public static let height: CGFloat = 58 +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift index fe60c322..9792380d 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift @@ -18,16 +18,16 @@ struct TXRectButton: View { .typography(style.typography ?? size.typhography) .foregroundStyle(state.fontColor) .padding(.horizontal, size.horizontalPadding) - .frame(minWidth: size.minWidth, maxWidth: size.maxWidth) - .frame(height: size.height) + .frame(minWidth: size.minWidth(style: style), maxWidth: size.maxWidth(style: style)) + .frame(height: size.height(style: style)) .background(state.backgroundColor) .insideBorder( state.borderColor, - shape: RoundedRectangle(cornerRadius: size.radius), + shape: RoundedRectangle(cornerRadius: size.radius(style: style)), lineWidth: state.borderWidth ) } - .clipShape(RoundedRectangle(cornerRadius: size.radius)) + .clipShape(RoundedRectangle(cornerRadius: size.radius(style: style))) .padding(.vertical, size.outVerticalPadding) .buttonStyle(.plain) } else { @@ -41,36 +41,44 @@ private extension TXButtonShape.TXRectStyle { var text: String { switch self { case .basic(let text, _): text + case .round(let text): text } } var typography: TypographyToken? { switch self { case .basic(_, let typography): typography + case .round: nil } } } private extension TXButtonShape.TXRectSize { - var maxWidth: CGFloat? { - switch self { - case .l: .infinity - case .m: 151 - case .s: nil + func maxWidth(style: TXButtonShape.TXRectStyle) -> CGFloat? { + switch (style, self) { + case (.basic, .l): .infinity + case (.basic, .m): 151 + case (.basic, .s): nil + case (.round, .s): .infinity + case (.round, .m), (.round, .l): nil } } - var minWidth: CGFloat? { - switch self { - case .l, .m: nil - case .s: 56 + func minWidth(style: TXButtonShape.TXRectStyle) -> CGFloat? { + switch (style, self) { + case (.basic, .l), (.basic, .m): nil + case (.basic, .s): 56 + case (.round, .s): 100 + case (.round, .m), (.round, .l): nil } } - var height: CGFloat { - switch self { - case .l, .m: 52 - case .s: 32 + func height(style: TXButtonShape.TXRectStyle) -> CGFloat? { + switch (style, self) { + case (.basic, .l), (.basic, .m): 52 + case (.basic, .s): 32 + case (.round, .s): 42 + case (.round, .m), (.round, .l): nil } } @@ -81,13 +89,15 @@ private extension TXButtonShape.TXRectSize { } } - var radius: CGFloat { - switch self { - case .l, .m: Radius.s - case .s: Radius.xs + func radius(style: TXButtonShape.TXRectStyle) -> CGFloat { + switch (style, self) { + case (.basic, .l), (.basic, .m): Radius.s + case (.basic, .s): Radius.xs + case (.round, .s): 999 + case (.round, .m), (.round, .l): .zero } } - + var horizontalPadding: CGFloat { switch self { case .s: Spacing.spacing6 diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift index cbcebbb3..b72bdaf1 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift @@ -9,21 +9,27 @@ import SwiftUI struct TXRoundButton: View { let shape: TXButtonShape + let allowsActionWhenDisabled: Bool let onTap: () -> Void public var body: some View { if case let .round(style, size, state) = shape { Button { - if state != .disabled { + switch state { + case .disabled: + if allowsActionWhenDisabled { + onTap() + } + case .standard: onTap() } } label: { - ZStack { + ZStack(alignment: .top) { Capsule() .fill(style.backgroundColor(state: state)) .frame(maxWidth: size.frameWidth) - .frame(height: size.backgroundHeight(state: state)) - .padding(.top, size.bottomYOffset(state: state)) + .frame(height: size.backgroundHeight) + .padding(.top, size.backgroundYOffset) Text(style.text) .typography(size.typography) @@ -36,8 +42,8 @@ struct TXRoundButton: View { lineWidth: size.borderWidth ) .background(style.foregroundColor(state: state), in: .capsule) + .padding(.top, size.foregroundYOffset(state: state)) } - .padding(.top, size.topYOffset(state: state)) } .buttonStyle(.plain) } else { @@ -114,29 +120,19 @@ private extension TXButtonShape.TXRoundSize { } } - func backgroundHeight(state: TXButtonShape.TXRoundState) -> CGFloat { + var backgroundHeight: CGFloat { switch self { case .l, .m: 70 - case .s: - switch state { - case .standard: 31 - case .disabled: 28 - } + case .s: 28 } } - func bottomYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { - switch self { - case .s, .l, .m: - switch state { - case .standard: 4 - case .disabled: 1 - } - } + var backgroundYOffset: CGFloat { + 4 } - func topYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { + func foregroundYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { switch self { case .s, .l, .m: switch state { diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift index 6af90249..cf8d27e7 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift @@ -24,6 +24,7 @@ import SwiftUI /// ``` public struct TXButton: View { let shape: TXButtonShape + let allowsActionWhenDisabled: Bool let onTap: () -> Void /// 버튼을 생성합니다. @@ -41,9 +42,11 @@ public struct TXButton: View { /// ``` public init( shape: TXButtonShape, + allowsActionWhenDisabled: Bool = false, onTap: @escaping () -> Void ) { self.shape = shape + self.allowsActionWhenDisabled = allowsActionWhenDisabled self.onTap = onTap } @@ -65,6 +68,7 @@ public struct TXButton: View { case .round: TXRoundButton( shape: shape, + allowsActionWhenDisabled: allowsActionWhenDisabled, onTap: onTap ) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift index ed274687..b0dc32f8 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift @@ -48,6 +48,7 @@ public enum TXButtonShape { /// 사각형 버튼의 표시 방식을 정의하는 타입입니다. public enum TXRectStyle { case basic(text: String, typography: TypographyToken? = nil) + case round(text: String) } /// 사각형 버튼의 크기 단계를 정의하는 타입입니다. diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift index 7027e14b..e9ad2060 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift @@ -23,7 +23,7 @@ enum CalendarSheetConstants { static let dragVelocityThreshold: CGFloat = 500 static let springResponse: Double = 0.35 static let springDamping: Double = 0.86 - static let hiddenOffsetFallback: CGFloat = 1000 + static let hiddenOffsetFallback: CGFloat = 1_000 } // MARK: - Calendar Sheet Modifier @@ -94,7 +94,11 @@ struct CalendarSheetModifier: ViewModifier { .padding(.bottom, safeAreaBottom) .background(Color.Common.white) .clipShape(.rect(cornerRadii: topCornerRadii)) - .transaction { $0.animation = nil } + .transaction { transaction in + if dragOffset != 0 { + transaction.animation = nil + } + } } @ViewBuilder @@ -107,6 +111,7 @@ struct CalendarSheetModifier: ViewModifier { onComplete: onComplete, isDateEnabled: isDateEnabled ) + case let .custom(content): TXCalendarBottomSheet( selectedDate: $selectedDate, diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift index f53d5162..01637b05 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift @@ -36,6 +36,7 @@ public struct TXCalendarBottomSheet: View { @Binding private var selectedDate: TXCalendarDate @State private var isDatePickerMode = false @State private var frozenCalendarHeight: CGFloat? + @State private var calendarData: CalendarPresentationData private let buttonContent: (_ exitPickerModeIfNeeded: @escaping () -> Bool) -> ButtonContent private let completeButtonText: String? @@ -65,6 +66,7 @@ public struct TXCalendarBottomSheet: View { @ViewBuilder buttonContent: @escaping (_ exitPickerModeIfNeeded: @escaping () -> Bool) -> ButtonContent ) { self._selectedDate = selectedDate + self._calendarData = State(initialValue: Self.makeCalendarData(for: selectedDate.wrappedValue)) self.buttonContent = buttonContent self.completeButtonText = nil self.onComplete = nil @@ -72,9 +74,11 @@ public struct TXCalendarBottomSheet: View { } public var body: some View { - let currentWeeks = TXCalendarDataGenerator.generateMonthData(for: selectedDate) - let displayWeeks = applyDisabledStatus(to: currentWeeks) - let currentCalendarHeight = calendarContentHeight(for: currentWeeks) + let currentData = calendarData.matches(selectedDate) + ? calendarData + : Self.makeCalendarData(for: selectedDate) + let displayWeeks = applyDisabledStatus(to: currentData.weeks) + let currentCalendarHeight = currentData.height VStack(spacing: 0) { // MonthNavigation + Calendar @@ -87,8 +91,8 @@ public struct TXCalendarBottomSheet: View { } isDatePickerMode.toggle() }, - onPrevious: { selectedDate.goToPreviousMonth() }, - onNext: { selectedDate.goToNextMonth() } + onPrevious: { updateSelectedDate { $0.goToPreviousMonth() } }, + onNext: { updateSelectedDate { $0.goToNextMonth() } } ) if isDatePickerMode { @@ -101,7 +105,7 @@ public struct TXCalendarBottomSheet: View { config: calendarConfig ) { item in if let day = Int(item.text), item.status != .lastDate { - selectedDate.selectDay(day) + updateSelectedDate { $0.selectDay(day) } } } } @@ -113,11 +117,19 @@ public struct TXCalendarBottomSheet: View { } .frame(maxWidth: .infinity) .background(Color.Common.white) + .overlay { + Color.clear + .accessibilityIdentifier("tx.calendar-bottom-sheet") + .allowsHitTesting(false) + } .onChange(of: isDatePickerMode) { _, newValue in if !newValue { frozenCalendarHeight = nil } } + .onChange(of: selectedDate) { _, newValue in + updateCalendarData(for: newValue) + } } } @@ -140,6 +152,7 @@ public extension TXCalendarBottomSheet where ButtonContent == DefaultCalendarBut isDateEnabled: ((TXCalendarDateItem) -> Bool)? = nil ) { self._selectedDate = selectedDate + self._calendarData = State(initialValue: Self.makeCalendarData(for: selectedDate.wrappedValue)) self.buttonContent = { _ in DefaultCalendarButton(text: completeButtonText, action: onComplete) } @@ -164,28 +177,62 @@ public struct DefaultCalendarButton: View { onTap: action ) .padding(.horizontal, Spacing.spacing8) + .accessibilityIdentifier("tx.calendar-bottom-sheet.complete-button") } } // MARK: - Private Views private extension TXCalendarBottomSheet { - var calendarConfig: TXCalendar.Configuration { + static var minimumMonthlyRowCount: Int { 6 } + + static var calendarConfig: TXCalendar.Configuration { .init( monthlyHeaderSpacing: Spacing.spacing7, - monthlyRowSpacing: Spacing.spacing6 + monthlyRowSpacing: Spacing.spacing6, + monthlyPaging: .init(minimumRowCount: minimumMonthlyRowCount) ) } - func calendarContentHeight(for weeks: [[TXCalendarDateItem]]) -> CGFloat { - let headerHeight = TXCalendarLayout.weekdayLabelHeight(calendarConfig.weekdayTypography) - let headerSectionHeight = headerHeight + calendarConfig.monthlyHeaderSpacing - let verticalPadding = calendarConfig.verticalPadding * 2 + var calendarConfig: TXCalendar.Configuration { + let isDateEnabled = isDateEnabled + return .init( + monthlyHeaderSpacing: Spacing.spacing7, + monthlyRowSpacing: Spacing.spacing6, + monthlyPaging: .init( + isEnabled: true, + pageSpacing: Spacing.spacing7, + minimumRowCount: Self.minimumMonthlyRowCount, + pageWeeks: { date in + let weeks = Self.makeCalendarData(for: date).weeks + return Self.applyDisabledStatus( + to: weeks, + isDateEnabled: isDateEnabled + ) + } + ) + ) + } + + static func makeCalendarData(for date: TXCalendarDate) -> CalendarPresentationData { + let weeks = TXCalendarDataGenerator.generateMonthData(for: date) + return CalendarPresentationData( + key: .init(date), + weeks: weeks, + height: calendarContentHeight(for: weeks) + ) + } + + static func calendarContentHeight(for weeks: [[TXCalendarDateItem]]) -> CGFloat { + let config = calendarConfig + let headerHeight = TXCalendarLayout.weekdayLabelHeight(config.weekdayTypography) + let headerSectionHeight = headerHeight + config.monthlyHeaderSpacing + let verticalPadding = config.verticalPadding * 2 guard !weeks.isEmpty else { return headerSectionHeight + verticalPadding } - let rowCount = CGFloat(weeks.count) - let rowSpacing = calendarConfig.monthlyRowSpacing * CGFloat(weeks.count - 1) - let monthGridHeight = (calendarConfig.dateStyle.size * rowCount) + rowSpacing + let rowCount = max(weeks.count, Self.minimumMonthlyRowCount) + let rowSpacing = config.monthlyRowSpacing * CGFloat(max(rowCount - 1, 0)) + let monthGridHeight = (config.dateStyle.size * CGFloat(rowCount)) + rowSpacing return headerSectionHeight + monthGridHeight + verticalPadding } @@ -215,14 +262,14 @@ private extension TXCalendarBottomSheet { func datePickerView(height: CGFloat) -> some View { HStack(spacing: 0) { - Picker("Year", selection: $selectedDate.year) { - ForEach(1940...2099, id: \.self) { year in + Picker("Year", selection: selectedYear) { + ForEach(1_940...2_099, id: \.self) { year in Text(verbatim: "\(year)년").tag(year) } } .pickerStyle(.wheel) - Picker("Month", selection: $selectedDate.month) { + Picker("Month", selection: selectedMonth) { ForEach(1...12, id: \.self) { month in Text(verbatim: "\(month)월").tag(month) } @@ -233,7 +280,51 @@ private extension TXCalendarBottomSheet { .padding(.horizontal, Spacing.spacing7) } + var selectedYear: Binding { + Binding( + get: { selectedDate.year }, + set: { year in + updateSelectedDate { date in + date.year = year + } + } + ) + } + + var selectedMonth: Binding { + Binding( + get: { selectedDate.month }, + set: { month in + updateSelectedDate { date in + date.month = month + } + } + ) + } + + func updateSelectedDate(_ update: (inout TXCalendarDate) -> Void) { + var newDate = selectedDate + update(&newDate) + selectedDate = newDate + updateCalendarData(for: newDate) + } + + func updateCalendarData(for date: TXCalendarDate) { + guard !calendarData.matches(date) else { return } + calendarData = Self.makeCalendarData(for: date) + } + func applyDisabledStatus(to weeks: [[TXCalendarDateItem]]) -> [[TXCalendarDateItem]] { + Self.applyDisabledStatus( + to: weeks, + isDateEnabled: isDateEnabled + ) + } + + static func applyDisabledStatus( + to weeks: [[TXCalendarDateItem]], + isDateEnabled: ((TXCalendarDateItem) -> Bool)? + ) -> [[TXCalendarDateItem]] { guard let isDateEnabled else { return weeks } return weeks.map { week in week.map { item in @@ -249,3 +340,25 @@ private extension TXCalendarBottomSheet { } } } + +private struct CalendarPresentationData: Equatable { + struct Key: Equatable { + let year: Int + let month: Int + let day: Int? + + init(_ date: TXCalendarDate) { + self.year = date.year + self.month = date.month + self.day = date.day + } + } + + let key: Key + let weeks: [[TXCalendarDateItem]] + let height: CGFloat + + func matches(_ date: TXCalendarDate) -> Bool { + key == Key(date) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Layout.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Layout.swift new file mode 100644 index 00000000..0e1bdced --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Layout.swift @@ -0,0 +1,227 @@ +// +// TXCalendar+Layout.swift +// SharedDesignSystem +// +// Created by Codex on 5/30/26. +// + +import SwiftUI + +// MARK: - Helpers +extension TXCalendar { + var headerSpacing: CGFloat { + switch mode { + case .weekly: config.weeklyHeaderSpacing + case .monthly: config.monthlyHeaderSpacing + } + } + + var horizontalPadding: CGFloat { + switch mode { + case .weekly: config.weeklyHorizontalPadding + case .monthly: config.monthlyHorizontalPadding + } + } + + var contentHeight: CGFloat { + let headerSectionHeight = config.weekdayHeight + headerSpacing + let verticalPadding: CGFloat = config.verticalPadding * 2 + + switch mode { + case .weekly: return headerSectionHeight + config.dateStyle.size + config.weeklyBottomPadding + verticalPadding + case .monthly: return headerSectionHeight + monthGridHeight + verticalPadding + } + } + + var monthGridHeight: CGFloat { + guard !weeks.isEmpty else { return 0 } + + let rowCount = max(weeks.count, config.monthlyPaging.minimumRowCount ?? 0) + let rowSpacing = config.monthlyRowSpacing * CGFloat(max(rowCount - 1, 0)) + return (config.dateStyle.size * CGFloat(rowCount)) + rowSpacing + } + + var monthlyPageHeight: CGFloat { + config.weekdayHeight + headerSpacing + monthGridHeight + } + + var isMonthlyVisualPagingEnabled: Bool { + mode == .monthly && config.monthlyPaging.isEnabled && currentDate != nil + } + + static var pagingAnimation: Animation { + .easeInOut(duration: 0.22) + } + + func weeklyPageSpacing(dayColumnSpacing: CGFloat) -> CGFloat { + dayColumnSpacing + } + + func weeklyPageDistance(pageWidth: CGFloat, dayColumnSpacing: CGFloat) -> CGFloat { + pageWidth + weeklyPageSpacing(dayColumnSpacing: dayColumnSpacing) + } + + func monthlyPageSpacing(dayColumnSpacing: CGFloat) -> CGFloat { + max(config.monthlyPaging.pageSpacing, dayColumnSpacing) + } + + func monthlyPageDate(monthOffset: Int) -> TXCalendarDate? { + guard var date = monthlyPagingBaseDate ?? currentDate?.wrappedValue else { + return nil + } + + guard monthOffset != 0 else { return date } + + if monthOffset > 0 { + for _ in 0.. [[TXCalendarDateItem]] { + guard let date = monthlyPageDate(monthOffset: monthOffset) else { + return weeks + } + + if monthOffset == 0, + monthlyPagingBaseDate == nil { + return weeks + } + + return config.monthlyPaging.pageWeeks?(date) + ?? TXCalendarDataGenerator.generateMonthData(for: date) + } + + func monthlyTargetDate(for swipe: SwipeGesture) -> TXCalendarDate? { + guard var date = monthlyPagingBaseDate ?? currentDate?.wrappedValue else { + return nil + } + + switch swipe { + case .previous: + date.goToPreviousMonth() + + case .next: + date.goToNextMonth() + } + return date + } + + var weekDateItems: [TXCalendarDateItem] { + weeks.first ?? [] + } + + var activeWeeklyReferenceDate: TXCalendarDate? { + weeklyPagingReferenceDate ?? weeklyReferenceDate + } + + func weeklyPageItems(weekOffset: Int) -> [TXCalendarDateItem] { + if let weeklyPagingReferenceDate { + guard weekOffset != 0 else { + return generatedWeeklyPageItems( + for: weeklyPagingReferenceDate, + weekOffset: 0 + ) + } + + guard let targetDate = weeklyTargetDate(for: weekOffset) else { + return weekDateItems + } + + return generatedWeeklyPageItems( + for: targetDate, + weekOffset: 0 + ) + } + + guard weekOffset != 0 else { + return weekDateItems + } + + guard let targetDate = weeklyTargetDate(for: weekOffset) else { + return weekDateItems + } + + return generatedWeeklyPageItems( + for: targetDate, + weekOffset: 0 + ) + } + + func generatedWeeklyPageItems( + for referenceDate: TXCalendarDate, + weekOffset: Int + ) -> [TXCalendarDateItem] { + TXCalendarDataGenerator.generateWeekData( + for: referenceDate, + weekOffset: weekOffset + ).first ?? [] + } + + func weeklyTargetDate(for swipe: SwipeGesture) -> TXCalendarDate? { + switch swipe { + case .previous: + return weeklyTargetDate(for: -1) + + case .next: + return weeklyTargetDate(for: 1) + } + } + + func weeklyTargetDate(for weekOffset: Int) -> TXCalendarDate? { + guard weekOffset != 0, + let referenceDate = activeWeeklyReferenceDate else { + return activeWeeklyReferenceDate + } + + return TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( + from: referenceDate, + by: weekOffset + ) + } + + var weeklyReferenceDate: TXCalendarDate? { + if let currentDate, currentDate.wrappedValue.day != nil { + return currentDate.wrappedValue + } + + let selectedItem = weekDateItems.first { item in + switch item.status { + case .selectedFilled, .selectedLine: + return item.dateComponents != nil + + case .completed, .default, .lastDate: + return false + } + } + + if let selectedItem, + let components = selectedItem.dateComponents { + return TXCalendarDate(components: components) + } + + guard let components = weekDateItems.compactMap(\.dateComponents).first else { + return nil + } + return TXCalendarDate(components: components) + } + + func weeklyHeaderTitle(index: Int, item: TXCalendarDateItem) -> String { + guard let components = item.dateComponents, + let year = components.year, + let month = components.month, + let day = components.day else { + return "" + } + let today = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: Date()) + let isToday = today.year == year && today.month == month && today.day == day + + return isToday ? "오늘" : weekdays[index] + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Paging.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Paging.swift new file mode 100644 index 00000000..cdb86497 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Paging.swift @@ -0,0 +1,361 @@ +// +// TXCalendar+Paging.swift +// SharedDesignSystem +// +// Created by Codex on 5/30/26. +// + +import SwiftUI + +// MARK: - Private Methods +extension TXCalendar { + func calendarSwipeGesture(pageWidth: CGFloat, dayColumnSpacing: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 16) + .onChanged { value in + handleSwipeChanged(value, pageWidth: pageWidth, dayColumnSpacing: dayColumnSpacing) + } + .onEnded { value in + handleSwipeEnded(value, pageWidth: pageWidth, dayColumnSpacing: dayColumnSpacing) + } + } + + func handleSwipeChanged( + _ value: DragGesture.Value, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + let horizontalDistance = value.translation.width + guard isHorizontalDrag(value.translation) else { + resetActiveDragTranslation() + return + } + + switch mode { + case .weekly: + updateWeeklyDragTranslation( + horizontalDistance, + pageDistance: weeklyPageDistance( + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + ) + + case .monthly: + updateMonthlyDragTranslation( + horizontalDistance, + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + } + } + + func handleSwipeEnded( + _ value: DragGesture.Value, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + let horizontalDistance = value.translation.width + guard isHorizontalDrag(value.translation) else { + resetActiveDragTranslation() + return + } + + let swipe: SwipeGesture = horizontalDistance > 0 ? .previous : .next + switch mode { + case .weekly: + finishWeeklySwipe( + swipe, + horizontalDistance: horizontalDistance, + pageDistance: weeklyPageDistance( + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + ) + + case .monthly: + finishMonthlySwipe( + swipe, + horizontalDistance: horizontalDistance, + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + } + } + + func isHorizontalDrag(_ translation: CGSize) -> Bool { + abs(translation.width) > abs(translation.height) + } + + func updateWeeklyDragTranslation(_ horizontalDistance: CGFloat, pageDistance: CGFloat) { + guard !isWeeklyPaging else { return } + + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = boundedWeeklyDragTranslation( + horizontalDistance, + pageDistance: pageDistance + ) + } + } + + func updateMonthlyDragTranslation( + _ horizontalDistance: CGFloat, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + guard isMonthlyVisualPagingEnabled, + !isMonthlyPaging else { + return + } + + let monthlyPageDistance = pageWidth + monthlyPageSpacing(dayColumnSpacing: dayColumnSpacing) + withTransaction(Transaction(animation: nil)) { + if monthlyPagingBaseDate == nil { + monthlyPagingBaseDate = currentDate?.wrappedValue + } + monthlyDragTranslation = boundedMonthlyDragTranslation( + horizontalDistance, + pageWidth: monthlyPageDistance + ) + } + } + + func finishWeeklySwipe( + _ swipe: SwipeGesture, + horizontalDistance: CGFloat, + pageDistance: CGFloat + ) { + handleWeeklySwipe( + swipe, + pageDistance: pageDistance, + releaseTranslation: boundedWeeklyDragTranslation( + horizontalDistance, + pageDistance: pageDistance + ) + ) + } + + func finishMonthlySwipe( + _ swipe: SwipeGesture, + horizontalDistance: CGFloat, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + guard isMonthlyVisualPagingEnabled else { + handleImmediateSwipe(swipe) + return + } + + let pageSpacing = monthlyPageSpacing(dayColumnSpacing: dayColumnSpacing) + let monthlyPageDistance = pageWidth + pageSpacing + handleMonthlySwipe( + swipe, + pageWidth: pageWidth, + pageSpacing: pageSpacing, + releaseTranslation: boundedMonthlyDragTranslation( + horizontalDistance, + pageWidth: monthlyPageDistance + ) + ) + } + + func handleWeeklySwipe( + _ swipe: SwipeGesture, + pageDistance: CGFloat, + releaseTranslation: CGFloat + ) { + guard canApplySwipe(swipe) else { + resetWeeklyPagingOffset() + return + } + + guard !isWeeklyPaging else { return } + + isWeeklyPaging = true + let targetDate = weeklyTargetDate(for: swipe) + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = 0 + weeklyPagingOffset = releaseTranslation + } + + withAnimation(Self.pagingAnimation) { + weeklyPagingOffset = pagingTargetOffset(for: swipe, pageDistance: pageDistance) + } completion: { + settleWeeklyPaging(to: targetDate) + applySwipe(swipe, animated: false) + } + } + + func handleMonthlySwipe( + _ swipe: SwipeGesture, + pageWidth: CGFloat, + pageSpacing: CGFloat, + releaseTranslation: CGFloat + ) { + guard canApplySwipe(swipe) else { + resetMonthlyPagingOffset() + return + } + + guard !isMonthlyPaging, + let baseDate = monthlyPagingBaseDate ?? currentDate?.wrappedValue, + let targetDate = monthlyTargetDate(for: swipe) else { + return + } + + isMonthlyPaging = true + withTransaction(Transaction(animation: nil)) { + monthlyPagingBaseDate = baseDate + monthlyDragTranslation = 0 + monthlyPagingOffset = releaseTranslation + } + + withAnimation(Self.pagingAnimation) { + monthlyPagingOffset = pagingTargetOffset(for: swipe, pageDistance: pageWidth + pageSpacing) + } completion: { + commitMonthlyVisualSwipe(swipe, targetDate: targetDate) + resetMonthlyPagingOffset() + } + } + + func commitMonthlyVisualSwipe(_ swipe: SwipeGesture, targetDate: TXCalendarDate) { + if let onSwipe { + onSwipe(swipe) + } else if let currentDate { + currentDate.wrappedValue = targetDate + } + } + + func handleImmediateSwipe(_ swipe: SwipeGesture) { + guard canApplySwipe(swipe) else { return } + applySwipe(swipe, animated: true) + } + + func canApplySwipe(_ swipe: SwipeGesture) -> Bool { + switch swipe { + case .previous: + return canMovePrevious + + case .next: + return canMoveNext + } + } + + func pagingTargetOffset(for swipe: SwipeGesture, pageDistance: CGFloat) -> CGFloat { + switch swipe { + case .previous: + return pageDistance + + case .next: + return -pageDistance + } + } + + func applySwipe(_ swipe: SwipeGesture, animated: Bool) { + if let onSwipe { + if animated { + withAnimation(Self.pagingAnimation) { + onSwipe(swipe) + } + } else { + onSwipe(swipe) + } + } else { + applySwipeToCurrentDate(swipe) + } + } + + func settleWeeklyPaging(to targetDate: TXCalendarDate?) { + withTransaction(Transaction(animation: nil)) { + weeklyPagingReferenceDate = targetDate + weeklyDragTranslation = 0 + weeklyPagingOffset = 0 + isWeeklyPaging = false + } + } + + func resetWeeklyPagingOffset() { + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = 0 + weeklyPagingOffset = 0 + isWeeklyPaging = false + } + } + + func clearStaleWeeklyPagingReferenceDate() { + guard let weeklyPagingReferenceDate, + let weeklyReferenceDate, + weeklyPagingReferenceDate != weeklyReferenceDate else { + return + } + + withTransaction(Transaction(animation: nil)) { + self.weeklyPagingReferenceDate = nil + } + } + + func resetMonthlyPagingOffset() { + withTransaction(Transaction(animation: nil)) { + monthlyDragTranslation = 0 + monthlyPagingOffset = 0 + monthlyPagingBaseDate = nil + isMonthlyPaging = false + } + } + + func resetActiveDragTranslation() { + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = 0 + monthlyDragTranslation = 0 + if !isMonthlyPaging { + monthlyPagingBaseDate = nil + } + } + } + + func boundedWeeklyDragTranslation(_ translation: CGFloat, pageDistance: CGFloat) -> CGFloat { + if translation > 0, !canMovePrevious { + return 0 + } + if translation < 0, !canMoveNext { + return 0 + } + return min(max(translation, -pageDistance), pageDistance) + } + + func boundedMonthlyDragTranslation(_ translation: CGFloat, pageWidth: CGFloat) -> CGFloat { + boundedWeeklyDragTranslation(translation, pageDistance: pageWidth) + } + + func applySwipeToCurrentDate(_ swipe: SwipeGesture) { + guard let currentDate else { return } + + var updatedDate = currentDate.wrappedValue + switch mode { + case .weekly: + let offset: Int + switch swipe { + case .previous: + offset = -1 + + case .next: + offset = 1 + } + guard let date = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( + from: updatedDate, + by: offset + ) else { return } + updatedDate = date + + case .monthly: + switch swipe { + case .previous: + updatedDate.goToPreviousMonth() + + case .next: + updatedDate.goToNextMonth() + } + } + + currentDate.wrappedValue = updatedDate + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift index 50ecb32e..567139ac 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift @@ -33,6 +33,28 @@ public struct TXCalendar: View { /// 캘린더 레이아웃 설정입니다. public struct Configuration { + /// 월간 캘린더 페이징 설정입니다. + public struct MonthlyPagingConfiguration { + let isEnabled: Bool + let pageSpacing: CGFloat + let minimumRowCount: Int? + let pageWeeks: ((TXCalendarDate) -> [[TXCalendarDateItem]])? + + public static let disabled = Self() + + public init( + isEnabled: Bool = false, + pageSpacing: CGFloat = 0, + minimumRowCount: Int? = nil, + pageWeeks: ((TXCalendarDate) -> [[TXCalendarDateItem]])? = nil + ) { + self.isEnabled = isEnabled + self.pageSpacing = pageSpacing + self.minimumRowCount = minimumRowCount + self.pageWeeks = pageWeeks + } + } + let weeklyHorizontalPadding: CGFloat let monthlyHorizontalPadding: CGFloat let verticalPadding: CGFloat @@ -46,6 +68,7 @@ public struct TXCalendar: View { let backgroundColor: Color let dateStyle: TXCalendarDateStyle let dateCellBackground: ((TXCalendarDateItem) -> AnyView?)? + let monthlyPaging: MonthlyPagingConfiguration /// 캘린더 레이아웃 설정을 생성합니다. public init( @@ -61,7 +84,8 @@ public struct TXCalendar: View { weekdayColor: Color = Color.Gray.gray300, backgroundColor: Color = Color.Common.white, dateStyle: TXCalendarDateStyle = .init(), - dateCellBackground: ((TXCalendarDateItem) -> AnyView?)? = nil + dateCellBackground: ((TXCalendarDateItem) -> AnyView?)? = nil, + monthlyPaging: MonthlyPagingConfiguration = .disabled ) { self.weeklyHorizontalPadding = weeklyHorizontalPadding self.monthlyHorizontalPadding = monthlyHorizontalPadding @@ -76,23 +100,32 @@ public struct TXCalendar: View { self.backgroundColor = backgroundColor self.dateStyle = dateStyle self.dateCellBackground = dateCellBackground + self.monthlyPaging = monthlyPaging } } public static let defaultWeekdays = ["일", "월", "화", "수", "목", "금", "토"] - private let mode: DisplayMode - private let weekdays: [String] - private let weeks: [[TXCalendarDateItem]] - private let currentDate: Binding? - private let canMovePrevious: Bool - private let canMoveNext: Bool - private let config: Configuration - private let onSelect: (TXCalendarDateItem) -> Void - private let onSwipe: ((SwipeGesture) -> Void)? - @GestureState private var weeklyDragTranslation: CGFloat = 0 - @State private var weeklyPagingOffset: CGFloat = 0 - @State private var isWeeklyPaging = false + let mode: DisplayMode + let weekdays: [String] + let weeks: [[TXCalendarDateItem]] + let currentDate: Binding? + let canMovePrevious: Bool + let canMoveNext: Bool + let config: Configuration + let onSelect: (TXCalendarDateItem) -> Void + let onSwipe: ((SwipeGesture) -> Void)? + // Split TXCalendar paging helpers live in same module extension files. + // swiftlint:disable private_swiftui_state + @State var weeklyDragTranslation: CGFloat = 0 + @State var weeklyPagingOffset: CGFloat = 0 + @State var weeklyPagingReferenceDate: TXCalendarDate? + @State var isWeeklyPaging = false + @State var monthlyDragTranslation: CGFloat = 0 + @State var monthlyPagingOffset: CGFloat = 0 + @State var monthlyPagingBaseDate: TXCalendarDate? + @State var isMonthlyPaging = false + // swiftlint:enable private_swiftui_state /// 캘린더 컴포넌트를 생성합니다. public init( @@ -104,7 +137,6 @@ public struct TXCalendar: View { config: Configuration = .init(), onSelect: @escaping (TXCalendarDateItem) -> Void = { _ in }, onSwipe: ((SwipeGesture) -> Void)? = nil - ) { self.mode = mode self.weeks = weeks @@ -154,9 +186,23 @@ public struct TXCalendar: View { width: proxy.size.width, spacing: spacing ) - .highPriorityGesture(calendarSwipeGesture(pageWidth: pageWidth)) + .transaction { transaction in + if isWeeklyPaging || isMonthlyPaging { + transaction.disablesAnimations = false + transaction.animation = Self.pagingAnimation + } + } + .highPriorityGesture( + calendarSwipeGesture( + pageWidth: pageWidth, + dayColumnSpacing: spacing + ) + ) } .frame(height: contentHeight) + .onChange(of: weeks) { _, _ in + clearStaleWeeklyPagingReferenceDate() + } } } @@ -172,8 +218,15 @@ private extension TXCalendar { ) case .monthly: - monthlyWeekdayRow(spacing: spacing) - monthGrid(spacing: spacing) + if isMonthlyVisualPagingEnabled { + monthlyPageContent( + width: max(0, width - (horizontalPadding * 2)), + spacing: spacing + ) + } else { + monthlyWeekdayRow(spacing: spacing) + monthGrid(weeks: weeks, spacing: spacing) + } } } .padding(.vertical, config.verticalPadding) @@ -183,7 +236,8 @@ private extension TXCalendar { } func weeklyPageContent(width: CGFloat, spacing: CGFloat) -> some View { - HStack(spacing: 0) { + let pageSpacing = weeklyPageSpacing(dayColumnSpacing: spacing) + return HStack(spacing: pageSpacing) { weeklyPage(items: weeklyPageItems(weekOffset: -1), spacing: spacing) .frame(width: width) weeklyPage(items: weeklyPageItems(weekOffset: 0), spacing: spacing) @@ -191,7 +245,7 @@ private extension TXCalendar { weeklyPage(items: weeklyPageItems(weekOffset: 1), spacing: spacing) .frame(width: width) } - .offset(x: -width + weeklyPagingOffset + weeklyDragTranslation) + .offset(x: -(width + pageSpacing) + weeklyPagingOffset + weeklyDragTranslation) .frame( width: width, height: config.weekdayHeight + headerSpacing + config.dateStyle.size + config.weeklyBottomPadding, @@ -200,6 +254,29 @@ private extension TXCalendar { .clipped() } + func monthlyPageContent(width: CGFloat, spacing: CGFloat) -> some View { + let pageSpacing = monthlyPageSpacing(dayColumnSpacing: spacing) + return HStack(spacing: pageSpacing) { + monthlyPage(weeks: monthlyPageWeeks(monthOffset: -1), spacing: spacing) + .frame(width: width, height: monthlyPageHeight, alignment: .top) + monthlyPage(weeks: monthlyPageWeeks(monthOffset: 0), spacing: spacing) + .frame(width: width, height: monthlyPageHeight, alignment: .top) + monthlyPage(weeks: monthlyPageWeeks(monthOffset: 1), spacing: spacing) + .frame(width: width, height: monthlyPageHeight, alignment: .top) + } + .offset(x: -(width + pageSpacing) + monthlyPagingOffset + monthlyDragTranslation) + .frame(width: width, height: monthlyPageHeight, alignment: .leading) + .clipped() + } + + func monthlyPage(weeks: [[TXCalendarDateItem]], spacing: CGFloat) -> some View { + VStack(spacing: headerSpacing) { + monthlyWeekdayRow(spacing: spacing) + monthGrid(weeks: weeks, spacing: spacing) + } + .frame(height: monthlyPageHeight, alignment: .top) + } + func weeklyPage(items: [TXCalendarDateItem], spacing: CGFloat) -> some View { VStack(spacing: headerSpacing) { weekdayRow(items: items, spacing: spacing) @@ -237,7 +314,7 @@ private extension TXCalendar { } } - func monthGrid(spacing: CGFloat) -> some View { + func monthGrid(weeks: [[TXCalendarDateItem]], spacing: CGFloat) -> some View { Grid( horizontalSpacing: spacing, verticalSpacing: config.monthlyRowSpacing @@ -267,218 +344,3 @@ private extension TXCalendar { .buttonStyle(.plain) } } - -// MARK: - Helpers -private extension TXCalendar { - var headerSpacing: CGFloat { - switch mode { - case .weekly: config.weeklyHeaderSpacing - case .monthly: config.monthlyHeaderSpacing - } - } - - var horizontalPadding: CGFloat { - switch mode { - case .weekly: config.weeklyHorizontalPadding - case .monthly: config.monthlyHorizontalPadding - } - } - - var contentHeight: CGFloat { - let headerSectionHeight = config.weekdayHeight + headerSpacing - let verticalPadding: CGFloat = config.verticalPadding * 2 - - switch mode { - case .weekly: return headerSectionHeight + config.dateStyle.size + config.weeklyBottomPadding + verticalPadding - case .monthly: return headerSectionHeight + monthGridHeight + verticalPadding - } - } - - var monthGridHeight: CGFloat { - guard !weeks.isEmpty else { return 0 } - - let rowCount = CGFloat(weeks.count) - let rowSpacing = config.monthlyRowSpacing * CGFloat(weeks.count - 1) - return (config.dateStyle.size * rowCount) + rowSpacing - } - - var weekDateItems: [TXCalendarDateItem] { - weeks.first ?? [] - } - - var weeklyReferenceDate: TXCalendarDate? { - if let currentDate, currentDate.wrappedValue.day != nil { - return currentDate.wrappedValue - } - - let selectedItem = weekDateItems.first { item in - switch item.status { - case .selectedFilled, .selectedLine: - return item.dateComponents != nil - case .completed, .default, .lastDate: - return false - } - } - - if let selectedItem, - let components = selectedItem.dateComponents { - return TXCalendarDate(components: components) - } - - guard let components = weekDateItems.compactMap(\.dateComponents).first else { - return nil - } - return TXCalendarDate(components: components) - } - -} - -// MARK: - Private Methods -private extension TXCalendar { - func calendarSwipeGesture(pageWidth: CGFloat) -> some Gesture { - DragGesture(minimumDistance: 16) - .updating($weeklyDragTranslation) { value, state, _ in - guard mode == .weekly else { return } - - let horizontalDistance = value.translation.width - let verticalDistance = value.translation.height - guard abs(horizontalDistance) > abs(verticalDistance) else { return } - - state = boundedWeeklyDragTranslation(horizontalDistance, pageWidth: pageWidth) - } - .onEnded { value in - let horizontalDistance = value.translation.width - let verticalDistance = value.translation.height - guard abs(horizontalDistance) > abs(verticalDistance) else { return } - - let swipe: SwipeGesture = horizontalDistance > 0 ? .previous : .next - handleSwipe(swipe, pageWidth: pageWidth) - } - } - - func handleSwipe(_ swipe: SwipeGesture, pageWidth: CGFloat) { - switch swipe { - case .previous: - guard canMovePrevious else { - resetWeeklyPagingOffset() - return - } - case .next: - guard canMoveNext else { - resetWeeklyPagingOffset() - return - } - } - - guard mode == .weekly else { - applySwipe(swipe) - return - } - guard !isWeeklyPaging else { return } - - let targetOffset: CGFloat - switch swipe { - case .previous: targetOffset = pageWidth - case .next: targetOffset = -pageWidth - } - isWeeklyPaging = true - withAnimation(.easeInOut(duration: 0.22)) { - weeklyPagingOffset = targetOffset - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { - applySwipe(swipe) - resetWeeklyPagingOffset() - } - } - - func applySwipe(_ swipe: SwipeGesture) { - if let onSwipe { - withAnimation(.easeInOut(duration: 0.2)) { - onSwipe(swipe) - } - } else { - applySwipeToCurrentDate(swipe) - } - } - - func resetWeeklyPagingOffset() { - withTransaction(Transaction(animation: nil)) { - weeklyPagingOffset = 0 - isWeeklyPaging = false - } - } - - func boundedWeeklyDragTranslation(_ translation: CGFloat, pageWidth: CGFloat) -> CGFloat { - if translation > 0, !canMovePrevious { - return 0 - } - if translation < 0, !canMoveNext { - return 0 - } - return min(max(translation, -pageWidth), pageWidth) - } - - func weeklyPageItems(weekOffset: Int) -> [TXCalendarDateItem] { - guard weekOffset != 0 else { - return weekDateItems - } - guard let referenceDate = weeklyReferenceDate else { - return weekDateItems - } - let items = TXCalendarDataGenerator.generateWeekData( - for: referenceDate, - weekOffset: weekOffset - ).first ?? [] - return items.map { item in - switch item.status { - case .selectedLine, .selectedFilled: - return TXCalendarDateItem( - id: item.id, - text: item.text, - status: .default, - dateComponents: item.dateComponents - ) - case .completed, .default, .lastDate: - return item - } - } - } - - func applySwipeToCurrentDate(_ swipe: SwipeGesture) { - guard let currentDate else { return } - - var updatedDate = currentDate.wrappedValue - switch mode { - case .weekly: - let offset: Int - switch swipe { - case .previous: offset = -1 - case .next: offset = 1 - } - guard let date = TXCalendarUtil.dateByAddingWeek(from: updatedDate, by: offset) else { return } - updatedDate = date - - case .monthly: - switch swipe { - case .previous: updatedDate.goToPreviousMonth() - case .next: updatedDate.goToNextMonth() - } - } - - currentDate.wrappedValue = updatedDate - } - - func weeklyHeaderTitle(index: Int, item: TXCalendarDateItem) -> String { - guard let components = item.dateComponents, - let year = components.year, - let month = components.month, - let day = components.day else { - return "" - } - let today = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: Date()) - let isToday = today.year == year && today.month == month && today.day == day - - return isToday ? "오늘" : weekdays[index] - } -} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift index d3f8528e..3990ba92 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift @@ -81,12 +81,14 @@ public struct TXCalendarMonthNavigation: View { HStack(spacing: config.itemSpacing) { navigationButton( icon: .Icon.Symbol.arrow1MLeft, + accessibilityIdentifier: "tx.calendar.month-navigation.previous-button", isDisabled: isPreviousDisabled, action: onPrevious ) titleView navigationButton( icon: .Icon.Symbol.arrow1MRight, + accessibilityIdentifier: "tx.calendar.month-navigation.next-button", isDisabled: isNextDisabled, action: onNext ) @@ -107,6 +109,7 @@ private extension TXCalendarMonthNavigation { titleLabel } .buttonStyle(.plain) + .accessibilityIdentifier("tx.calendar.month-navigation.title-button") } else { titleLabel } @@ -123,6 +126,7 @@ private extension TXCalendarMonthNavigation { private extension TXCalendarMonthNavigation { func navigationButton( icon: Image, + accessibilityIdentifier: String, isDisabled: Bool, action: @escaping () -> Void ) -> some View { @@ -136,5 +140,6 @@ private extension TXCalendarMonthNavigation { .buttonStyle(.plain) .disabled(isDisabled) .frame(width: config.buttonSize, height: config.buttonSize) + .accessibilityIdentifier(accessibilityIdentifier) } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift index d64fc272..7239a04b 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift @@ -65,4 +65,48 @@ public enum TXCalendarUtil { } return TXCalendarDate(year: year, month: month, day: day) } + + /// 주간 캘린더 스와이프 시 경계 요일을 보정한 날짜를 반환합니다. + /// + /// ## 사용 예시 + /// ```swift + /// let sunday = TXCalendarDate(year: 2026, month: 2, day: 8) + /// let previous = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe(from: sunday, by: -1) + /// ``` + /// + /// 일요일에서 이전으로 이동하면 전날인 토요일로, 토요일에서 다음으로 이동하면 다음 날인 일요일로 이동합니다. + /// 그 외 날짜는 기존 주 단위 이동과 동일하게 처리합니다. + public static func dateByApplyingWeeklyBoundarySwipe( + from date: TXCalendarDate, + by offset: Int + ) -> TXCalendarDate? { + guard let baseDate = date.date else { return nil } + + let calendar = Calendar(identifier: .gregorian) + let weekday = calendar.component(.weekday, from: baseDate) + let dayOffset: Int + + switch (weekday, offset) { + case (1, let offset) where offset < 0: + dayOffset = -1 + + case (7, let offset) where offset > 0: + dayOffset = 1 + + default: + return dateByAddingWeek(from: date, by: offset) + } + + guard let targetDate = calendar.date(byAdding: .day, value: dayOffset, to: baseDate) else { + return nil + } + + let components = calendar.dateComponents([.year, .month, .day], from: targetDate) + guard let year = components.year, + let month = components.month, + let day = components.day else { + return nil + } + return TXCalendarDate(year: year, month: month, day: day) + } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift index 524cb235..ae498178 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift @@ -169,6 +169,7 @@ private extension GoalCardView { size: .s, state: isButtonDisabled ? .disabled : .standard ), + allowsActionWhenDisabled: true, onTap: { buttonAction?() } ) .padding(.bottom, 14) @@ -179,7 +180,7 @@ private extension GoalCardView { .padding(.bottom, 10) } } - .frame(maxHeight: .infinity) + .frame(maxHeight: .infinity, alignment: .center) } func emojiImage(emoji: Image) -> some View { diff --git a/Projects/Shared/DesignSystem/Sources/Components/Tab/TopBar/TXTopTabBar.swift b/Projects/Shared/DesignSystem/Sources/Components/Tab/TopBar/TXTopTabBar.swift index 7eddf617..077a57dd 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Tab/TopBar/TXTopTabBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Tab/TopBar/TXTopTabBar.swift @@ -43,7 +43,6 @@ struct TXTopTabBar: View { } label: { tabItem(item: item, isSelected: selectedItem == item) } - .buttonStyle(.plain) .frame(maxWidth: .infinity) } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift b/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift index c0f633e3..ab83ae44 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift @@ -12,7 +12,6 @@ public struct TXCommentCircle: View { @Binding private var commentText: String @FocusState private var isFocused: Bool private let isEditable: Bool - private let keyboardInset: CGFloat private let externalFocus: Binding? public var onFocused: ((Bool) -> Void)? @@ -28,13 +27,11 @@ public struct TXCommentCircle: View { public init( commentText: Binding, isEditable: Bool, - keyboardInset: CGFloat, isFocused: Binding? = nil, onFocused: ((Bool) -> Void)? = nil ) { self._commentText = commentText self.isEditable = isEditable - self.keyboardInset = keyboardInset self.externalFocus = isFocused self.onFocused = onFocused } @@ -55,12 +52,6 @@ public struct TXCommentCircle: View { commentText = String(commentText.prefix(Constants.maxCount)) } } - .safeAreaInset(edge: .bottom) { - if isFocused { - Color.clear - .frame(height: keyboardInset) - } - } .onChange(of: isFocused) { onFocused?(isFocused) externalFocus?.wrappedValue = isFocused @@ -189,7 +180,6 @@ private struct PositionedCircleShape: Shape { @Previewable @State var text: String = "" TXCommentCircle( commentText: $text, - isEditable: true, - keyboardInset: .zero + isEditable: true ) } diff --git a/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift b/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift new file mode 100644 index 00000000..c36955e7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift @@ -0,0 +1,94 @@ +// +// DataRetryView.swift +// SharedDesignSystem +// +// Created by 정지훈 on 6/5/26. +// + +import SwiftUI + +/// 데이터 로드 실패 상태에서 재시도 안내를 표시하는 View입니다. +/// +/// ## 사용 예시 +/// ```swift +/// DataRetryView { +/// store.send(.view(.dataRetryTapped)) +/// } +/// ``` +public struct DataRetryView: View { + var onTap: () -> Void + + /// `DataRetryView`를 생성합니다. + /// + /// ## 사용 예시 + /// ```swift + /// DataRetryView { + /// store.send(.view(.dataRetryTapped)) + /// } + /// ``` + /// + /// - Parameter onTap: 재시도 버튼을 탭했을 때 실행할 동작입니다. + public init(onTap: @escaping () -> Void) { + self.onTap = onTap + } + + public var body: some View { + GeometryReader { proxy in + VStack(spacing: 0) { + Image.Illustration.trash + + Text(Constants.title) + .typography(.t2_16b) + .padding(.top, Spacing.spacing5) + + Text(Constants.subTitle) + .typography(.c1_12r) + .foregroundStyle(Color.Gray.gray300) + .padding(.top, Spacing.spacing3) + + TXButton( + shape: .rect( + style: .round(text: Constants.buttonTitle), + size: .s, + state: .standard + ), + onTap: onTap + ) + .padding(.top, Spacing.spacing8) + } + .frame(width: Constants.frameWidth) + .position( + x: proxy.size.width / 2, + y: proxy.deviceCenterYInView + ) + .frame(width: proxy.size.width, height: proxy.size.height) + .background(Color.Common.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private extension GeometryProxy { + var deviceCenterYInView: CGFloat { + let frame = frame(in: .global) + let deviceCenterY = UIScreen.main.bounds.height / 2 + + return min( + max(0, deviceCenterY - frame.minY), + size.height + ) + } +} + +private extension DataRetryView { + enum Constants { + static let title: String = "데이터를 불러오지 못했어요" + static let subTitle: String = "잠시 후 다시 시도해 주세요" + static let buttonTitle: String = "재시도" + static let frameWidth: CGFloat = 212 + } +} + +#Preview { + DataRetryView(onTap: { }) +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/View+BorderInOutSide.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/View+BorderInOutSide.swift index de1e721e..640c2e64 100644 --- a/Projects/Shared/DesignSystem/Sources/Modifiers/View+BorderInOutSide.swift +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/View+BorderInOutSide.swift @@ -45,11 +45,10 @@ extension View { lineWidth: CGFloat? ) -> some View { if let lineWidth { - overlay( + background { shape .stroke(content, lineWidth: lineWidth * 2) - .overlay(self) - ) + } } else { self } diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift index 5d91cb73..59df02de 100644 --- a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift @@ -50,6 +50,8 @@ private struct TXBottomSheetModifier: ViewModifier { @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height @State private var dimmedOpacity: CGFloat = 0 @State private var contentHeight: CGFloat = 0 + @State private var isDismissing = false + @State private var dismissalGeneration = 0 private let animationDuration: TimeInterval = 0.2 private let dragAreaHeight: CGFloat = 28 @@ -57,7 +59,13 @@ private struct TXBottomSheetModifier: ViewModifier { content .onChange(of: isPresented) { if isPresented { - isCoverPresented = true + dismissalGeneration += 1 + isDismissing = false + if isCoverPresented { + presentAnimated() + } else { + isCoverPresented = true + } } else { startDismiss() } @@ -66,22 +74,31 @@ private struct TXBottomSheetModifier: ViewModifier { isPresented: $isCoverPresented, onDismiss: { resetSheetState() + }, + content: { + ZStack(alignment: .bottom) { + sheetView + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea(.container, edges: .bottom) + .onAppear { presentAnimated() } + .presentationBackground { dimmedBackground } } - ) { - ZStack(alignment: .bottom) { - sheetView + ) + .transaction { transaction in + if shouldDisableCoverTransactionAnimation { + transaction.disablesAnimations = true } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .ignoresSafeArea(.container, edges: .bottom) - .onAppear { presentAnimated() } - .presentationBackground { dimmedBackground } } - .transaction { $0.disablesAnimations = true } } } // MARK: - SubViews { private extension TXBottomSheetModifier { + var shouldDisableCoverTransactionAnimation: Bool { + isDismissing || isCoverPresented != isPresented + } + var sheetView: some View { VStack(spacing: 0) { dragContainer @@ -90,6 +107,11 @@ private extension TXBottomSheetModifier { .padding(.bottom, TXSafeArea.inset(.bottom)) .frame(maxWidth: .infinity, alignment: .bottom) .background(Color.Common.white) + .overlay { + Color.clear + .accessibilityIdentifier("tx.bottom-sheet.content") + .allowsHitTesting(false) + } .clipShape( UnevenRoundedRectangle(cornerRadii: .init(topLeading: Radius.m, topTrailing: Radius.m)) ) @@ -121,6 +143,7 @@ private extension TXBottomSheetModifier { .padding(.vertical, 11) } } + .accessibilityIdentifier("tx.bottom-sheet.drag-area") .gesture( DragGesture(coordinateSpace: .global) .onChanged { value in @@ -147,6 +170,7 @@ private extension TXBottomSheetModifier { .ignoresSafeArea() .contentShape(Rectangle()) .onTapGesture { startDismiss() } + .accessibilityIdentifier("tx.bottom-sheet.backdrop") } } @@ -156,10 +180,17 @@ private extension TXBottomSheetModifier { sheetOffset = UIScreen.main.bounds.height dimmedOpacity = 0 isPresented = false + isDismissing = false + dismissalGeneration += 1 } func presentAnimated() { + let currentGeneration = dismissalGeneration + Task { @MainActor in + await Task.yield() + guard currentGeneration == dismissalGeneration, isPresented else { return } + withAnimation(.easeOut(duration: animationDuration)) { dimmedOpacity = 1 sheetOffset = 0 @@ -168,6 +199,11 @@ private extension TXBottomSheetModifier { } func startDismiss() { + guard !isDismissing else { return } + isDismissing = true + dismissalGeneration += 1 + let currentDismissalGeneration = dismissalGeneration + if isPresented { isPresented = false } @@ -179,12 +215,14 @@ private extension TXBottomSheetModifier { Task { @MainActor in try await Task.sleep(for: .seconds(animationDuration)) + guard currentDismissalGeneration == dismissalGeneration else { return } isCoverPresented = false + isDismissing = false } } func updateContentHeight(_ newHeight: CGFloat) { - guard newHeight > 0 else { return } + guard newHeight > 0, abs(contentHeight - newHeight) > 0.5 else { return } contentHeight = newHeight } } diff --git a/Projects/Shared/PerfTestingSupport/Project.swift b/Projects/Shared/PerfTestingSupport/Project.swift new file mode 100644 index 00000000..123fcc19 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Project.swift @@ -0,0 +1,19 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.makeModule( + name: Module.Shared.name + Module.Shared.perfTestingSupport.rawValue, + targets: [ + .shared( + implements: .perfTestingSupport, + config: .init( + dependencies: [ + .external(dependency: .ComposableArchitecture) + ] + ) + ), + .sharedPerfTestingSupportUITests( + config: .init() + ) + ] +) diff --git a/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift b/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift new file mode 100644 index 00000000..1190775f --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift @@ -0,0 +1,79 @@ +import Foundation +import SwiftUI + +/// Pass 3 direct instrumentation에서 사용하는 process-wide probe 전용 counter입니다. +/// `UITestMode.isEnabled`일 때만 값이 바뀌므로 production build에서는 boolean check 비용만 냅니다. +/// +/// 이 값은 UITest driver / harness sanity signal이며, authoritative SwiftUI rendering metric이 아닙니다. +/// 최종 렌더링 판단은 Xcode Instruments / xctrace trace를 기준으로 합니다. +public enum PerfCounters { +#if PERF_TESTING + private static let lock = NSLock() + private static var values: [String: Int] = [:] +#endif + + /// 이름이 같은 counter를 1 증가시킵니다. + /// Production build 또는 UITest가 아닌 실행에서는 no-op입니다. + /// + /// ## 사용 예시 + /// ```swift + /// PerfCounters.increment("home.view.rebuild.proxy") + /// ``` + public static func increment(_ key: String) { +#if PERF_TESTING + guard UITestMode.isEnabled else { return } + lock.lock() + values[key, default: 0] += 1 + lock.unlock() +#else + _ = key +#endif + } + + /// counter의 현재 값을 읽습니다. + /// Production build 또는 UITest가 아닌 실행에서는 항상 0을 반환합니다. + /// + /// ## 사용 예시 + /// ```swift + /// let count = PerfCounters.value(for: "home.view.rebuild.proxy") + /// ``` + public static func value(for key: String) -> Int { +#if PERF_TESTING + guard UITestMode.isEnabled else { return 0 } + lock.lock() + defer { lock.unlock() } + return values[key, default: 0] +#else + _ = key + return 0 +#endif + } +} + +/// SwiftUI view rebuild 빈도를 거칠게 보기 위한 proxy counter view입니다. +/// parent가 child node를 다시 만들 때 View struct가 재생성될 수 있으므로 +/// `init`에서 counter를 증가시킵니다. +/// +/// 이 값은 정확한 SwiftUI body evaluation count가 아닙니다. 보고서에서는 +/// coarse invalidation-frequency signal로만 다루고, authoritative 분석에는 +/// Xcode Instruments / xctrace trace를 사용합니다. +public struct PerfRebuildProxyPing: View { + /// 추적할 counter key로 proxy ping view를 생성합니다. + /// + /// ## 사용 예시 + /// ```swift + /// PerfRebuildProxyPing("home.view.rebuild.proxy") + /// ``` + public init(_ key: String) { +#if PERF_TESTING + PerfCounters.increment(key) +#else + _ = key +#endif + } + + /// 화면에는 보이지 않는 0 크기 view입니다. + public var body: some View { + Color.clear.frame(width: 0, height: 0) + } +} diff --git a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift new file mode 100644 index 00000000..f8b178ee --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift @@ -0,0 +1,106 @@ +import ComposableArchitecture +import SwiftUI + +/// Example 앱과 perf UITest가 공유하는 launch argument 계약입니다. +public enum UITestMode { + private static let arguments = ProcessInfo.processInfo.arguments + + /// `-UITEST` launch argument가 있는지 나타냅니다. + /// `PerfCounters`의 fast-path에서 매번 `ProcessInfo.arguments`를 스캔하지 않도록 캐시합니다. + public static let isEnabled: Bool = arguments.contains("-UITEST") + + /// probe scenario(driver / marker / counter sanity test)로 실행됐는지 나타냅니다. + /// `HomeView`의 PERF action harness와 probe marker/counter를 켭니다. + /// 실제 렌더링 측정에는 사용하지 않습니다. + public static let isProbeScenario: Bool = arguments.contains("-UITEST_PROBE_SCENARIO") + + /// Xcode Instruments / xctrace로 기록하는 authoritative rendering scenario인지 나타냅니다. + /// PERF probe harness를 끄고 production layout / scroll geometry를 유지합니다. + public static let isRenderingScenario: Bool = arguments.contains("-UITEST_RENDERING_SCENARIO") + + /// `-UITEST_SEED` 뒤에 전달된 fixture seed 이름입니다. + /// 값이 없으면 `"default"`를 반환합니다. + public static var seedName: String { + value(after: "-UITEST_SEED") ?? "default" + } + + /// `-UITEST_DISABLE_ANIMATIONS` launch argument가 있는지 나타냅니다. + /// UITest나 perf harness에서 애니메이션 noise를 줄이는 데 사용합니다. + public static var disablesAnimations: Bool { + arguments.contains("-UITEST_DISABLE_ANIMATIONS") + } + + /// `-UITEST_WAIT_READY` launch argument가 있는지 나타냅니다. + /// Example host가 ready marker를 노출해야 하는 scenario를 구분합니다. + public static var waitsForReady: Bool { + arguments.contains("-UITEST_WAIT_READY") + } + + /// SwiftUI Template launch-mode에서 typing interaction을 self-run으로 재현할지 나타냅니다. + /// attach-mode가 이 환경에서 0 rows를 내는 경우 interactive SwiftUI row attribution을 얻기 위한 + /// Example/perf 전용 flag입니다. + public static var isSwiftUISelfRunTyping: Bool { + arguments.contains("-UITEST_SWIFTUI_SELF_RUN_TYPING") + } + + /// Home feed self-running scroll을 켤지 나타냅니다. + /// Home Example host에서 `ScrollViewReader`와 `proxy.scrollTo(...)`를 사용해 + /// SwiftUI Template launch-mode가 scroll attribution을 캡처하도록 돕는 Example/perf 전용 flag입니다. + public static var isSwiftUISelfRunFeedScroll: Bool { + arguments.contains("-UITEST_SWIFTUI_SELF_RUN_FEED_SCROLL") + } + + /// Stats feed self-running scroll을 켤지 나타냅니다. + /// `stats-heavy` seed의 `StatsCardView` 리스트에서 scroll attribution을 캡처하기 위한 + /// Example/perf 전용 flag입니다. + public static var isSwiftUISelfRunStatsScroll: Bool { + arguments.contains("-UITEST_SWIFTUI_SELF_RUN_STATS_SCROLL") + } + + /// MainTab Calendar bottomsheet self-running presentation을 켤지 나타냅니다. + /// xctrace launch-mode가 Calendar bottomsheet presentation window를 직접 캡처하도록 돕는 + /// Example/perf 전용 flag입니다. + public static var isSwiftUISelfRunCalendarBottomSheet: Bool { + arguments.contains("-UITEST_SWIFTUI_SELF_RUN_CALENDAR_BOTTOM_SHEET") + } + + /// 현재 launch argument에 따라 앱 전역 UITest 설정을 적용합니다. + /// 지금은 `-UITEST_DISABLE_ANIMATIONS`가 있을 때 UIKit animation을 비활성화합니다. + /// + /// ## 사용 예시 + /// ```swift + /// UITestMode.configureApplication() + /// ``` + public static func configureApplication() { + guard isEnabled, disablesAnimations else { return } + UIView.setAnimationsEnabled(false) + } + + /// UITest일 때만 TCA dependency override를 적용하는 wrapper를 반환합니다. + /// Production launch에서는 전달된 update closure를 실행하지 않습니다. + /// + /// ## 사용 예시 + /// ```swift + /// let update = UITestMode.dependencyValues { _ in + /// // UITest launch에서만 dependency override 적용 + /// } + /// withDependencies(update) { + /// // UITest launch에서만 override 적용 + /// } + /// ``` + public static func dependencyValues( + _ update: @escaping (inout DependencyValues) -> Void + ) -> (inout DependencyValues) -> Void { + { values in + guard isEnabled else { return } + update(&values) + } + } + + private static func value(after key: String) -> String? { + guard let index = arguments.firstIndex(of: key) else { return nil } + let valueIndex = arguments.index(after: index) + guard arguments.indices.contains(valueIndex) else { return nil } + return arguments[valueIndex] + } +} diff --git a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift new file mode 100644 index 00000000..dd4d1601 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift @@ -0,0 +1,145 @@ +import SwiftUI + +public extension View { + /// feature root-level accessibility marker를 노출합니다. + /// + /// Parent SwiftUI view에 직접 `accessibilityIdentifier`를 붙이면 + /// child identifier를 덮을 수 있으므로, + /// 1x1 `Color.clear` overlay에만 marker를 붙입니다. + /// + /// ## 사용 예시 + /// ```swift + /// Text("Home") + /// .perfRoot("home") + /// ``` + func perfRoot(_ slug: String) -> some View { +#if PERF_TESTING + overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("feature.\(slug).root") + } +#else + self +#endif + } + + /// feature feed container에 deterministic accessibility identifier를 부여합니다. + /// PERF_TESTING build가 아니면 원본 view를 그대로 반환합니다. + /// + /// ## 사용 예시 + /// ```swift + /// ScrollView { + /// Text("Cell") + /// } + /// .perfFeed("home") + /// ``` + func perfFeed(_ slug: String) -> some View { +#if PERF_TESTING + accessibilityIdentifier("feature.\(slug).feed") +#else + self +#endif + } + + /// feature feed cell에 stable id 기반 accessibility identifier를 부여합니다. + /// UITest driver가 특정 cell을 찾거나 scroll target을 잡을 때 사용합니다. + /// + /// ## 사용 예시 + /// ```swift + /// Text("Cell") + /// .perfCell(slug: "home", stableId: "goal-1") + /// ``` + func perfCell(slug: String, stableId: CustomStringConvertible) -> some View { +#if PERF_TESTING + accessibilityIdentifier("feature.\(slug).cell.\(stableId)") +#else + self +#endif + } + + /// feature control에 accessibility identifier를 부여합니다. + /// Button, calendar 등 interaction target을 UITest에서 안정적으로 찾기 위한 helper입니다. + /// + /// ## 사용 예시 + /// ```swift + /// Button("Next") { } + /// .perfControl(slug: "home", element: "calendar-next") + /// ``` + func perfControl(slug: String, element: String) -> some View { +#if PERF_TESTING + accessibilityIdentifier("feature.\(slug).\(element)") +#else + self +#endif + } + + /// feature가 perf scenario 준비를 마쳤음을 나타내는 ready marker를 노출합니다. + /// UITest는 이 marker가 나타날 때까지 기다린 뒤 action을 시작합니다. + /// + /// ## 사용 예시 + /// ```swift + /// Text("Ready") + /// .perfReadyMarker("home") + /// ``` + func perfReadyMarker(_ slug: String) -> some View { +#if PERF_TESTING + overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("feature.\(slug).ready") + } +#else + self +#endif + } + + /// `value` 변경에 따라 identifier가 바뀌는 deterministic accessibility marker를 노출합니다. + /// UITest는 특정 값의 marker를 기다려 SwiftUI가 state mutation을 반영했는지 + /// 확인할 수 있습니다. + /// + /// ## 사용 예시 + /// ```swift + /// Text("Toast") + /// .perfStateMarker(slug: "home", key: "toast", value: "visible") + /// ``` + func perfStateMarker(slug: String, key: String, value: String) -> some View { +#if PERF_TESTING + overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("feature.\(slug).marker.\(key).\(value)") + } +#else + self +#endif + } + + /// `PerfCounters` key마다 accessibility marker를 하나씩 노출합니다. + /// 각 marker identifier에는 현재 counter 값이 포함됩니다. + /// + /// Probe 전용 sanity signal이며, authoritative SwiftUI rendering metric으로 인용하지 않습니다. + /// + /// ## 사용 예시 + /// ```swift + /// Text("Counters") + /// .perfCounterMarkers(slug: "home", keys: ["home.view.rebuild.proxy"]) + /// ``` + func perfCounterMarkers(slug: String, keys: [String]) -> some View { +#if PERF_TESTING + overlay(alignment: .topLeading) { + VStack(spacing: 0) { + ForEach(keys, id: \.self) { key in + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier( + "feature.\(slug).counter.\(key).\(PerfCounters.value(for: key))" + ) + } + } + } +#else + self +#endif + } +} diff --git a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift new file mode 100644 index 00000000..e508ebe0 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift @@ -0,0 +1,123 @@ +import XCTest + +/// feature ready marker가 나타날 때까지 기다립니다. +/// Example host가 `perfReadyMarker(_:)`를 노출한 뒤 action을 시작하도록 맞추는 helper입니다. +/// +/// ## 사용 예시 +/// ```swift +/// waitForFeatureReady("home") +/// ``` +public func waitForFeatureReady( + _ slug: String, + timeout: TimeInterval = 10, + file: StaticString = #filePath, + line: UInt = #line +) { + let app = XCUIApplication() + let ready = app.descendants(matching: .any)["feature.\(slug).ready"] + XCTAssertTrue( + ready.waitForExistence(timeout: timeout), + "Timed out waiting for feature.\(slug).ready", + file: file, + line: line + ) +} + +/// `perfStateMarker(slug:key:value:)`가 특정 값으로 나타날 때까지 기다립니다. +/// 값마다 accessibility identifier가 달라지므로 SwiftUI가 state mutation을 +/// 반영했는지 확인할 수 있습니다. +/// +/// ## 사용 예시 +/// ```swift +/// awaitPerfMarker(slug: "home", key: "toast", value: "visible") +/// ``` +public func awaitPerfMarker( + slug: String, + key: String, + value: String, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line +) { + let app = XCUIApplication() + let identifier = "feature.\(slug).marker.\(key).\(value)" + let marker = app.descendants(matching: .any)[identifier] + XCTAssertTrue( + marker.waitForExistence(timeout: timeout), + "Timed out waiting for marker \(identifier)", + file: file, + line: line + ) +} + +/// accessibility marker를 통해 `PerfCounters` counter 최신 값을 읽습니다. +/// marker가 없으면 `-1`을 반환합니다. 최신 값을 보장하려면 읽기 전에 +/// state-change marker로 body re-render를 유도해야 합니다. +/// +/// ## 사용 예시 +/// ```swift +/// let count = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") +/// ``` +public func readPerfCounter(slug: String, key: String) -> Int { + let app = XCUIApplication() + let prefix = "feature.\(slug).counter.\(key)." + let query = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", prefix) + ) + for index in 0.. Void + ) { + measure(metrics: metrics) { + for _ in 0.. XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append("-UITEST") + app.launchArguments.append(contentsOf: ["-UITEST_SEED", seed]) + app.launchArguments.append("-UITEST_WAIT_READY") + + if disableAnimations { + app.launchArguments.append("-UITEST_DISABLE_ANIMATIONS") + } + + if let scenario { + app.launchArguments.append(scenario.rawValue) + } + + app.launch() + return app + } +} diff --git a/Prompt.md b/Prompt.md deleted file mode 100644 index 8918c138..00000000 --- a/Prompt.md +++ /dev/null @@ -1,216 +0,0 @@ -You are an iOS architecture engineer and build-system operator. - -Environment: -- Platform: iOS only -- Minimum target: iOS 17 -- UI: SwiftUI -- State: TCA 1.23 -- Architecture: Clean Architecture + Micro Feature Architecture (MFA) -- Build tool: xcodebuild -- Project docs are authoritative - -Rules: -- Do NOT invent architecture rules -- Do NOT modify documents unless feasibility is verified -- Prefer minimal diffs over rewrites -- All outputs must be deterministic and reproducible -- Ask for missing files instead of assuming - -Language Policy: -- Think and reason in English -- All sections in OUTPUT FORMAT must be written in Korean -- Code, file names, symbols, and commands must remain in English - -Output format is mandatory. No extra commentary. - ---- - -TASK: Architecture Change Validator & Document Editor [MODE 1] - -You are given: -1) Architecture documents (Markdown) -2) My requested change - -Process: -PHASE 1 — FeASIBILITY CHECK -- Validate if this change is technically valid under: - - SwiftUI + TCA 1.23 - - Clean Architecture - - Micro Feature Architecture - - iOS 17 runtime + toolchain -- Identify violations, contradictions, or untestable constraints - -PHASE 2 — DECISION -- If invalid: - - Output only: REJECTED + technical reasons + minimum viable alternative -- If valid: - - Proceed to PHASE 3 - -PHASE 3 — DOCUMENT PATCH -- Apply minimal diffs to the documents -- Preserve structure, tone, and conventions -- Do not reword unrelated sections - -INPUTS: ---- -[ARCHITECTURE DOCS] -./Claude.md ---- -[REQUESTED CHANGE] -./RequestedChange.md - ---- -OUTPUT FORMAT (STRICT): -STATUS: ACCEPTED | REJECTED - -FEASIBILITY_REPORT: -- Constraint Check: -- TCA Compatibility: -- MFA Impact: -- Build/Test Impact: - -DOCUMENT_PATCH: -```diff -(unified diff here) - - ---- -TASK: Architecture Compliance Auditor [MODE 2] - -You are given: -- Architecture rules (Markdown) -- Swift source code - -Process: -PHASE 1 — RULE EXTRACTION -- Derive enforceable rules from docs: - - Module boundaries - - Dependency direction - - TCA patterns (Reducer, State, Action, Environment, Scope) - - MFA constraints (Interface vs Sources vs Domain) - -PHASE 2 — CODE AUDIT -- Map each file to: - - Layer - - Feature - - Dependency direction -- Flag violations with: - - Rule reference - - File - - Line range - - Impact - -PHASE 3 — REFACTOR -- Fix violations with minimal architectural disturbance -- Keep public interfaces stable unless explicitly forbidden - -INPUTS: ---- -[ARCHITECTURE_DOCS] -./Claude.md ---- -[SOURCE_CODE] -./ ---- - -OUTPUT FORMAT (STRICT): -RULES_DERIVED: -- R1: -- R2: - -VIOLATIONS: -- ID: - Rule: - File: - Lines: - Problem: - Severity: - -PATCH: -```diff -(unified diff) - -POST_REFACTOR_CHECK: -- Build Safety: - TCA Integrity: - Dependency Direction: - ---- - -TASK: Feature Implementation Pipeline [MODE 3] - -You are given: -- Architecture docs -- Feature requirements -- Project structure - -Process: -PHASE 1 — ARCHITECTURE FIT -- Identify: - - Feature module - - Domain contracts - - Reducer scope - - Dependency injection path - -PHASE 2 — DESIGN -- Define: - - State - - Action - - Reducer - - UseCase - - Interfaces -- Ensure MFA boundaries are preserved - -PHASE 3 — IMPLEMENTATION -- Generate Swift code -- Respect: - - TCA 1.23 APIs - - iOS 17 SDK - - Module visibility rules - -PHASE 4 — BUILD PLAN -- Produce xcodebuild command: - - scheme - - destination - - configuration -- Predict failure points - -INPUTS: ---- -[ARCHITECTURE_DOCS] -./Claude.md ---- -[FEATURE_REQUEST] -./RequestedChange.md ---- -[PROJECT_TREE] ---- - -OUTPUT FORMAT (STRICT): -ARCHITECTURE_MAP: -- Feature: -- Domain: -- Interfaces: -- Dependencies: - -DESIGN: -- State: -- Action: -- Reducer: -- UseCase: - -CODE: -```swift -(files separated by // MARK: FileName) - -BUILD: - xcodebuild \ - -scheme \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ - -configuration Debug \ - build - -RISK_REPORT: -- Compile Risk: - Dependency Risk: - TCA Misuse Risk: \ No newline at end of file diff --git a/Rules.md b/Rules.md deleted file mode 100644 index 08c1b944..00000000 --- a/Rules.md +++ /dev/null @@ -1,185 +0,0 @@ -Rules - -## 목적 -팀 아키텍처 규칙과 결정사항을 간단하고 실행 가능한 형태로 정리합니다. - -## DocC 문서화 기준 -대상: Core / Domain / Feature / Shared 모듈의 Interface 계층 - -**문서화 대상** -- Interface의 public 타입(struct, enum, class 등)에 대한 간단한 설명 (필수) -- Shared의 경우 Interface가 없으므로 public 타입에 한해서 문서화 (필수) -- public 함수는 사용 예시 코드까지 작성 (필수) - -**문서화 제외** -- enum case, 변수/프로퍼티: 문서화 주석 작성 안 함 -- App 계층: internal 타입이므로 문서화 불필요 -- Implementation 계층: public이 아닌 한 문서화 불필요 - -**엄격 적용** -- 문서화 제외 항목은 예외 없이 문서화를 금지합니다. -- public API(타입/함수) 문서화 누락은 규칙 위반입니다. - -예시 -```swift -/// 앱 전체에서 사용하는 네트워크 요청 프로토콜입니다. -/// -/// ## 사용 예시 -/// ```swift -/// let provider: NetworkProviderProtocol = NetworkProvider() -/// let user: User = try await provider.request(endpoint: UserEndpoint.profile) -/// ``` -public protocol NetworkProviderProtocol { - /// 공통 엔드포인트를 통해 서버에 데이터를 요청합니다. - func request(endpoint: Endpoint) async throws -> T -} -``` - -## Feature 모듈 구조 -모든 Feature는 Interface / Implementation 분리 구조를 유지합니다. - -```text -Feature - ├── FeatureOnboarding - ├── FeatureProfile - ├── FeatureCrew - └── Sources (Feature Root) -``` - -### 예외 Feature (App 직접 Path 관리) -- Auth / Onboarding / MainTab은 App에서 직접 Path를 관리하는 중간 관리자 Feature로 취급합니다. -- 위 Feature는 Interface/Implementation 분리 및 ViewFactory 강제 규칙에서 예외입니다. -- App은 위 Feature를 `makeView(_:)` 없이 직접 조립할 수 있습니다. -- 위 Feature는 내부 하위 Feature 조립 시 Implementation 모듈을 직접 import 할 수 있습니다. -- 위 Feature는 자식 Feature를 Interface-only `makeView(_:)` 대신 직접 생성할 수 있습니다. -- 그 외 Feature는 Interface 모듈만 import하며 `makeView(_:)` 또는 동등한 factory로만 조립합니다. - -## Navigation 규칙 -프로젝트 전체에서 **통일된 Navigation 패턴**을 사용합니다. - -### 사용 패턴: Route enum + `[Route]` 배열 -- `enum Route: Hashable` + `[Route]` 배열 사용 -- Child State는 Optional로 관리, `.ifLet`으로 Reducer 연결 -- 예시: `state.routes.append(.codeInput)` - -### 사용하지 않는 패턴: TCA 공식 StackState -- `StackState` + `@Reducer enum Path` 사용 안 함 -- 이유: `@Reducer enum Path` 매크로가 Interface/Implementation 분리 구조에서 동작하지 않음 -- 예외 Feature도 코드 일관성을 위해 동일한 패턴 사용 - -상세 가이드는 `docs/Guides/NavigationStack.md` 참고 - -## Reducer 생성 규칙 -- Interface에는 Reducer의 시그니처만 둡니다. (body는 외부 Reduce 주입) -- Implementation에서 실제 Reduce를 구성하는 init을 제공합니다. -- 다른 Feature에서 Reducer를 사용할 때는 Interface 타입만 의존합니다. - -Interface 예시 -```swift -@Reducer -public struct CounterReducer { - let reducer: Reduce - public init(reducer: Reduce) { self.reducer = reducer } - public var body: some ReducerOf { reducer } -} -``` - -Implementation 예시 -```swift -extension CounterReducer { - public init() { - self.init(reducer: Reduce { state, action in - // 실제 로직 - return .none - }) - } -} -``` - -## Feature Root에서의 조립 -Feature Root(Sources)에서 각 Feature의 구현체를 조립합니다. - -- Root가 구현 모듈을 직접 의존하고 Reducer/View를 주입합니다. -- 외부 모듈은 Interface에만 의존합니다. -- Feature Root에서 타입 재노출이 필요할 경우 **Interface 타입만 재노출**합니다. - -## ViewFactory 도입 기준 -기본 규칙: 모든 Feature에 강제하지 않습니다. - -1) Flow 단위 Feature -- Flow 내부에서만 쓰이고 외부 재사용이 없다면 Root에서 직접 조립 -- ViewFactory 생략 가능 - -2) 하위 기능 단위 Feature -- 다른 화면에서 재사용 가능성이 있으면 ViewFactory 도입 -- Interface에 Factory 정의, Sources에서 liveValue 제공 - -## 의존성 주입 규칙 (필수) -Struct + closure + TCA Dependency 스타일을 기본으로 사용합니다. - -- 모든 모듈은 TCA Dependency Container를 사용합니다. -- 계층 간 연결(Feature <-> Domain)은 Interface 모듈만 import합니다. -- liveValue는 Implementation 모듈에서 제공하며, 조립은 App/Feature Root에서 `.withDependency`로 명시합니다. -- Implementation 모듈 내부에서 다른 모듈의 의존성을 조립하지 않습니다. -- Core/Network, Core/Storage는 singleton을 사용하지 않고 TCA Dependency로 주입 가능한 인스턴스형으로 제공합니다. - -Interface 예시 -```swift -public struct DetailFactory: Sendable { - public var makeView: @MainActor (StoreOf) -> AnyView - public init(makeView: @escaping @MainActor (StoreOf) -> AnyView) { - self.makeView = makeView - } -} - -extension DetailFactory: TestDependencyKey { - public static let testValue = Self { _ in - assertionFailure("DetailFactory.makeView is unimplemented") - return AnyView(EmptyView()) - } -} -``` - -Sources 예시 -```swift -extension DetailFactory: DependencyKey { - public static let liveValue = Self { store in - AnyView(DetailView(store: store)) - } -} -``` - -사용 예시 -```swift -@Dependency(\.detailFactory) var detailFactory -detailFactory.makeView(store: store.scope(state: \.detail, action: \.detail)) -``` - -## SwiftLint 규칙 (필수) -SwiftLint 경고를 가능한 한 최소화해야 합니다. - -- 새로운 코드에서는 SwiftLint 경고가 발생하지 않도록 작성합니다. -- 변경으로 인해 경고가 증가하지 않도록 합니다. -- 불가피한 경우에만 제한적으로 `swiftlint:disable`을 사용하고, 범위를 최소화합니다. - -## 코드 스타일 규칙 (필수) -메소드의 매개 변수가 2개 이상일 때는 개행하여 가독성을 높입니다. - -예시 -```swift -public func example( - a: Int, - b: Int -) -> ReturnType { ... } -``` - -## 외부 의존성 참조 규칙 (필수) -서로 다른 계층(Feature/Domain/Core) 간 참조는 Interface만으로 해결 가능한지 먼저 검증합니다. - -- Interface만으로 해결 가능하면 해당 방식만 사용합니다. -- Interface만으로 불가능한 경우에만 implements를 허용하며, 불가능한 이유를 문서화합니다. -- 전체 모듈 참조(예: `.domain`, `.core`)로 대체하는 결정은 원칙적으로 지양하며, 구조적 필요성이 명확할 때만 허용합니다. - -## TCA Dependency + Interface 규칙 메모 -Interface에 TestDependencyKey를 두면 MFA 규칙상 Testing 모듈 분리 원칙과 충돌 가능성이 있으므로, -팀 합의로 허용하거나 Testing 모듈로 대체하는 방안을 추후 결정합니다. diff --git a/Scripts/generate-proof-photo-large-fixture.swift b/Scripts/generate-proof-photo-large-fixture.swift new file mode 100755 index 00000000..be3a6006 --- /dev/null +++ b/Scripts/generate-proof-photo-large-fixture.swift @@ -0,0 +1,145 @@ +#!/usr/bin/env swift +// +// generate-proof-photo-large-fixture.swift +// +// Generates a deterministic 4032×3024 JPEG fixture for the Pass 4 +// ProofPhoto rendering scenarios. The fixture must be high-entropy enough +// to represent a real iPhone photo for image decode/render measurement +// (Pass 4 plan section A: minimum 3MB JPEG, preferably 4-8MB). +// +// Output: Projects/Feature/ProofPhoto/Example/Resources/proof-photo-prefilled-large.jpg +// +// Re-run after changing parameters: +// swift Scripts/generate-proof-photo-large-fixture.swift +// +// Content design (deterministic, no RNG): +// - Per-pixel hash-derived noise on top of a vertical color gradient. +// - Diagonal grid overlay for additional high-frequency detail. +// - Color bands per region to keep the image visually identifiable +// (not pure static), while staying high-entropy enough that JPEG +// compression at q=0.85 lands in the 3-8MB target. + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +let width = 4032 +let height = 3024 +let outputDir = "Projects/Feature/ProofPhoto/Example/Resources" +let jpegQuality: CGFloat = 0.85 + +// Two variants: "large" (initial) and "large-second" (reselect). Same +// dimensions / generation method, different coordinate seed so the byte +// content differs. Re-select scenario must show a different image after +// dispatch to prove the production action replaces state.imageData. +let variants: [(name: String, offset: Int)] = [ + ("proof-photo-prefilled-large", 0), + ("proof-photo-prefilled-large-second", 1_000_003) +] + +let bytesPerPixel = 4 +let bytesPerRow = width * bytesPerPixel +let bufferSize = bytesPerRow * height + +func generate(name: String, offset: Int) { + guard let buffer = malloc(bufferSize)?.assumingMemoryBound(to: UInt8.self) else { + fputs("malloc failed for buffer\n", stderr) + exit(1) + } + defer { free(buffer) } + + // Deterministic per-pixel content. The xorshift-like mix gives + // high-entropy noise that resists JPEG compression. Mixed with a + // gradient + diagonal bands so the result is still visually structured. + for y in 0..> 13 + h = h &* 1274126177 + h ^= h &>> 16 + let n = Double(h & 0xFF) / 255.0 + + let band = sin((Double(x + y + offset) / 32.0)) * 0.5 + 0.5 + let r = (baseR * 0.5 + band * 0.2 + n * 0.3).clamped() + let g = (baseG * 0.5 + band * 0.25 + n * 0.25).clamped() + let b = (baseB * 0.5 + band * 0.15 + n * 0.35).clamped() + + buffer[pixelIndex + 0] = UInt8(r * 255.0) + buffer[pixelIndex + 1] = UInt8(g * 255.0) + buffer[pixelIndex + 2] = UInt8(b * 255.0) + buffer[pixelIndex + 3] = 0xFF + } + } + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo: UInt32 = CGImageAlphaInfo.noneSkipLast.rawValue + | CGBitmapInfo.byteOrder32Big.rawValue + + guard let context = CGContext( + data: buffer, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + fputs("CGContext create failed\n", stderr) + exit(1) + } + + guard let cgImage = context.makeImage() else { + fputs("CGContext.makeImage failed\n", stderr) + exit(1) + } + + let outputPath = "\(outputDir)/\(name).jpg" + let outputURL = URL(fileURLWithPath: outputPath) + try? FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + guard let destination = CGImageDestinationCreateWithURL( + outputURL as CFURL, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + fputs("CGImageDestination create failed\n", stderr) + exit(1) + } + + let properties: CFDictionary = [ + kCGImageDestinationLossyCompressionQuality: jpegQuality + ] as CFDictionary + + CGImageDestinationAddImage(destination, cgImage, properties) + guard CGImageDestinationFinalize(destination) else { + fputs("JPEG finalize failed\n", stderr) + exit(1) + } + + let attrs = try! FileManager.default.attributesOfItem(atPath: outputPath) + let bytes = attrs[.size] as? Int ?? 0 + let mib = Double(bytes) / 1024.0 / 1024.0 + print(String(format: "wrote %@ — %d bytes (%.2f MiB), %dx%d, q=%.2f", + outputPath, bytes, mib, width, height, jpegQuality)) +} + +for variant in variants { + generate(name: variant.name, offset: variant.offset) +} + +extension Double { + fileprivate func clamped() -> Double { + return Swift.min(1.0, Swift.max(0.0, self)) + } +} diff --git a/Scripts/run-claude-implementation.sh b/Scripts/run-claude-implementation.sh new file mode 100755 index 00000000..3dcb2279 --- /dev/null +++ b/Scripts/run-claude-implementation.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +HANDOFF_DIR=".agent/handoff" +REQUEST_FILE="$HANDOFF_DIR/IMPLEMENTATION_REQUEST.md" +RESULT_FILE="$HANDOFF_DIR/IMPLEMENTATION_RESULT.md" +OUT_FILE="$HANDOFF_DIR/claude.out" +ERR_FILE="$HANDOFF_DIR/claude.err" + +if [[ ! -f "$REQUEST_FILE" ]]; then + echo "Missing required file: $REQUEST_FILE" >&2 + exit 1 +fi + +mkdir -p "$HANDOFF_DIR" + +if command -v uuidgen >/dev/null 2>&1; then + SESSION_ID="$(uuidgen)" +else + SESSION_ID="$(date +%Y%m%d%H%M%S)" +fi + +set +e +claude -p \ + --session-id "$SESSION_ID" \ + --no-session-persistence \ + --permission-mode acceptEdits \ + --allowedTools "Read Edit Write Bash(git diff*) Bash(git status*) Bash(git log*) Bash(git branch*) Bash(rg *) Bash(find *) Bash(ls *) Bash(pwd)" \ + --disallowedTools "Bash(git push*) Bash(git add*) Bash(git commit*) Bash(rm -rf*) Bash(git reset --hard*) Bash(git clean*) Bash(sudo*) Bash(xcodebuild*) Bash(fastlane*) Bash(bundle exec fastlane*) Bash(tuist clean*)" \ + --max-budget-usd 5.00 \ + --output-format json \ + --append-system-prompt "Keep stdout concise. Do not stage files, commit, push, open PRs, run full CI, run xcodebuild, run Fastlane, or run tuist clean. Write .agent/handoff/IMPLEMENTATION_RESULT.md with STATUS: DONE, BLOCKED, NO_CHANGES, or FAILED." \ + < "$REQUEST_FILE" \ + > "$OUT_FILE" \ + 2> "$ERR_FILE" +CLAUDE_EXIT_CODE=$? +set -e + +RESULT_STATUS="" +if [[ -f "$RESULT_FILE" ]]; then + RESULT_STATUS="$(grep -E '^STATUS:' "$RESULT_FILE" | head -n 1 | sed 's/^STATUS:[[:space:]]*//')" +fi + +echo "Claude implementation summary:" +echo "- session id: $SESSION_ID" +echo "- exit code: $CLAUDE_EXIT_CODE" +echo "- result status: ${RESULT_STATUS:-MISSING}" +echo "- output files:" +echo " - $OUT_FILE" +echo " - $ERR_FILE" +echo " - $RESULT_FILE" + +if [[ $CLAUDE_EXIT_CODE -ne 0 ]]; then + exit "$CLAUDE_EXIT_CODE" +fi + +if [[ ! -f "$RESULT_FILE" ]]; then + echo "Missing required result file: $RESULT_FILE" >&2 + exit 1 +fi + +if [[ -z "$RESULT_STATUS" ]]; then + echo "Missing STATUS line in $RESULT_FILE" >&2 + exit 1 +fi diff --git a/Scripts/smoke-test-claude-handoff.sh b/Scripts/smoke-test-claude-handoff.sh new file mode 100755 index 00000000..6ddc052f --- /dev/null +++ b/Scripts/smoke-test-claude-handoff.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +HANDOFF_DIR=".agent/handoff" +REQUEST_FILE="$HANDOFF_DIR/IMPLEMENTATION_REQUEST.md" +RESULT_FILE="$HANDOFF_DIR/IMPLEMENTATION_RESULT.md" +OUT_FILE="$HANDOFF_DIR/claude.out" +ERR_FILE="$HANDOFF_DIR/claude.err" +TARGET_FILE="$HANDOFF_DIR/SMOKE_TEST_TARGET.md" +RUNNER="Scripts/run-claude-implementation.sh" + +mkdir -p "$HANDOFF_DIR" + +if [[ ! -x "$RUNNER" ]]; then + echo "실패: runner가 없거나 실행 권한이 없습니다: $RUNNER" >&2 + exit 1 +fi + +status_without_handoff() { + git status --porcelain --untracked-files=all \ + | grep -vE '^(.. )?\.agent/handoff/' \ + || true +} + +BEFORE_STATUS="$(status_without_handoff)" +BEFORE_DIFF_HASH="$(git diff -- . ':(exclude).agent/handoff/**' | shasum | awk '{print $1}')" + +rm -f \ + "$REQUEST_FILE" \ + "$RESULT_FILE" \ + "$OUT_FILE" \ + "$ERR_FILE" \ + "$TARGET_FILE" + +cat > "$REQUEST_FILE" <<'REQUEST' +Read AGENTS.md first. + +This is a smoke test for the Pi → Claude Code handoff runner. + +Allowed change: +- Create or update only .agent/handoff/SMOKE_TEST_TARGET.md +- Write exactly this single line to it: + smoke test completed + +Required result file: +- Write .agent/handoff/IMPLEMENTATION_RESULT.md +- Include exactly one STATUS line: + STATUS: DONE + +Do not edit any other files. +Do not modify source code, docs, tests, AGENTS.md, CLAUDE.md, or skill files. +Do not run build/test/Fastlane/xcodebuild/tuist. +Do not git add, git commit, or git push. +Keep output minimal. +REQUEST + +RUNNER_EXIT=0 +set +e +"$RUNNER" +RUNNER_EXIT=$? +set -e + +SMOKE_FAILED=0 +FAIL_REASONS=() + +if [[ $RUNNER_EXIT -ne 0 ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("runner exit code가 0이 아닙니다: $RUNNER_EXIT") +fi + +if [[ ! -f "$TARGET_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("SMOKE_TEST_TARGET.md가 생성되지 않았습니다") +fi + +if [[ ! -f "$RESULT_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("IMPLEMENTATION_RESULT.md가 생성되지 않았습니다") +elif ! grep -qE '^STATUS: DONE$' "$RESULT_FILE"; then + SMOKE_FAILED=1 + FAIL_REASONS+=("IMPLEMENTATION_RESULT.md에 'STATUS: DONE'이 없습니다") +fi + +if [[ ! -f "$OUT_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("claude.out이 생성되지 않았습니다") +fi + +if [[ ! -f "$ERR_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("claude.err이 생성되지 않았습니다") +fi + +AFTER_STATUS="$(status_without_handoff)" +AFTER_DIFF_HASH="$(git diff -- . ':(exclude).agent/handoff/**' | shasum | awk '{print $1}')" + +if [[ "$BEFORE_STATUS" != "$AFTER_STATUS" || "$BEFORE_DIFF_HASH" != "$AFTER_DIFF_HASH" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=(".agent/handoff/ 밖의 git 상태 또는 tracked diff가 변경되었습니다") +fi + +if [[ $SMOKE_FAILED -eq 0 ]]; then + echo "성공: Claude handoff runner smoke test 통과" +else + echo "실패: Claude handoff runner smoke test 실패" >&2 + for reason in "${FAIL_REASONS[@]}"; do + echo "- $reason" >&2 + done +fi + +echo "생성된 파일:" +echo "- $REQUEST_FILE" +echo "- $TARGET_FILE" +echo "- $RESULT_FILE" +echo "- $OUT_FILE" +echo "- $ERR_FILE" +echo "runner exit result: $RUNNER_EXIT" +echo "다음 수동 확인 명령:" +echo "- git status --short" +echo "- git diff --name-only" +echo "- sed -n '1,80p' $RESULT_FILE" + +if [[ $SMOKE_FAILED -ne 0 ]]; then + exit 1 +fi diff --git a/Scripts/verify-perf-targets.sh b/Scripts/verify-perf-targets.sh new file mode 100755 index 00000000..e6554693 --- /dev/null +++ b/Scripts/verify-perf-targets.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE_NAME="Jiyong의 iPhone" +TIME_LIMIT="${TIME_LIMIT:-5s}" +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/twix-perf-traces}" + +if [[ -z "$DEVICE_NAME" ]]; then + echo "error: set DEVICE_NAME to a connected iOS device name." >&2 + exit 64 +fi + +mkdir -p "$OUTPUT_DIR" + +features=( + "auth:org.yapp.twix.example.auth" + "goal-detail:org.yapp.twix.example.goal-detail" + "home:org.yapp.twix.example.home" + "main-tab:org.yapp.twix.example.main-tab" + "make-goal:org.yapp.twix.example.make-goal" + "notification:org.yapp.twix.example.notification" + "onboarding:org.yapp.twix.example.onboarding" + "proof-photo:org.yapp.twix.example.proof-photo" + "settings:org.yapp.twix.example.settings" + "stats:org.yapp.twix.example.stats" +) + +for item in "${features[@]}"; do + slug="${item%%:*}" + bundle_id="${item#*:}" + output="$OUTPUT_DIR/$slug.trace" + + rm -rf "$output" + echo "recording $slug ($bundle_id)" + xcrun xctrace record \ + --device "$DEVICE_NAME" \ + --template "Time Profiler" \ + --time-limit "$TIME_LIMIT" \ + --output "$output" \ + --launch "$bundle_id" \ + -- \ + -UITEST \ + -UITEST_SEED default \ + -UITEST_DISABLE_ANIMATIONS \ + -UITEST_WAIT_READY +done + +echo "traces written to $OUTPUT_DIR" diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 2e613ec7..2c2a68b3 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "5c0d0ed23de9ecd5ee8f58f55c05e9268ba72f94994e48c0f6f308e2b3d7430e", + "originHash" : "8c94e2269422c116e5525a199d307e9532a7fdf925bdb423193501f8bfe1d3fe", "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, { "identity" : "alamofire", "kind" : "remoteSourceControl", @@ -19,24 +10,6 @@ "version" : "5.11.0" } }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" - } - }, - { - "identity" : "appauth-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/openid/AppAuth-iOS.git", - "state" : { - "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", - "version" : "2.0.0" - } - }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", @@ -47,95 +20,14 @@ } }, { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60", - "version" : "12.13.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf", - "version" : "3.5.0" - } - }, - { - "identity" : "googleappmeasurement", + "identity" : "firebase-ios-sdk-xcframeworks", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", + "location" : "https://github.com/akaffenberger/firebase-ios-sdk-xcframeworks", "state" : { - "revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604", + "revision" : "7f17017dc1f529bab158eb950bd6012acd9e3ad1", "version" : "12.13.0" } }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googlesignin-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleSignIn-iOS", - "state" : { - "revision" : "913b4005ea26aebe1c97d54e35ad82a515924c71", - "version" : "9.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" - } - }, - { - "identity" : "gtmappauth", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GTMAppAuth.git", - "state" : { - "revision" : "56e0ccf09a6dd29dc7e68bdf729598240ca8aa16", - "version" : "5.0.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, { "identity" : "kakao-ios-sdk", "kind" : "remoteSourceControl", @@ -154,33 +46,6 @@ "version" : "8.6.2" } }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, { "identity" : "pulse", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 750193a4..92d2ec34 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -9,13 +9,17 @@ import PackageDescription "ComposableArchitecture": .framework, "Kingfisher": .framework, "Pulse": .framework, - "KakaoSDK": .staticLibrary, - "GoogleSignIn": .staticLibrary, - "GoogleSignInSwift": .staticLibrary + "KakaoSDK": .staticLibrary ] ) #endif +// Firebase / GoogleSignIn은 akaffenberger 미러를 통해 prebuilt xcframework로 통합한다. +// 미러는 Firebase 공식 zip을 SPM `binaryTarget`으로 재포장한 것으로, Firebase가 +// 의존하는 GoogleUtilities/Promises 등을 동일 패키지가 함께 제공하기 때문에 +// 다른 SPM 경로(예: google/GoogleSignIn-iOS의 transitive deps)와의 sub-framework +// 분할 충돌을 구조적으로 피한다. 따라서 GoogleSignIn도 같은 미러의 product를 사용한다. +// 버전은 미러의 release tag(= Firebase 공식 버전)와 일치한다. let package = Package( name: "Twix", dependencies: [ @@ -23,7 +27,6 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"), .package(url: "https://github.com/kean/Pulse", from: "5.1.4"), .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.27.1"), - .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "9.1.0"), - .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "12.13.0") + .package(url: "https://github.com/akaffenberger/firebase-ios-sdk-xcframeworks", from: "12.13.0") ] ) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift index 84976f58..6e18f064 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift @@ -36,4 +36,20 @@ public extension InfoPlist { return self } } + + func mergingExampleDisplayName(_ displayName: String) -> InfoPlist { + switch self { + case .default: + return .extendingDefault(with: ["CFBundleDisplayName": .string(displayName)]) + + case .extendingDefault(let dict): + let merged = dict.merging( + ["CFBundleDisplayName": .string(displayName)] + ) { current, _ in current } + return .extendingDefault(with: merged) + + default: + return self + } + } } diff --git a/Tuist/ProjectDescriptionHelpers/Module.swift b/Tuist/ProjectDescriptionHelpers/Module.swift index 7a264bba..3359abfc 100644 --- a/Tuist/ProjectDescriptionHelpers/Module.swift +++ b/Tuist/ProjectDescriptionHelpers/Module.swift @@ -112,6 +112,7 @@ public extension Module { case util = "Util" case thirdPartyLib = "ThirdPartyLib" case designSystem = "DesignSystem" + case perfTestingSupport = "PerfTestingSupport" /// Shared 타겟 이름의 기본 prefix입니다. public static let name: String = "Shared" diff --git a/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift b/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift index 68b8967e..1c3257ee 100644 --- a/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift @@ -17,6 +17,50 @@ import ProjectDescription /// /// **Project.swift**에서 사용됩니다. public extension Project { + private static var defaultModuleSettings: Settings { + .settings( + configurations: [ + .debug(name: "Debug"), + .release(name: "Release"), + .release( + name: "Profile", + settings: [ + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", + "COPY_PHASE_STRIP": "NO", + "STRIP_INSTALLED_PRODUCT": "NO", + "CONFIGURATION_BUILD_DIR": "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)", + "FRAMEWORK_SEARCH_PATHS": [ + "$(inherited)", + "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)" + ], + "LIBRARY_SEARCH_PATHS": [ + "$(inherited)", + "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)" + ] + ] + ), + .release( + name: "PerfProfile", + settings: [ + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", + "COPY_PHASE_STRIP": "NO", + "STRIP_INSTALLED_PRODUCT": "NO", + "SWIFT_ACTIVE_COMPILATION_CONDITIONS": "$(inherited) PERF_TESTING", + "CONFIGURATION_BUILD_DIR": "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)", + "FRAMEWORK_SEARCH_PATHS": [ + "$(inherited)", + "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)" + ], + "LIBRARY_SEARCH_PATHS": [ + "$(inherited)", + "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)" + ] + ] + ) + ] + ) + } + /// `Project`모듈을 생성합니다. /// 내부적으로 `Project.init`과 1:1로 매핑됩니다. /// - Parameters: @@ -53,7 +97,7 @@ public extension Project { classPrefix: classPrefix, options: options, packages: packages, - settings: settings, + settings: settings ?? defaultModuleSettings, targets: targets, schemes: schemes, fileHeaderTemplate: fileHeaderTemplate, diff --git a/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift b/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift index dd91c6cf..1fac3a61 100644 --- a/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift +++ b/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift @@ -21,9 +21,9 @@ public extension TargetScript { exit 0 fi - UPLOAD_SYMBOLS="$SRCROOT/../../Tuist/.build/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols" + UPLOAD_SYMBOLS="$SRCROOT/../../Tuist/.build/checkouts/firebase-ios-sdk-xcframeworks/Sources/FirebaseCrashlytics/upload-symbols" if [ ! -x "$UPLOAD_SYMBOLS" ]; then - UPLOAD_SYMBOLS=$(find "$SRCROOT/../.." -maxdepth 6 -name "upload-symbols" -path "*/firebase-ios-sdk/Crashlytics/*" 2>/dev/null | head -1) + UPLOAD_SYMBOLS=$(find "$SRCROOT/../.." -maxdepth 8 -name "upload-symbols" -path "*/FirebaseCrashlytics/*" 2>/dev/null | head -1) fi if [ -z "$UPLOAD_SYMBOLS" ] || [ ! -x "$UPLOAD_SYMBOLS" ]; then echo "warning: Firebase Crashlytics upload-symbols not found. Run 'tuist install'." diff --git a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift index c78b39e1..d5c2f69b 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift @@ -16,14 +16,15 @@ public extension TargetDependency { /// 각 case는 SPM 패키지를 나타내며, /// `TargetDependency.external(dependency:)`와 유기적으로 사용됩니다. enum External: String { + // Firebase: akaffenberger 미러는 product 단위로 노출하며 FirebaseCore는 + // 별도 product가 아닌 다른 Firebase product의 transitive 의존성으로 포함된다. + // 따라서 FirebaseCore는 enum에 두지 않는다. case FirebaseAnalytics - case FirebaseCore case FirebaseMessaging case FirebaseRemoteConfig case FirebaseCrashlytics - + case GoogleSignIn - case GoogleSignInSwift case KakaoSDKCommon case KakaoSDKAuth diff --git a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift index 02e559cd..4c821f52 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift @@ -196,4 +196,12 @@ public extension TargetDependency { static func shared(interface module: Module.Shared) -> Self { return .project(target: Module.Shared.name + module.rawValue + "Interface", path: .shared(implementation: module)) } + + /// Perf UITest 타겟에서만 사용하는 XCTest 지원 모듈입니다. + static var sharedPerfTestingSupportUITests: Self { + return .project( + target: Module.Shared.name + Module.Shared.perfTestingSupport.rawValue + "UITests", + path: .shared(implementation: .perfTestingSupport) + ) + } } diff --git a/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift b/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift index 9338dd98..2037f389 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift @@ -8,6 +8,33 @@ import ProjectDescription public extension Target { + private static func featureExampleSlug(_ module: Module.Feature) -> String { + switch module { + case .auth: + return "auth" + case .goalDetail: + return "goal-detail" + case .home: + return "home" + case .mainTab: + return "main-tab" + case .makeGoal: + return "make-goal" + case .notification: + return "notification" + case .onboarding: + return "onboarding" + case .proofPhoto: + return "proof-photo" + case .settings: + return "settings" + case .stats: + return "stats" + case .common: + return "common" + } + } + /// Feature 모듈의 루트 타겟을 생성합니다. /// - Parameter config: 기본 설정을 담고 있는 `TargetConfig`입니다. Feature 루트 타겟에 맞게 일부 값이 수정됩니다. /// - Returns: Feature 루트 타겟 설정이 적용된 `Target` @@ -81,28 +108,62 @@ public extension Target { newConfig.name = exampleName newConfig.sources = .exampleSources newConfig.product = .app - newConfig.bundleId = Project.Environment.BundleId.bundlePrefix + newConfig.bundleId = Project.Environment.BundleId.bundlePrefix + ".example." + featureExampleSlug(module) newConfig.destinations = .iOS - newConfig.resources = ["Resources/**"] + newConfig.resources = ["Example/Resources/**"] newConfig.productName = exampleName + newConfig.dependencies.append(.shared(implements: .perfTestingSupport)) if let infoPlist = newConfig.infoPlist { - newConfig.infoPlist = infoPlist.mergingLaunchScreenDefaults() + newConfig.infoPlist = infoPlist + .mergingLaunchScreenDefaults() + .mergingExampleDisplayName("Example: \(module.rawValue)") } else { newConfig.infoPlist = .extendingDefault( with: Project.Environment.InfoPlist.launchScreen ) + .mergingExampleDisplayName("Example: \(module.rawValue)") } - // Example 앱 타겟에 코드 사이닝 설정 추가 (match Development) + // Example 앱은 perf 측정용 독립 번들 ID를 automatic signing으로 관리합니다. + newConfig.settings = .settings( + base: [ + "CODE_SIGN_STYLE": "Automatic", + "DEVELOPMENT_TEAM": "\(Project.Environment.BundleId.teamId)", + "TARGETED_DEVICE_FAMILY": "1", + "SUPPORTS_MACCATALYST": "NO", + "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO", + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym" + ] + ) + + return makeTarget(config: newConfig) + } + + /// Feature 예제 앱의 smoke UITest 타겟을 생성합니다. + static func feature(exampleUITests module: Module.Feature, config: TargetConfig = .init()) -> Self { + var newConfig = config + let exampleName = Module.Feature.name + module.rawValue + "Example" + newConfig.name = exampleName + "UITests" + newConfig.product = .uiTests + newConfig.sources = "ExampleUITests/Sources/**" + newConfig.bundleId = Project.Environment.BundleId.bundlePrefix + + ".example." + + featureExampleSlug(module) + + ".uitests" + newConfig.destinations = .iOS + newConfig.dependencies = [ + .target(name: exampleName), + .sharedPerfTestingSupportUITests + ] + newConfig.dependencies newConfig.settings = .settings( base: [ - "CODE_SIGN_STYLE": "Manual", + "CODE_SIGN_STYLE": "Automatic", "DEVELOPMENT_TEAM": "\(Project.Environment.BundleId.teamId)", - "PROVISIONING_PROFILE_SPECIFIER": "match Development \(Project.Environment.BundleId.bundlePrefix)", "TARGETED_DEVICE_FAMILY": "1", "SUPPORTS_MACCATALYST": "NO", - "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO" + "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO", + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym" ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift b/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift index 9f6faa0b..31e71cba 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift @@ -34,4 +34,23 @@ public extension Target { return makeTarget(config: newConfig) } + + /// Shared PerfTestingSupport의 XCTest 전용 지원 타겟을 생성합니다. + /// 앱 런타임 모듈과 XCTest import 경계를 분리하기 위한 타겟입니다. + static func sharedPerfTestingSupportUITests(config: TargetConfig) -> Self { + var newConfig = config + newConfig.name = Module.Shared.name + Module.Shared.perfTestingSupport.rawValue + "UITests" + newConfig.product = .staticFramework + newConfig.sources = "UITests/Sources/**" + newConfig.dependencies = [ + .shared(implements: .perfTestingSupport) + ] + newConfig.dependencies + newConfig.settings = .settings( + base: [ + "ENABLE_TESTING_SEARCH_PATHS": "YES" + ] + ) + + return makeTarget(config: newConfig) + } } diff --git a/docs/Architecture/Overview.md b/docs/Architecture/Overview.md index 99b0331d..b2932c95 100644 --- a/docs/Architecture/Overview.md +++ b/docs/Architecture/Overview.md @@ -23,10 +23,10 @@ Projects/ │ └── Resources/ │ ├── Feature/ # 기능 모듈 (UI + 비즈니스 로직) -│ ├── Auth/ -│ ├── Onboarding/ -│ ├── MainTab/ -│ └── Sources/ # Feature Root +│ ├── Auth/ # 현재 App 직접 조립 예외 Feature +│ ├── Onboarding/ # 현재 App 직접 조립 예외 Feature +│ ├── MainTab/ # 현재 App 직접 조립 예외 Feature +│ └── Sources/ # 현재 Feature Root / re-export layer │ ├── Domain/ # 도메인 로직 │ └── Auth/ @@ -52,6 +52,9 @@ Projects/ ### Feature 계층 - UI + 비즈니스 로직 - Interface/Sources 분리 +- Interface 모듈은 외부에 노출되는 public boundary입니다. +- Sources 모듈은 구현 세부사항을 숨기는 implementation layer입니다. +- 다른 Feature/App/상위 조립 계층은 일반적으로 implementation Sources가 아니라 Interface 모듈에 의존합니다. - 독립적으로 실행 가능 (Example 타겟) ### Domain 계층 @@ -75,11 +78,15 @@ Projects/ ### 1. Interface/Implementation 분리 ``` -Feature/Auth/ -├── Interface/Sources/ # 타입 정의만 (public) -└── Sources/ # 실제 구현 (internal) +Feature/{Feature}/ +├── Interface/Sources/ # 외부 공개 계약(public boundary) +└── Sources/ # 실제 구현(implementation details) ``` +Interface 모듈은 public reducer/state/action, client, factory, dependency key 등 외부 조립에 필요한 공개 계약을 제공합니다. Sources 모듈은 View, live 구현, reducer 세부 로직 등 구현 세부사항을 숨깁니다. + +소비자는 특별한 예외가 없는 한 implementation Sources가 아니라 Interface 모듈에 의존해야 합니다. 구현 모듈을 직접 import하는 것은 모듈 경계를 약화시키므로, 문서화된 예외 또는 명시적 승인 없이 새로 도입하지 않습니다. + **장점**: - 빌드 시간 최적화 - 의존성 최소화 @@ -87,6 +94,9 @@ Feature/Auth/ **예외 (App 직접 조립 Feature)**: - Auth / Onboarding / MainTab은 App에서 직접 Path를 관리하는 중간 관리자 Feature로 취급합니다. +- 현재 경로는 각각 `Projects/Feature/Auth/`, `Projects/Feature/Onboarding/`, `Projects/Feature/MainTab/`입니다. +- 현재 `Projects/Feature/Sources/Source.swift`는 Feature Root / re-export layer로 사용됩니다. +- 위 경로는 현재 코드베이스 관찰값이며, 그 자체가 모든 신규 구조의 이상적인 형태임을 의미하지는 않습니다. - 위 Feature는 Interface/Implementation 분리 및 ViewFactory 강제 규칙에서 예외입니다. - App은 위 Feature를 `makeView(_:)` 없이 직접 조립할 수 있습니다. - 위 Feature는 내부 하위 Feature 조립 시 Implementation 모듈을 직접 import 할 수 있습니다. @@ -101,7 +111,10 @@ Feature/Auth/ 모든 의존성은 TCA Dependency Container로 주입합니다. - 모든 모듈에서 TCA Dependency Container를 사용합니다. - 계층 간 연결은 Interface 모듈만 노출하며, liveValue는 Implementation 모듈에서 제공합니다. +- 서로 다른 계층(Feature / Domain / Core) 간 참조는 Interface만으로 해결 가능한지 먼저 검증합니다. +- Interface만으로 불가능한 implementation 의존은 명확한 구조적 이유가 있을 때만 허용합니다. - 의존성 조립은 App/Feature Root에서 `.withDependency`로 명시적으로 수행합니다. +- Feature Root에서 타입 재노출이 필요할 경우 public boundary를 해치지 않도록 Interface 타입 재노출을 우선합니다. ```swift @Dependency(\.authLoginClient) var authLoginClient ``` @@ -114,6 +127,20 @@ View를 직접 노출하지 않고 Factory로 생성: authViewFactory.makeView(store) ``` +### 4. Token 접근 경계 + +토큰 접근은 현재 `TokenManager` 패턴을 통해 중재합니다. + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` +- Token storage interface: `Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift` +- Keychain implementation: `Projects/Core/Storage/Sources/KeychainTokenStorage.swift` +- 현재 Authorization header 처리 패턴: `Projects/Domain/Auth/Sources/AuthInterceptor.swift`가 `TokenManager`를 사용합니다. +- 현재 App/root wiring: `Projects/App/Sources/View/TwixApp.swift`에서 live token storage dependency를 설정합니다. + +Feature, Reducer, View, 일반 Client, request-building code는 `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults 등 token persistence에 직접 접근하지 않습니다. 토큰 조회/저장/삭제/refresh-state 전환 및 access token 조회는 `TokenManager`를 통해 수행합니다. + +직접 TokenStorage 사용은 `TokenManager` 내부, Core Storage interface/implementation, App/root dependency wiring, tests/mocks, 그리고 `TokenManager`에 의존하는 승인된 auth infrastructure로 제한합니다. + --- ## 데이터 흐름 @@ -150,10 +177,10 @@ authViewFactory.makeView(store) ## 다음 단계 -- [Reducer 패턴](./ReducerPattern.md) - Reducer 구현 방법 -- [Dependency Injection](./DependencyInjection.md) - 의존성 주입 -- [ViewFactory 패턴](./ViewFactory.md) - ViewFactory 구현 -- [팀 규칙](../../Rules.md) - 팀 합의사항 +- [구현 체크리스트](../Reference/Checklists.md) - Feature 구현 확인 항목 +- [파일 구조화 규칙](../Reference/FileOrganization.md) - 파일 분리 및 Interface 파일 정책 +- [네이밍 규칙](../Reference/NamingConventions.md) - Action, File, 타입 네이밍 +- [프로젝트 규칙](../Reference/ProjectRules.md) - 팀 합의사항 --- diff --git a/docs/Architecture/ReducerPattern.md b/docs/Architecture/ReducerPattern.md new file mode 100644 index 00000000..c4386834 --- /dev/null +++ b/docs/Architecture/ReducerPattern.md @@ -0,0 +1,365 @@ +# Reducer 패턴 + +> Interface/Implementation 분리 + 중첩 State/Action 구조에 대한 가이드 + +--- + +## 개요 + +Feature Reducer가 커져서 State/Action의 의미가 흐려질 때는 다음 원칙을 우선합니다. +작은 단일 목적 Reducer는 기존 플랫 구조를 유지할 수 있습니다. + +1. **Interface/Implementation 분리** — `*Interface` 모듈에 State/Action 셸, `Sources` 모듈에 실제 Reduce 로직 +2. **중첩 Action enum** — 규모가 큰 Action은 `View` / `Internal` / `Response` / `Presentation` / `Delegate` 로 분리 +3. **중첩 State struct** — 상태 규모에 따라 `Data` / `UIState` / `Presentation` 분리를 검토 +4. **Reducer 분기 분리** — Reduce 클로저 내 큰 분기를 `reduceView`, `reduceInternal` 등 helper 함수/메서드로 위임 + +--- + +## Action 구조 + +```swift +public enum Action: BindableAction { + case binding(BindingAction) + + /// 사용자 이벤트. ~Tapped, ~Changed, ~Selected 네이밍. + /// SwiftUI에서 직접 전송하는 onAppear/onDisappear 같은 lifecycle도 여기에 둡니다. + public enum View: Equatable { + case backButtonTapped + case submitButtonTapped + case onAppear // lifecycle — SwiftUI가 직접 트리거 + } + + /// Reducer 내부에서 발행하는 Effect 트리거. + /// API 호출 시작, 상태 계산, View 이벤트 이후의 후속 작업 등. + public enum Internal: Equatable { + case fetchItems + case updateCache([Item]) + } + + /// 비동기 응답. Result 포함 시 Equatable 불필요. + public enum Response { + case fetchItemsResponse(Result<[Item], Error>) + case deleteItemResponse(Result) + } + + /// 토스트·모달 표시. + public enum Presentation: Equatable { + case showToast(TXToastType) + } + + /// 부모 Reducer에게 알림. 항상 Equatable. + public enum Delegate: Equatable { + case navigateBack + case itemSelected(Item) + } + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) + case delegate(Delegate) +} +``` + +### 규칙 요약 + +| enum | Equatable | 내용 | +|------|-----------|------| +| `View` | ✅ | 사용자 탭·입력·lifecycle | +| `Internal` | ✅ | Reducer 내부 Effect 트리거 | +| `Response` | ❌ (Error 포함 시) | 비동기 결과 | +| `Presentation` | ✅ | 토스트, 모달 | +| `Delegate` | ✅ | 부모에게 전달하는 이벤트 | + +--- + +## State 구조 + +### 분리 기준 + +**플랫 State 유지** (인스턴스 프로퍼티 5개 이하): +- 단일 목적의 작은 화면 (OnboardingProfile, Auth 등) +- Presentation 레이어 없음 (modal/toast 없음) + +**Data/UIState/Presentation 분리 권장** (인스턴스 프로퍼티 6개 이상): +- 여러 섹션을 가진 복잡한 화면 +- modal·toast 같은 Presentation 상태가 도메인 데이터와 섞이는 경우 + +> `static let` 상수는 인스턴스 프로퍼티가 아니므로 개수 계산에서 제외합니다. + +--- + +### Constants 위치 + +**`static let` 상수는 `State` 최상위에 선언하는 것을 권장합니다.** `Data` / `UIState` / `Presentation` 서브구조체 안에 두지 않습니다. +인스턴스 `let`(상수처럼 보이지만 사실 모든 State 인스턴스에 복사되는 프로퍼티)은 피합니다. + +```swift +public struct State: Equatable { + // ✅ 타입 상수 — State 최상위 + public static let maxLength = 8 + public static let icons: [GoalIcon] = GoalIcon.allCases + + // ❌ 인스턴스 상수 — 금지 (모든 State 인스턴스마다 복사됨) + // public let maxLength = 8 + + public struct Data: Equatable { ... } + public struct UIState: Equatable { ... } + public struct Presentation: Equatable { ... } + ... +} +``` + +--- + +### 서브구조체 선택적 적용 + +세 구조체 중 해당하는 카테고리가 없으면 만들지 않습니다. + +```swift +// Presentation(modal/toast)이 없는 경우 — Presentation 생략 +public struct State: Equatable { + public struct Data: Equatable { ... } + public struct UIState: Equatable { ... } + public var data: Data + public var ui: UIState +} +``` + +--- + +규모가 충분하면 아래 패턴으로 분리합니다. + +```swift +@ObservableState +public struct State: Equatable { + + /// 도메인 데이터 + public struct Data: Equatable { + public var items: [Item] = [] + public var selectedItemId: Int64? + } + + /// UI 플래그 및 로딩 상태 + public struct UIState: Equatable { + public var isLoading: Bool = false + public var isEditing: Bool = false + } + + /// 토스트, 모달, 시트 표시 여부 + public struct Presentation: Equatable { + public var toast: TXToastType? + public var modal: TXModalStyle? + } + + public var data: Data + public var ui: UIState + public var presentation: Presentation + + // 서브구조체를 넘나드는 computed property는 최상위에 둡니다 + public var isReady: Bool { !ui.isLoading && !data.items.isEmpty } + + public init() { + self.data = Data() + self.ui = UIState() + self.presentation = Presentation() + } +} +``` + +### `Swift.Data` 충돌 주의 + +`State` 안에 `Data` struct를 정의하면 Swift 표준 `Data` 타입과 이름이 충돌합니다. +`Data` 타입을 사용하는 프로퍼티는 `Swift.Data`로 명시합니다. + +```swift +public var imageData: Swift.Data? +``` + +--- + +## Interface 셸 + +`*Interface` 모듈에는 State/Action 정의와 셸 init만 둡니다. +실제 Reduce 로직은 포함하지 않습니다. + +```swift +// FeatureXxxInterface/Sources/XxxReducer.swift +@Reducer +public struct XxxReducer { + private let reducer: Reduce + + @ObservableState + public struct State: Equatable { ... } + + public enum Action: BindableAction { ... } + + public init(reducer: Reduce) { + self.reducer = reducer + } + + public var body: some ReducerOf { + BindingReducer() + reducer + // ifLet 등 child reducer 연결은 여기에 + } +} +``` + +--- + +## Implementation + +`Sources` 모듈의 `extension` 파일에서 실제 Reduce를 구성합니다. + +```swift +// FeatureXxx/Sources/XxxReducer+Impl.swift +extension XxxReducer { + public init() { + let reducer = Reduce { state, action in + switch action { + case .view(let viewAction): + return reduceView(state: &state, action: viewAction) + + case .internal(let internalAction): + return reduceInternal(state: &state, action: internalAction) + + case .response(let responseAction): + return reduceResponse(state: &state, action: responseAction) + + case .presentation(let presentationAction): + return reducePresentation(state: &state, action: presentationAction) + + case .binding: + return .none + + case .delegate: + return .none + } + } + self.init(reducer: reducer) + } +} + +// MARK: - View + +private func reduceView( + state: inout XxxReducer.State, + action: XxxReducer.Action.View +) -> Effect { + switch action { + case .backButtonTapped: + return .send(.delegate(.navigateBack)) + case .onAppear: + return .send(.internal(.fetchItems)) + } +} + +// MARK: - Internal + +private func reduceInternal( + state: inout XxxReducer.State, + action: XxxReducer.Action.Internal +) -> Effect { + switch action { + case .fetchItems: + @Dependency(\.xxxClient) var client + return .run { send in + do { + let items = try await client.fetchItems() + await send(.response(.fetchItemsResponse(.success(items)))) + } catch { + await send(.response(.fetchItemsResponse(.failure(error)))) + } + } + } +} + +// MARK: - Response + +private func reduceResponse( + state: inout XxxReducer.State, + action: XxxReducer.Action.Response +) -> Effect { + switch action { + case .fetchItemsResponse(.success(let items)): + state.data.items = items + state.ui.isLoading = false + return .none + + case .fetchItemsResponse(.failure): + state.ui.isLoading = false + return .send(.presentation(.showToast(.warning(message: "불러오기 실패")))) + } +} + +// MARK: - Presentation + +private func reducePresentation( + state: inout XxxReducer.State, + action: XxxReducer.Action.Presentation +) -> Effect { + switch action { + case .showToast(let toast): + state.presentation.toast = toast + return .none + } +} +``` + +### `@Dependency` 위치 + +- **지연 해석 방식**: 자유 함수 reducer에서는 `@Dependency`를 필요한 `case` 핸들러 내부에 선언할 수 있습니다. + `init` 시점이 아니라 실행 시점에 DI Container에서 꺼내므로, 테스트에서 재정의가 가능합니다. +- 구조체 reducer가 이미 `@Dependency` 프로퍼티를 보유한 경우에는 최신 구현을 우선 보존하고, 네임스페이스 이동만을 위해 의존성 주입 방식을 바꾸지 않습니다. + +```swift +case .logoutTapped: + @Dependency(\.authClient) var authClient // ← 핸들러 내부 선언 + return .run { send in + try await authClient.signOut() + ... + } +``` + +--- + +## 예외 1: Coordinator Reducer + +`HomeCoordinator`, `StatsCoordinator` 등 네비게이션을 담당하는 Coordinator는 State 분리 패턴을 적용하지 않습니다. + +**이유:** +- State가 자식 Reducer의 State(`home: HomeReducer.State`, `stats: StatsReducer.State`)와 라우트 배열로 구성됨 +- 도메인 데이터·UI 플래그가 아니라 화면 조합이 목적이므로 Data/UIState/Presentation 구분이 부자연스러움 +- `scope` 키패스가 바뀌면 자식 연결 코드 전체가 영향을 받음 + +**Action 중첩 패턴은 동일하게 적용합니다.** (View/Delegate 등) + +--- + +## 예외 2: 자체 구현 Reducer (Auth / MainTab / Onboarding) + +Auth / MainTab / Onboarding은 Interface 분리 없이 `Sources`에 구현이 모두 있습니다. +동일한 Action 중첩 패턴을 적용하되, `init(reducer:)` 셸 없이 `body` 내 `Reduce { }` 직접 작성도 허용합니다. +State 분리는 일반 기준(인스턴스 프로퍼티 6개 이상)을 따릅니다. + +--- + +## View에서의 액션 디스패치 + +```swift +// View 이벤트 +store.send(.view(.backButtonTapped)) +store.send(.view(.onAppear)) + +// Reducer 내부 후속 작업 +return .send(.internal(.fetchItems)) +``` + +SwiftUI가 직접 보내는 lifecycle 이벤트(`onAppear`, `onDisappear`)는 `Action.View`에 둡니다. +Reducer가 View 이벤트를 받은 뒤 스스로 발행하는 API 호출 시작, 캐시 갱신, 계산 트리거는 `Action.Internal`에 둡니다. + +--- + +**작성일**: 2026-04-14 diff --git a/docs/Checklists.md b/docs/Checklists.md deleted file mode 100644 index 1adc329b..00000000 --- a/docs/Checklists.md +++ /dev/null @@ -1,34 +0,0 @@ -# Feature 구현 체크리스트 - -## Interface 구현 -Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스트를 강제하지 않습니다. - -- [ ] State struct 정의 (public) -- [ ] State.init() 정의 (public) -- [ ] Action enum 정의 (public) -- [ ] Reducer struct 정의 (public) -- [ ] Client struct 정의 (필요 시) -- [ ] ViewFactory struct 정의 (필요 시) -- [ ] TestDependencyKey 구현 -- [ ] DependencyValues 확장 -- [ ] Client liveValue 구현 -- [ ] ViewFactory liveValue 구현 -- [ ] DocC 문서 작성 - -## Sources 구현 - -- [ ] Reducer.init() 구현 (public) -- [ ] Reducer body 로직 작성 -- [ ] View 구현 (internal) - -## 테스트 -현재 단계에서는 테스트 항목을 적용하지 않습니다. - -- [ ] Reducer 유닛 테스트 -- [ ] Client Mock 구현 -- [ ] Integration 테스트 -- [ ] Preview 작성 (Live, Mock, Error) - ---- - -**작성일**: 2026-01-12 diff --git a/docs/Examples/NavigationStackExample.swift b/docs/Examples/NavigationStackExample.swift index 58d15b21..df3ae6c5 100644 --- a/docs/Examples/NavigationStackExample.swift +++ b/docs/Examples/NavigationStackExample.swift @@ -1,9 +1,17 @@ // MARK: - NavigationStack 패턴 완전한 예제 // 이 파일은 학습용 예제입니다. 실제 프로젝트에서는 Feature 모듈로 분리하세요. +// 프로젝트 canonical [Route] 배열 NavigationStack 패턴을 따릅니다. import ComposableArchitecture import SwiftUI +// MARK: - Route + +enum HomeRoute: Hashable { + case detail + case settings +} + // MARK: - 1️⃣ Home Feature (Root) @Reducer @@ -11,57 +19,69 @@ struct HomeReducer { @ObservableState struct State: Equatable { var items: [Item] = Item.samples - var path = StackState() // ✨ NavigationStack! + var routes: [HomeRoute] = [] + var detail: DetailReducer.State? + var settings: SettingsReducer.State? - // Stack에 들어갈 수 있는 화면들을 Enum으로 정의 - @CasePathable - enum Path: Equatable { - case detail(DetailReducer.State) - case settings(SettingsReducer.State) + mutating func syncChildStatesWithRoutes() { + if !routes.contains(.detail) { + detail = nil + } + if !routes.contains(.settings) { + settings = nil + } } } - enum Action { - case itemTapped(Item) // 항목 클릭 - case settingsButtonTapped // 설정 버튼 클릭 - case path(StackActionOf) // ✨ Stack 액션 (자식 Reducer들의 액션 포함) - - @CasePathable - enum Path { - case detail(DetailReducer.Action) - case settings(SettingsReducer.Action) - } + enum Action: BindableAction { + case binding(BindingAction) + case itemTapped(Item) + case settingsButtonTapped + case detail(DetailReducer.Action) + case settings(SettingsReducer.Action) } var body: some ReducerOf { + BindingReducer() + Reduce { state, action in switch action { + case .binding: + // System back/pop mutates NavigationStack(path:) directly. + // Keep optional child states in sync with the remaining routes. + state.syncChildStatesWithRoutes() + return .none + case .itemTapped(let item): - // ✨ Push: Stack에 Detail 화면 추가 - state.path.append(.detail(DetailReducer.State(item: item))) + state.detail = DetailReducer.State(item: item) + state.routes.append(.detail) return .none case .settingsButtonTapped: - // ✨ Push: Stack에 Settings 화면 추가 - state.path.append(.settings(SettingsReducer.State())) + state.settings = SettingsReducer.State() + state.routes.append(.settings) return .none - case .path(.element(id: _, action: .detail(.settingsButtonTapped))): - // Detail 화면에서 설정 버튼 클릭 → Settings 추가 - state.path.append(.settings(SettingsReducer.State())) + case .detail(.settingsButtonTapped): + state.settings = SettingsReducer.State() + state.routes.append(.settings) return .none - case .path(.element(id: _, action: .settings(.delegate(.logoutRequested)))): - // Settings에서 로그아웃 요청 → 모든 Stack Pop - state.path.removeAll() + case .settings(.delegate(.logoutRequested)): + state.routes.removeAll() + state.syncChildStatesWithRoutes() return .none - case .path: + case .detail, .settings: return .none } } - // ✨ forEach: Stack의 각 화면을 해당 Reducer와 연결 - .forEach(\.path, action: \.path) + .ifLet(\.detail, action: \.detail) { + DetailReducer() + } + .ifLet(\.settings, action: \.settings) { + SettingsReducer() + } } } @@ -69,7 +89,7 @@ struct HomeView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + NavigationStack(path: $store.routes) { List { ForEach(store.items) { item in Button { @@ -92,14 +112,24 @@ struct HomeView: View { Image(systemName: "gearshape") } } - } destination: { store in - // ✨ Stack의 각 케이스에 따라 View 렌더링 - switch store.case { - case .detail(let detailStore): - DetailView(store: detailStore) - - case .settings(let settingsStore): - SettingsView(store: settingsStore) + .navigationDestination(for: HomeRoute.self) { route in + switch route { + case .detail: + if let detailStore = store.scope( + state: \.detail, + action: \.detail + ) { + DetailView(store: detailStore) + } + + case .settings: + if let settingsStore = store.scope( + state: \.settings, + action: \.settings + ) { + SettingsView(store: settingsStore) + } + } } } } @@ -133,7 +163,7 @@ struct DetailReducer { } case .settingsButtonTapped: - // Parent가 처리 (HomeReducer에서 .path 액션으로 받음) + // Parent가 처리 (HomeReducer에서 child action으로 받음) return .none case .detailsResponse(let details): diff --git a/docs/Guides/NetworkGuide.md b/docs/Guides/NetworkGuide.md index c50af6a4..052a5c6e 100644 --- a/docs/Guides/NetworkGuide.md +++ b/docs/Guides/NetworkGuide.md @@ -21,12 +21,20 @@ ### URLSession → TCA Client 변환 과정 ``` -1. Interface에 Client Protocol 정의 -2. Sources에 URLSession 기반 구현 +1. Interface에 struct-based TCA Client 계약 정의 +2. Sources에 URLSession/NetworkProvider 기반 live 구현 3. Reducer에서 @Dependency로 주입 4. Effect로 비동기 호출 ``` +Feature dependency는 **struct-based TCA Client**를 기본으로 사용합니다. 새 Feature Client마다 protocol을 만들지 않습니다. + +Protocol-based client/abstraction은 다음 경우에만 사용합니다. +- 기존 Core protocol이 이미 존재하는 경우 +- 플랫폼 abstraction에 protocol이 필요한 경우 +- legacy integration이 protocol을 요구하는 경우 +- 명시적인 문서/요구사항이 protocol을 요구하는 경우 + ### 왜 Client로 래핑하나? | 항목 | 직접 URLSession 사용 | TCA Client 패턴 | @@ -40,6 +48,8 @@ ## 현재 프로젝트 구조 +아래 `NetworkProviderProtocol`, `Endpoint`는 Core Network의 infrastructure-level protocol입니다. 이는 Feature별 dependency도 protocol로 만들어야 한다는 뜻이 아닙니다. Feature Reducer가 주입받는 dependency는 일반적으로 struct-based TCA Client입니다. + ``` Projects/Core/Network/ ├── Interface/Sources/ @@ -114,8 +124,10 @@ extension NetworkError { ### 전체 구현 예시 +Feature dependency는 아래처럼 `struct`로 정의합니다. 같은 책임의 protocol을 추가로 만들지 않습니다. + ```swift -// 1️⃣ Interface에 Client 정의 +// 1️⃣ Interface에 struct-based TCA Client 정의 public struct PostsClient { public var fetchPosts: @Sendable () async throws -> [Post] public var fetchPost: @Sendable (_ id: Int) async throws -> Post @@ -818,6 +830,10 @@ case .incrementRetryCount: ## 고급: NetworkProvider를 Dependency로 주입 +이 섹션은 Core Network infrastructure를 TCA Dependency로 감싸는 고급 패턴입니다. 기존 `NetworkProviderProtocol` 같은 infrastructure-level protocol을 보존하는 경우에 사용합니다. + +일반적인 Feature dependency는 여전히 struct-based TCA Client를 우선합니다. 새 Feature Client마다 protocol을 만들기 위한 패턴으로 사용하지 않습니다. + NetworkProvider 자체도 Dependency로 주입하여 테스트 시 Mock으로 교체 가능합니다. ### 1. NetworkProvider를 Dependency로 등록 @@ -923,6 +939,7 @@ func testFetchPosts() async throws { - [ ] Endpoint 정의 (baseURL, path, method, headers 등) - [ ] Client struct 정의 (Interface) +- [ ] 새 protocol을 만들지 않았는지 확인 (기존 Core/platform/legacy boundary가 필요한 경우 제외) - [ ] TestDependencyKey 구현 (assertionFailure) - [ ] Live Implementation (NetworkProvider 사용) - [ ] Mock Implementation (Preview/테스트용) @@ -1269,17 +1286,41 @@ struct PostsListView: View { ### 핵심 포인트 -1. **Client 패턴** - 3가지 구현 (live, test, mock)으로 유연성 확보 -2. **Effect 사용** - `.run { send in ... }` 패턴으로 비동기 처리 -3. **에러 처리** - Result 타입으로 성공/실패 분기 -4. **Mock 주입** - `withDependencies`로 Preview 및 테스트에서 Mock 사용 -5. **취소 가능** - `.cancellable(id:)`로 중복 요청 방지 +1. **Struct-based TCA Client** - Feature dependency의 기본 형태 +2. **Client 패턴** - 3가지 구현 (live, test, mock)으로 유연성 확보 +3. **Infrastructure protocol 보존** - 기존 Core/platform/legacy boundary가 있을 때만 protocol 사용 +4. **Effect 사용** - `.run { send in ... }` 패턴으로 비동기 처리 +5. **에러 처리** - Result 타입으로 성공/실패 분기 +6. **Mock 주입** - `withDependencies`로 Preview 및 테스트에서 Mock 사용 +7. **취소 가능** - `.cancellable(id:)`로 중복 요청 방지 + +## 인증 토큰 처리 + +Authorization header가 필요한 요청은 token storage나 Keychain을 직접 읽지 않습니다. + +현재 승인된 패턴: + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` +- `AuthInterceptor`: `Projects/Domain/Auth/Sources/AuthInterceptor.swift` +- `AuthInterceptor`는 `TokenManager`에서 access token을 읽고, refresh flow는 `AuthClient.refreshToken` 경로로 위임합니다. +- App/root wiring은 `Projects/App/Sources/View/TwixApp.swift`에서 live token storage dependency를 설정합니다. + +금지: + +- Feature Client나 Network request code에서 `@Dependency(\.tokenStorage)` 직접 사용 +- `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults를 Authorization header 생성을 위해 직접 읽기 +- Feature Client마다 token refresh logic을 중복 구현 +- owner 승인 없이 새로운 token/header path 도입 + +직접 TokenStorage 사용은 `TokenManager` 내부, Core Storage interface/implementation, App/root dependency wiring, tests/mocks로 제한합니다. 인증 인프라를 추가해야 한다면 `TokenStorage`가 아니라 `TokenManager`에 의존하도록 설계합니다. + +--- ### 다음 단계 - [ ] 실제 프로젝트에 API Client 구현 - [ ] 여러 Endpoint 추가 -- [ ] 인증 토큰 처리 (Authorization Header) +- [ ] 인증 토큰 처리는 `TokenManager` + `AuthInterceptor` 패턴 확인 - [ ] 캐싱 전략 구현 - [ ] 오프라인 대응 diff --git a/docs/QuickStart.md b/docs/QuickStart.md index 697e591d..9d2b5d13 100644 --- a/docs/QuickStart.md +++ b/docs/QuickStart.md @@ -2,6 +2,15 @@ > 10분 만에 TCA 기본 개념을 이해하고 첫 Feature를 만들어봅시다 +이 문서는 TCA와 Feature 구조를 이해하기 위한 **입문용 튜토리얼**입니다. 예제는 설명을 위해 단순화되어 있으므로, production 구현 전에는 canonical docs를 기준으로 검증하세요. + +- Architecture / module boundary: [Architecture/Overview.md](./Architecture/Overview.md) +- Implementation checklist: [Reference/Checklists.md](./Reference/Checklists.md) +- File organization: [Reference/FileOrganization.md](./Reference/FileOrganization.md) +- Naming: [Reference/NamingConventions.md](./Reference/NamingConventions.md) +- Navigation: [Guides/NavigationStack.md](./Guides/NavigationStack.md) +- Network / client patterns: [Guides/NetworkGuide.md](./Guides/NetworkGuide.md) + ## 📋 목차 1. [TCA 핵심 개념 (5분)](#tca-핵심-개념) @@ -35,7 +44,7 @@ struct State: Equatable { **핵심**: - 화면에 표시되는 모든 데이터 -- `Equitable` 준수 필수 +- `Equatable` 준수 필수 - `@ObservableState` 매크로로 SwiftUI 자동 구독 ### 2. Action - 발생 가능한 모든 이벤트 @@ -348,14 +357,16 @@ case .onAppear: ### 실제 프로젝트에서 Feature 만들기 +아래 구조는 개념 설명용 예시입니다. 실제 production 구현에서는 Interface 모듈을 public boundary로 유지하고, 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. 기존 `Interface/Sources/Source.swift`는 legacy/compatibility 패턴으로 남아 있을 수 있습니다. + ``` Projects/Feature/Counter/ -├── Interface/Sources/Source.swift # Public API -├── Sources/CounterReducer.swift # 로직 구현 -└── Sources/CounterView.swift # View (internal) +├── Interface/Sources/CounterReducer.swift # Public API 예시 +├── Sources/CounterReducer.swift # 로직 구현 +└── Sources/CounterView.swift # View (internal) ``` -**Interface/Sources/Source.swift**: +**Interface/Sources/CounterReducer.swift**: ```swift import ComposableArchitecture @@ -413,18 +424,14 @@ extension CounterReducer { ### 📚 더 배우기 1. **아키텍처 이해** - - [아키텍처 개요](../Architecture/Overview.md) - 전체 구조 - - [Reducer 패턴](../Architecture/ReducerPattern.md) - Reducer 심화 - - [Dependency Injection](../Architecture/DependencyInjection.md) - 의존성 주입 + - [아키텍처 개요](./Architecture/Overview.md) - 전체 구조와 module boundary + - [구현 체크리스트](./Reference/Checklists.md) - production 구현 전 확인 항목 + - [파일 구조화 규칙](./Reference/FileOrganization.md) - 파일 분리 및 Interface 파일 정책 + - [네이밍 규칙](./Reference/NamingConventions.md) - Action, File 네이밍 2. **실전 가이드** - - [네트워크 통신](./Guides/NetworkGuide.md) - API 호출 + - [네트워크 통신](./Guides/NetworkGuide.md) - API 호출과 TCA Client 패턴 - [NavigationStack](./Guides/NavigationStack.md) - 화면 전환 - - [테스트 작성](./Guides/Testing.md) - Reducer 테스트 - -3. **예제 분석** - - [Auth Feature](./Examples/Auth.md) - 실제 로그인 Feature - - [MainTab Feature](./Examples/MainTab.md) - 탭 구조 ### 🛠️ 직접 해보기 diff --git a/docs/Reference/Checklists.md b/docs/Reference/Checklists.md index 7ea512f5..346069e6 100644 --- a/docs/Reference/Checklists.md +++ b/docs/Reference/Checklists.md @@ -2,9 +2,36 @@ > Feature를 구현할 때 빠뜨리지 말아야 할 항목들 +이 문서는 Feature 구현 시 사용하는 **canonical checklist**입니다. 중복된 축약 체크리스트 대신 이 문서를 기준으로 확인합니다. + +--- + +## 구현 품질 Gate + +비단순 구현을 시작하기 전에 구조가 팀 아키텍처를 기계적으로 따르는 수준을 넘어, 유지보수 가능한 형태인지 확인합니다. + +- [ ] 변경 코드가 올바른 module / layer / feature에 위치하는가? +- [ ] Interface 모듈은 public boundary로 유지되는가? +- [ ] Sources 모듈의 구현 세부사항이 외부로 새지 않는가? +- [ ] 소비자가 implementation Sources가 아니라 Interface 모듈에 의존하는가? +- [ ] 의존성 방향이 역전되거나 순환 의존성을 만들지 않는가? +- [ ] TCA State / Action / Reducer 소유권이 해당 Feature에 명확히 있는가? +- [ ] Side effect는 Dependency와 Effect를 통해 처리되는가? +- [ ] 토큰 접근이 필요한 경우 `TokenManager`를 사용하고, Feature/Reducer/View/일반 Client에서 `TokenStorage`/Keychain에 직접 접근하지 않았는가? +- [ ] Authorization header 또는 token refresh logic을 중복 구현하지 않았는가? +- [ ] public API는 필요한 최소 범위인가? +- [ ] Feature 간 불필요한 coupling을 만들지 않는가? +- [ ] 동일 책임의 Client / Factory / Route / Model을 중복 생성하지 않았는가? +- [ ] 새로운 architecture pattern 또는 예외가 필요하다면 구현 전에 승인을 받았는가? +- [ ] 동작 또는 아키텍처가 바뀌면 관련 문서 업데이트가 필요한지 확인했는가? + +--- + ## Interface 구현 체크리스트 Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스트를 강제하지 않습니다. +Interface 모듈은 외부 소비자가 의존하는 public boundary입니다. 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. 기존 `Interface/Sources/Source.swift` 파일은 legacy/compatibility 패턴으로 유지할 수 있습니다. + ### Reducer - [ ] `@Reducer` 매크로 추가 @@ -36,6 +63,7 @@ Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스 ### Client (필요 시) - [ ] `public struct {Domain}Client` 정의 +- [ ] Feature dependency는 struct-based TCA Client를 기본으로 사용 - [ ] 메서드 프로퍼티 정의 (`@Sendable` 클로저) - [ ] `public init` 생성자 정의 - [ ] `TestDependencyKey` extension 추가 @@ -61,6 +89,7 @@ Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스 - [ ] `self.init(reducer: Reduce { ... })` 호출 - [ ] 모든 Action에 대한 case 처리 - [ ] State 변경 후 Effect 반환 +- [ ] 비동기/외부 side effect는 Dependency와 Effect를 통해 처리 ### View @@ -92,12 +121,14 @@ Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스 ## 문서화 체크리스트 +상세 DocC 기준은 [ProjectRules.md](./ProjectRules.md)를 따릅니다. + ### DocC 주석 -- [ ] public 타입에 `///` 주석 추가 -- [ ] 간단 설명 (1-2문장) -- [ ] `## 사용 예시` 섹션 추가 -- [ ] 코드 블록 (```swift```) 추가 +- [ ] Interface 계층의 public 타입에 `///` 주석 추가 +- [ ] Shared 모듈은 public 타입에 `///` 주석 추가 +- [ ] public 함수는 `## 사용 예시`와 코드 블록 (```swift```) 추가 +- [ ] enum case, 변수/프로퍼티에는 불필요한 문서화 주석을 추가하지 않음 - [ ] 복잡한 로직은 `## 동작 원리` 섹션 추가 ### 예시 @@ -149,30 +180,41 @@ public struct AuthLoginClient { } --- -## 테스트 체크리스트 -현재 단계에서는 테스트 항목을 적용하지 않습니다. +## 테스트 정책 -### Reducer 테스트 +현재 테스트는 일반 요구사항으로 정착되어 있지 않습니다. 테스트 아키텍처를 임의로 만들지 않습니다. -- [ ] `@Test` 함수 작성 -- [ ] `TestStore` 생성 -- [ ] `withDependencies` Mock 주입 -- [ ] `await store.send(action)` - Action 전송 -- [ ] State 변경 검증 -- [ ] `await store.receive(action)` - Effect 응답 검증 -- [ ] 최종 State 검증 +- [ ] 명시적으로 요청받지 않았다면 새 테스트를 만들지 않음 +- [ ] 테스트 타겟 또는 실행 명령이 없으면 테스트를 실행했다고 주장하지 않음 +- [ ] 위험한 로직 변경은 테스트 계획을 제안 +- [ ] 테스트 부재로 검증이 제한되는 경우 결과 보고에 명시 -### Client Mock +### 향후 테스트 추가 시 참고 항목 -- [ ] `mockSuccess` 구현 -- [ ] `mockFailure` 구현 -- [ ] 다양한 시나리오 Mock (필요 시) +- [ ] Reducer 유닛 테스트 (`TestStore`) +- [ ] Client Mock (`mockSuccess`, `mockFailure` 등) +- [ ] Integration 테스트 +- [ ] Preview 시나리오 (Live, Mock, Error, Loading, Empty) --- -## Tuist 프로젝트 설정 체크리스트 +## Tuist 설정 / 생성 체크리스트 + +Tuist는 setup/generation의 canonical tool입니다. `tuist build`는 사용하지 않습니다. CI/PR 수준 검증은 Fastlane을 사용합니다. + +- [ ] 필요한 경우 `tuist install` 실행 +- [ ] 프로젝트 파일 재생성이 필요한 경우 `tuist generate` 실행 +- [ ] regeneration 문제가 있을 때만 `tuist clean` 고려 +- [ ] CI/PR 수준 검증이 필요한 경우 `bundle exec fastlane ios ci_pr` 실행 +- [ ] Bundler를 사용할 수 없는 경우에만 `fastlane ios ci_pr` 사용 +- [ ] direct `xcodebuild` scheme / destination / configuration을 추측하지 않음 +- [ ] `xcodebuild`는 올바른 scheme / destination / configuration이 문서화되었거나 제공된 경우에만 사용 +- [ ] Fastlane 또는 빌드 명령 실행이 불가능한 경우 검증 제한을 결과 보고에 명시 + 현재 단계에서는 테스트/Testing 타겟 추가를 필수로 보지 않습니다. +> Unresolved: `TestDependencyKey`를 Interface에 두는 현재 패턴은 Testing 모듈 분리 원칙과 충돌할 가능성이 있습니다. 새 패턴을 도입하거나 기존 패턴을 변경하기 전에는 owner 확인이 필요합니다. + ### Project.swift - [ ] `.feature(interface:)` 타겟 정의 @@ -208,8 +250,16 @@ public struct AuthLoginClient { } ### 아키텍처 - [ ] Interface/Sources 분리 올바른가? (예외 Feature: Auth / MainTab / Onboarding 제외) +- [ ] Interface가 public boundary로 유지되는가? +- [ ] Sources의 implementation detail이 외부로 노출되지 않는가? +- [ ] 의존성 방향이 올바른가? +- [ ] 올바른 Feature / module / layer에 배치되었는가? - [ ] public/internal 접근 제어자 올바른가? +- [ ] public API가 최소인가? +- [ ] 기존 Client / Factory / Route / Model과 책임이 중복되지 않는가? - [ ] Dependency 올바르게 주입했는가? +- [ ] 토큰 조회/저장/삭제/refresh-state 전환은 `TokenManager`를 통해 수행되는가? +- [ ] `@Dependency(\.tokenStorage)`, `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults를 Feature/Reducer/View/일반 Client에서 직접 사용하지 않았는가? - [ ] Reducer는 순수 함수인가? (Side Effect 없는가?) ### 네이밍 @@ -239,16 +289,19 @@ public struct AuthLoginClient { } --- -## 배포 전 최종 체크리스트 +## 완료 전 최종 체크리스트 -- [ ] 모든 테스트 통과 -- [ ] SwiftLint 경고 없음 +- [ ] CI/PR 수준 검증이 필요한 경우 `bundle exec fastlane ios ci_pr`를 실제로 실행했는가? +- [ ] Bundler를 사용할 수 없는 경우 `fastlane ios ci_pr`로 검증했는가? +- [ ] Fastlane/빌드/테스트 명령을 실행할 수 없는 경우 검증 제한을 보고했는가? +- [ ] 테스트를 만들거나 실행하지 않은 경우 그렇게 명시했는가? - [ ] 불필요한 로그 제거 - [ ] 주석 처리된 코드 제거 - [ ] TODO 주석 확인 -- [ ] DocC 문서 완성 -- [ ] Example 앱 정상 동작 -- [ ] Preview 모두 정상 렌더링 +- [ ] 필요한 DocC 문서 완성 +- [ ] SwiftLint 경고가 증가하지 않았는지 확인 (Tuist-configured script: `Tuist/ProjectDescriptionHelpers/Scripts/SwiftLintScript.swift`) +- [ ] Example 앱/Preview 확인이 필요한 변경인지 판단했는가? +- [ ] 동작 또는 아키텍처 변경 시 관련 문서 업데이트 필요성을 확인했는가? --- diff --git a/docs/Reference/FileOrganization.md b/docs/Reference/FileOrganization.md index 8c088dc7..f5dd600c 100644 --- a/docs/Reference/FileOrganization.md +++ b/docs/Reference/FileOrganization.md @@ -8,6 +8,8 @@ **기본 규칙**: 하나의 파일에는 하나의 주요 타입만 정의합니다. +Interface 모듈도 예외가 아닙니다. Interface 모듈은 public API를 외부에 노출하는 boundary이지만, 이것이 모든 public 타입을 반드시 하나의 `Source.swift` 파일에 모아야 한다는 뜻은 아닙니다. + **목적**: - 파일 이름만으로 내용 파악 가능 - 코드 탐색 및 유지보수 용이 @@ -74,6 +76,19 @@ struct AppRootReducer { **이유**: TCA 표준 패턴이며, State/Action/Reducer는 하나의 단위로 이해되어야 함 +### 4. Interface 모듈은 public boundary + +**Interface 모듈은 외부 소비자가 의존하는 public boundary입니다.** + +- public reducer/state/action, client, factory, dependency key 등 외부 조립에 필요한 계약을 노출합니다. +- Sources 모듈의 View, live 구현, reducer 세부 로직 등 implementation details를 숨깁니다. +- 소비자는 특별한 예외가 없는 한 implementation Sources가 아니라 Interface 모듈에 의존해야 합니다. +- 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선 적용합니다. +- 기존 `Interface/Sources/Source.swift` 파일은 legacy/compatibility 패턴으로 유지할 수 있습니다. +- 기존 모듈을 수정할 때는 주변 패턴을 따르되, public API가 불명확해지거나 architecture boundary를 약화시키면 One Type Per File로 정리합니다. + +이전 문서의 `Interface/Sources/Source.swift` 예시는 “Interface 모듈을 통해 public interface 타입을 노출한다”는 의미였으며, 모든 public 타입을 하나의 파일에 강제한다는 의미가 아닙니다. + --- ## 🎯 분리 vs 유지 결정 기준 @@ -315,20 +330,22 @@ Projects/Core/Logging/Sources/ └── PulseNetworkLogViewProvider.swift ``` -### 3. Protocol/Implementation 패턴 +### 3. Interface/Implementation 패턴 -이미 적용된 Interface/Implementation 분리: +Interface 모듈은 public boundary, Sources 모듈은 implementation layer입니다. 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. ``` Projects/Core/Network/ ├── Interface/ │ └── Sources/ -│ ├── NetworkProviderProtocol.swift # Protocol 정의 -│ └── NetworkClient.swift # TCA Client +│ ├── NetworkProviderProtocol.swift # public protocol/contract +│ └── NetworkClient.swift # public TCA Client └── Sources/ └── NetworkProvider.swift # 실제 구현 ``` +기존 `Interface/Sources/Source.swift` 파일은 compatibility 목적으로 유지할 수 있지만, 신규 public 타입을 추가할 때는 주변 패턴과 public API 명확성을 함께 고려합니다. + --- ## ⚠️ 주의사항 @@ -422,8 +439,9 @@ import Foundation - [ ] 불필요한 import를 제거했는가? ### 분리 후 -- [ ] `tuist generate` 성공하는가? -- [ ] `tuist build` 성공하는가? +- [ ] 필요한 경우 `tuist generate` 성공하는가? +- [ ] 빌드 검증이 필요한 경우, 알려진 scheme/destination/configuration으로 검증했는가? +- [ ] 빌드 명령을 알 수 없는 경우 검증 제한을 보고했는가? - [ ] 기존 코드가 정상 작동하는가? - [ ] Public API가 변경 없이 작동하는가? - [ ] Git status로 의도한 파일만 변경되었는지 확인했는가? diff --git a/docs/Reference/NamingConventions.md b/docs/Reference/NamingConventions.md index a1bc30d6..8b109c1e 100644 --- a/docs/Reference/NamingConventions.md +++ b/docs/Reference/NamingConventions.md @@ -29,7 +29,7 @@ case optionSelected(Option) // 네트워크 응답 case loginResponse(Result) case postsResponse(Result<[Post], Error>) -case dataResponse(Result) +case dataResponse(Result) // 성공만 필요한 경우 case loginSucceeded(User) @@ -47,43 +47,67 @@ case onBackground --- -## Action 주석 구분 +## Action 중첩 enum 구조 -Action이 많아지면 가독성을 위해 MARK 주석으로 구분합니다. +Action이 많아지면 의미별 중첩 enum으로 분리하여 reducer와 call-site의 가독성을 높입니다. +작은 reducer는 플랫 구조를 유지할 수 있지만, 큰 reducer를 정리할 때는 아래 구조를 우선합니다. ```swift public enum Action: BindableAction { - // MARK: - Binding case binding(BindingAction) - // MARK: - LifeCycle - case onAppear + // MARK: - View (사용자 이벤트) + public enum View: Equatable { + case onAppear + case backButtonTapped + case submitButtonTapped + case itemSelected(Item) + } - // MARK: - User Action - case backButtonTapped - case submitButtonTapped - case itemSelected(Item) + // MARK: - Internal (Reducer 내부 Effect/후속 작업) + public enum Internal: Equatable { + case fetchItems + case updateCache([Item]) + } - // MARK: - Update State - case fetchCompleted([Item]) - case toastDismissed + // MARK: - Response (비동기 응답) + public enum Response { + case fetchItemsResponse(Result<[Item], Error>) + } - // MARK: - Delegate - case delegate(Delegate) + // MARK: - Presentation (토스트, 모달 등) + public enum Presentation: Equatable { + case showToast(TXToastType) + } - // MARK: - Navigation (Coordinator에서 사용) - case path(StackActionOf) + // MARK: - Delegate (부모에게 알림) + public enum Delegate: Equatable { + case navigateBack + case itemSelected(Item) + } + + // MARK: - Child Action (필요시) + case child(ChildReducer.Action) + + case view(View) + case `internal`(Internal) + case response(Response) + case presentation(Presentation) + case delegate(Delegate) } ``` -### 주석 카테고리 -- **Binding**: `BindingAction` 관련 -- **LifeCycle**: `onAppear`, `onDisappear` 등 -- **User Action**: 사용자 인터랙션 (`~Tapped`, `~Changed`, `~Selected`) -- **Update State**: 상태 업데이트 응답 (`~Completed`, `~Dismissed`) -- **Delegate**: 부모에게 전달하는 이벤트 -- **Navigation**: Coordinator의 path 액션 (필요시) -- **Child Action**: 자식 Reducer 액션 (필요시) +### 중첩 enum 카테고리 +- **Binding**: `BindingAction` 관련. TCA case path가 필요하므로 최상위에 둡니다. +- **View**: SwiftUI가 직접 보내는 이벤트. 사용자 인터랙션(`~Tapped`, `~Changed`, `~Selected`)과 `onAppear`/`onDisappear` 같은 lifecycle을 포함합니다. +- **Internal**: Reducer가 스스로 발행하는 Effect 트리거, 캐시 갱신, 상태 계산 등 후속 작업. +- **Response**: 비동기 응답(`~Response(Result)`). `Error` 포함 시 `Equatable`을 강제하지 않습니다. +- **Presentation**: 토스트·모달·시트 표시 이벤트(`showToast`, `showModal` 등). +- **Delegate**: 부모 Reducer에게 전달하는 이벤트. 가능한 한 `Equatable`을 유지합니다. +- **Navigation**: Coordinator의 route/path 변경 액션. 이 프로젝트는 `[Route]` 배열 기반 NavigationStack 패턴을 사용합니다. +- **Child Action**: 자식 Reducer 액션. TCA `Scope`/`ifLet` case path가 안정적으로 동작하도록 기본은 최상위 child case를 유지합니다. + +자세한 reducer 분리 기준과 예외는 [Reducer 패턴](../Architecture/ReducerPattern.md)을 확인하세요. ### Delegate: `delegate(<결과>)` @@ -112,8 +136,16 @@ case notificationReceived(Notification) ### Interface 모듈 +Interface 모듈은 외부 소비자가 의존하는 public boundary입니다. 이전 `Source.swift` 예시는 public interface 타입을 Interface 모듈을 통해 노출한다는 의미였으며, 모든 public 타입을 하나의 파일에 강제한다는 의미가 아닙니다. + +새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. 기존 `Interface/Sources/Source.swift` 파일은 legacy/compatibility 패턴으로 유지할 수 있습니다. + ``` -Interface/Sources/Source.swift # 모든 public 타입 정의 (하나의 파일) +Interface/Sources/{Feature}Reducer.swift # public Reducer / State / Action +Interface/Sources/{Feature}Factory.swift # public ViewFactory 또는 factory +Interface/Sources/{Domain}Client.swift # public TCA Client +Interface/Sources/{Feature}Route.swift # public Route enum (필요 시) +Interface/Sources/Source.swift # 기존 compatibility/re-export 파일 (필요 시) ``` ### Sources 모듈 @@ -142,6 +174,21 @@ Testing/Sources/{Feature}Fixtures.swift # Test Fixtures --- +## 코드 스타일 + +메서드의 매개변수가 2개 이상일 때는 개행하여 가독성을 높입니다. + +```swift +public func example( + a: Int, + b: Int +) -> ReturnType { + // ... +} +``` + +--- + ## 변수/프로퍼티 네이밍 ### State 프로퍼티 @@ -305,7 +352,7 @@ case showError(String) // ✅ 좋은 예 case loginButtonTapped case usernameChanged(String) -case loginResponse(.failure(error)) +case loginResponse(Result.failure(error)) ``` ### ❌ 불명확한 네이밍 @@ -343,6 +390,7 @@ case authResponse 작성한 코드가 다음 규칙을 따르는지 확인하세요: - [ ] Action은 "What happened" 형태로 작성 (사건 중심) +- [ ] 큰 Reducer는 View/Internal/Response/Presentation/Delegate 중첩 구조를 일관되게 사용 - [ ] 사용자 액션은 `Tapped/Changed/Selected` 접미사 사용 - [ ] 시스템 응답은 `Response` 접미사 사용 - [ ] Bool 프로퍼티는 `is/has/should` 접두사 사용 diff --git a/docs/Reference/ProjectRules.md b/docs/Reference/ProjectRules.md new file mode 100644 index 00000000..68050484 --- /dev/null +++ b/docs/Reference/ProjectRules.md @@ -0,0 +1,205 @@ +# 프로젝트 규칙 + +> 팀 아키텍처 결정과 공통 구현 규칙을 정리한 canonical reference입니다. + +상세 구현 체크리스트는 [Checklists.md](./Checklists.md)를, 파일 분리 기준은 [FileOrganization.md](./FileOrganization.md)를, 네이밍 규칙은 [NamingConventions.md](./NamingConventions.md)를 함께 확인하세요. + +--- + +## DocC 문서화 기준 + +대상: Core / Domain / Feature / Shared 모듈의 public API + +### 문서화 대상 + +- Interface 계층의 public 타입(struct, enum, class 등): 간단 설명 필수 +- Shared 모듈은 Interface가 없으므로 public 타입에 한해 문서화 필수 +- public 함수: 사용 예시 코드 작성 필수 + +### 문서화 제외 + +- enum case, 변수/프로퍼티: 문서화 주석 작성 안 함 +- App 계층: internal 타입이므로 문서화 불필요 +- Implementation 계층: public이 아닌 한 문서화 불필요 + +### 적용 원칙 + +- 문서화 제외 항목은 불필요한 주석을 추가하지 않습니다. +- public API 타입/함수의 문서화 누락은 규칙 위반입니다. + +--- + +## Feature 조립 규칙 + +상세 구조는 [Architecture/Overview.md](../Architecture/Overview.md)를 따릅니다. + +- 일반 Feature는 Interface / Sources 분리 구조를 유지합니다. +- 외부 모듈은 일반적으로 Interface 모듈에만 의존합니다. +- Sources 모듈은 View, live 구현, reducer 세부 로직 등 implementation details를 숨깁니다. +- Feature Root 또는 App 조립 계층은 필요한 구현체를 조립하고 dependency를 명시적으로 주입합니다. +- Feature Root에서 타입 재노출이 필요할 경우 public boundary를 해치지 않도록 Interface 타입 재노출을 우선합니다. + +### 예외 Feature + +다음 Feature는 App 직접 조립 예외로 문서화되어 있습니다. + +- Auth +- Onboarding +- MainTab + +이 예외 Feature들은 App에서 `makeView(_:)` 없이 직접 조립할 수 있고, 내부 하위 Feature 조립 시 implementation 모듈을 직접 import 할 수 있습니다. + +--- + +## Reducer 생성 규칙 + +- Interface에는 Reducer의 public signature를 둡니다. +- Implementation에서는 실제 `Reduce`를 구성하는 기본 initializer를 제공합니다. +- 다른 Feature에서 Reducer를 사용할 때는 Interface 타입 의존을 우선합니다. + +```swift +@Reducer +public struct CounterReducer { + public let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + public var body: some ReducerOf { + reducer + } +} +``` + +```swift +extension CounterReducer { + public init() { + self.init(reducer: Reduce { state, action in + // 실제 로직 + return .none + }) + } +} +``` + +--- + +## ViewFactory 도입 기준 + +ViewFactory는 모든 Feature에 강제하지 않습니다. + +- Flow 단위 Feature가 내부에서만 사용되고 외부 재사용 가능성이 낮으면 Root에서 직접 조립할 수 있습니다. +- 다른 화면/Feature에서 재사용될 하위 기능 단위 Feature는 ViewFactory 또는 동등한 factory를 Interface에 정의하고 Sources에서 live 구현을 제공합니다. +- 예외 Feature(Auth / Onboarding / MainTab)는 별도 조립 규칙을 따릅니다. + +--- + +## 의존성 주입 규칙 + +- Struct + closure + TCA Dependency 스타일을 기본으로 사용합니다. +- 모든 모듈은 TCA Dependency Container를 사용합니다. +- Feature 간 또는 Feature/Domain/Core 간 연결은 가능한 한 Interface 모듈만 import합니다. +- `liveValue`는 Implementation 모듈에서 제공합니다. +- 조립은 App 또는 Feature Root에서 `.withDependency`로 명시합니다. +- Implementation 모듈 내부에서 다른 모듈의 의존성을 임의로 조립하지 않습니다. +- Core/Network, Core/Storage는 singleton 직접 접근 대신 TCA Dependency로 주입 가능한 인스턴스형 구조를 사용합니다. + +--- + +## 외부 의존성 참조 규칙 + +서로 다른 계층(Feature / Domain / Core) 간 참조는 Interface만으로 해결 가능한지 먼저 검증합니다. + +- Interface만으로 해결 가능하면 Interface 의존만 사용합니다. +- Interface만으로 불가능한 경우에만 implementation 의존을 검토하고, 불가능한 이유를 문서화합니다. +- 전체 모듈 참조(예: `.domain`, `.core`)로 대체하는 결정은 지양하며, 구조적 필요성이 명확할 때만 허용합니다. + +--- + +## Token 접근 규칙 + +토큰 접근은 현재 `TokenManager` 패턴을 통해 중재합니다. + +현재 코드베이스 기준 위치: + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` +- Token storage interface: `Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift` +- Keychain implementation: `Projects/Core/Storage/Sources/KeychainTokenStorage.swift` +- 현재 Authorization header 처리 패턴: `Projects/Domain/Auth/Sources/AuthInterceptor.swift`가 `TokenManager`를 사용 +- 현재 App/root wiring: `Projects/App/Sources/View/TwixApp.swift`에서 live token storage dependency 설정 + +금지: + +- Feature / Reducer / View / 일반 Client에서 `@Dependency(\.tokenStorage)` 직접 사용 +- Feature / Reducer / View / 일반 Client / request-building code에서 `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults 등 token persistence 직접 접근 +- Authorization header 구성을 위해 storage를 직접 읽기 +- Feature client에서 token refresh logic 중복 구현 +- owner 승인 없이 새로운 token/header path 도입 + +허용: + +- `TokenManager` 내부 +- Core Storage interface/implementation +- App/root dependency wiring +- tests/mocks +- `AuthInterceptor`처럼 `TokenManager`에 의존하는 승인된 auth infrastructure + +위 경로는 현재 코드베이스 기준입니다. `TokenManager`의 장기적 모듈 위치를 고정하는 의미는 아닙니다. + +--- + +## SwiftLint 규칙 + +SwiftLint 경고를 가능한 한 최소화합니다. + +- 새로운 코드에서는 SwiftLint 경고가 발생하지 않도록 작성합니다. +- 변경으로 인해 경고가 증가하지 않도록 합니다. +- 불가피한 경우에만 제한적으로 `swiftlint:disable`을 사용하고, 범위를 최소화합니다. +- SwiftLint 실행은 Tuist에 설정된 script를 따릅니다: `Tuist/ProjectDescriptionHelpers/Scripts/SwiftLintScript.swift` +- 별도 standalone SwiftLint 명령을 임의로 만들지 않습니다. + +--- + +## 코드 스타일 규칙 + +메서드의 매개변수가 2개 이상일 때는 개행하여 가독성을 높입니다. + +```swift +public func example( + a: Int, + b: Int +) -> ReturnType { + // ... +} +``` + +--- + +## 검증 정책 + +Tuist는 setup/generation 용도로 사용합니다. + +```bash +tuist install +tuist generate +tuist clean +``` + +- `tuist clean`은 regeneration cleanup이 필요할 때만 사용합니다. +- `tuist build`는 표준 검증 명령이 아닙니다. +- CI/PR 수준 검증은 `bundle exec fastlane ios ci_pr`를 우선 사용합니다. +- Bundler를 사용할 수 없는 경우에만 `fastlane ios ci_pr`를 사용합니다. +- direct `xcodebuild`는 scheme / destination / configuration이 명시적으로 문서화되었거나 제공된 direct-xcodebuild-specific task에서만 사용합니다. +- direct `xcodebuild` 값을 추측하지 않습니다. +- 검증을 실행할 수 없는 경우 결과 보고에 검증 한계를 명시합니다. + +--- + +## Unresolved + +### TestDependencyKey와 Testing 모듈 분리 + +Interface에 `TestDependencyKey`를 두는 현재 패턴은 MFA의 Testing 모듈 분리 원칙과 충돌할 가능성이 있습니다. + +현재 문서와 예제는 `TestDependencyKey`를 Interface에 두는 패턴을 포함하지만, 장기적으로 팀 합의에 따라 Testing 모듈로 대체할 수 있습니다. 새 패턴을 도입하거나 기존 패턴을 변경하기 전에는 owner 확인이 필요합니다. diff --git a/docs/perf-infra/README.md b/docs/perf-infra/README.md new file mode 100644 index 00000000..9bf95f3f --- /dev/null +++ b/docs/perf-infra/README.md @@ -0,0 +1,105 @@ +# UI Rendering Perf Infrastructure + +이 문서는 Feature Example 앱에 seed 기반 UITest와 `xctrace` 측정을 추가하기 위한 공통 인프라 사용법입니다. 현재 범위는 smoke UITest와 Time Profiler launch 검증까지이며, 실제 `measure(metrics:)` 성능 시나리오는 다음 작업에서 추가합니다. + +## Targets + +측정 대상 Example 앱: + +| Feature | Example scheme | Bundle ID | UITest target | +| --- | --- | --- | --- | +| Auth | `FeatureAuthExample` | `org.yapp.twix.example.auth` | `FeatureAuthExampleUITests` | +| GoalDetail | `FeatureGoalDetailExample` | `org.yapp.twix.example.goal-detail` | `FeatureGoalDetailExampleUITests` | +| Home | `FeatureHomeExample` | `org.yapp.twix.example.home` | `FeatureHomeExampleUITests` | +| MainTab | `FeatureMainTabExample` | `org.yapp.twix.example.main-tab` | `FeatureMainTabExampleUITests` | +| MakeGoal | `FeatureMakeGoalExample` | `org.yapp.twix.example.make-goal` | `FeatureMakeGoalExampleUITests` | +| Notification | `FeatureNotificationExample` | `org.yapp.twix.example.notification` | `FeatureNotificationExampleUITests` | +| Onboarding | `FeatureOnboardingExample` | `org.yapp.twix.example.onboarding` | `FeatureOnboardingExampleUITests` | +| ProofPhoto | `FeatureProofPhotoExample` | `org.yapp.twix.example.proof-photo` | `FeatureProofPhotoExampleUITests` | +| Settings | `FeatureSettingsExample` | `org.yapp.twix.example.settings` | `FeatureSettingsExampleUITests` | +| Stats | `FeatureStatsExample` | `org.yapp.twix.example.stats` | `FeatureStatsExampleUITests` | + +`Common`은 Example target이 없는 의도된 예외입니다. + +## Launch Contract + +Example 앱은 `SharedPerfTestingSupport.UITestMode`를 통해 다음 launch arguments를 읽습니다. + +```text +-UITEST +-UITEST_SEED +-UITEST_DISABLE_ANIMATIONS +-UITEST_WAIT_READY +``` + +공통 helper: + +```swift +let app = XCUIApplication.launchForPerf(seed: "default") +waitForFeatureReady("home") +``` + +Seed별 fixture가 필요한 경우: + +- Example 앱 내부에서 `UITestMode.seedName`을 switch합니다. +- 기존 `Testing` 모듈이 있는 Feature는 `Feature...Testing`에 seed 이름 또는 mock helper를 추가합니다. +- Testing 모듈이 없는 Auth, MainTab, Notification, Settings는 새 Testing 모듈을 만들지 말고 Example target 내부 fixture를 사용합니다. + +## Accessibility Contract + +식별자 형식: + +```text +feature..root +feature..feed +feature..cell. +feature.. +feature..ready +``` + +현재 smoke UITest는 Feature당 정확히 하나이며 `feature..ready`만 기다립니다. 성능 시나리오를 추가할 때 화면별 feed, cell, control identifier를 확장합니다. + +## Build Configuration + +Tuist 모듈 프로젝트는 `Debug`, `Release`, `Profile`, `PerfProfile` configuration을 생성합니다. `Profile`과 `PerfProfile`은 Release 계열이며 Time Profiler 분석을 위해 다음 값을 유지합니다. + +```text +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +COPY_PHASE_STRIP = NO +STRIP_INSTALLED_PRODUCT = NO +``` + +`PerfProfile`은 Pass 3/Pass 4 perf UITest와 xctrace 측정 전용입니다. `Profile`과 동일한 Release-like 설정에 `SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PERF_TESTING`만 추가합니다. `SharedPerfTestingSupport`의 accessibility marker / counter modifier는 `PERF_TESTING`이 켜진 빌드에서만 실제 marker를 붙이고, 일반 `Profile`/`Release`에서는 no-op입니다. + +Production 앱 signing은 기존 manual/match 설정을 유지합니다. Example 앱과 Example UITest target만 automatic signing을 사용합니다. + +## Verification Commands + +먼저 프로젝트를 생성합니다. + +```bash +tuist generate +``` + +이 작업은 direct `xcodebuild`를 사용하는 perf infra 검증이므로 scheme/configuration/destination 형식을 명시합니다. 실기기 destination 값은 로컬 기기에 맞게 지정해야 합니다. + +```bash +xcodebuild test \ + -scheme FeatureHomeExample \ + -configuration PerfProfile \ + -destination 'platform=iOS,name=' \ + -only-testing:FeatureHomeExampleUITests +``` + +전체 Example의 Time Profiler launch smoke는 실기기에서만 실행합니다. + +```bash +DEVICE_NAME='' Scripts/verify-perf-targets.sh +``` + +SwiftUI template 기반 profiling은 simulator에서 신뢰할 수 없으므로 실기기 검증만 지원합니다. + +## Known Issues + +- `FeatureOnboardingExample`은 entitlements를 사용합니다. Automatic signing에서 associated domains 등 provisioning 누락이 발생하면 target, 누락 entitlement, Xcode signing error를 이 문서에 추가하고 owner가 Apple Developer portal capability를 확인해야 합니다. +- 이 인프라는 launch/readiness smoke만 제공합니다. 실제 render scenario, baseline/after 비교, 리포트 생성, 성능 개선은 별도 작업입니다. diff --git a/docs/perf-infra/inventory.md b/docs/perf-infra/inventory.md new file mode 100644 index 00000000..7744f573 --- /dev/null +++ b/docs/perf-infra/inventory.md @@ -0,0 +1,18 @@ +# UI Rendering Perf Infrastructure Inventory + +작성일: 2026-05-17 + +| Feature | Example 존재 | 현재 Bundle ID | 측정 대상 | 비고 | +| --- | --- | --- | --- | --- | +| Auth | 있음 | `org.yapp.twix.example.auth` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| Common | 없음(의도) | N/A | 아니오 | 매니페스트에 Example target 없음 | +| GoalDetail | 있음 | `org.yapp.twix.example.goal-detail` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| Home | 있음 | `org.yapp.twix.example.home` | 예 | placeholder Example을 실제 `HomeCoordinatorView` wiring으로 교체 | +| MainTab | 있음 | `org.yapp.twix.example.main-tab` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| MakeGoal | 있음 | `org.yapp.twix.example.make-goal` | 예 | placeholder Example을 실제 `MakeGoalView` wiring으로 교체 | +| Notification | 있음 | `org.yapp.twix.example.notification` | 예 | `Date()` 기반 mock을 deterministic reference date로 교체 | +| Onboarding | 있음 | `org.yapp.twix.example.onboarding` | 예 | entitlements/provisioning 검증 필요 | +| ProofPhoto | 있음 | `org.yapp.twix.example.proof-photo` | 예 | placeholder Example을 실제 `ProofPhotoView` wiring으로 교체 | +| Settings | 있음 | `org.yapp.twix.example.settings` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| Stats | 있음 | `org.yapp.twix.example.stats` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | + diff --git a/docs/perf-infra/reports/2026-05-18-render-pass-3.md b/docs/perf-infra/reports/2026-05-18-render-pass-3.md new file mode 100644 index 00000000..7313c8f5 --- /dev/null +++ b/docs/perf-infra/reports/2026-05-18-render-pass-3.md @@ -0,0 +1,418 @@ +# Pass 3 — UI Rendering 성능 측정 + 정리 + +- **작성일**: 2026-05-18 +- **대상 브랜치 / HEAD**: `766a6c3` (Phase F 진입 직전) +- **Baseline tag**: `pass3-rendering-before` = `af07cc4` +- **Authoritative metric**: Xcode Instruments / xctrace (Time Profiler + Animation Hitches), iOS 26.4.2 device (Jiyong의 iPhone, UDID `00008110-00096DC42632801E`), Profile configuration +- **Probe metric (보조)**: XCTest XCUI driver — driver/marker sanity 신호 (개선 evidence 아님) + +이 리포트는 Pass 3 의 측정 인프라 구축 → 공식 baseline 수집 → 두 개의 +fix commit 적용 → 한 개의 commit 사전 조사 후 skip → 결정 정리까지의 +전 과정을 한 문서로 통합한다. 상세 workspace 문서는 +`docs/perf-infra/reports/_workspace/` 에 보존. + +--- + +## 1. Executive summary + +| 항목 | 결과 | +|---|---| +| 측정 인프라 (probe + rendering scenario) | 구축 완료 (Commit 1 `f8456e8`, Commit 2 `97ffee7`, Commit 3 driver/reclass `24e93f6`/`ba8b790`) | +| Rendering scenario (Home / GoalDetail / ProofPhoto / Stats × 2씩) | 8 시나리오 × 2 template × 3 rep = **48 traces** 공식 baseline 수집 완료 (contam 0 / 48 official + 일부 재수집 후 0 잔류) | +| Commit 3 (Home read-set split) | **KEEP** — 측정상 noise floor 내, structural cleanup 으로 유지. rendering 개선 evidence 로 인용하지 않음 | +| Commit 7 (GoalDetail TimelineView idle guard) | **KEEP** — initial 시나리오 top-1 user-code frame (`TimelineView<>.UpdateFilter.updateValue()` 9–12ms 일관) 3/3 rep 에서 제거. idle CPU / 배터리 카테고리로 분류 | +| Commit 6 재정의 (Home card rendering / GoalCardView input stability) | **investigated and skipped** — measurable candidate 없음 | +| Commit 4 (destination/presentation scoping) | **skip/hold** — Commit 3 의 `HomePresentationLayer` 가 흡수, attribution 악화 우려 | +| Commit 5 (`goalSectionTitle` / `nowDate` stored derivation) | **stand-by/skip** — baseline top-frame 미등장 | +| Cold launch 추가 최적화 | Pass 2 결론 유지 — 95%+ 가 dyld + UIScene + SwiftUI framework 시스템 한계, **타겟 아님** | + +**한 줄 요약**: Pass 3 의 권위 있는 측정으로 본 Twix iOS 의 UI Rendering 비용은 +**UIKit + SwiftUI framework 코드가 압도적으로 점유**하고 있다. user-code 가 +top-frame 에 잡힌 단 한 경우 (GoalDetail idle TimelineView) 는 Commit 7 로 +제거했다. 나머지 후보들은 측정 가능한 user-code 핫스팟이 없어 본 Pass 의 +범위에서 진행하지 않았다. + +--- + +## 2. Scope and rules + +### 2.1 What was measured + +| 영역 | Scheme | 시나리오 | seed | +|---|---|---|---| +| Home | `FeatureHomeExample` | feed scroll (`testRendering_homeHeavyFeedScroll`, 25↑/25↓ drag) | `home-heavy` (200 cells) | +| Home | `FeatureHomeExample` | calendar week sweep (`testRendering_homeHeavyCalendarWeekSweep`) | `home-heavy` | +| GoalDetail | `FeatureGoalDetailExample` | initial render (`testRendering_goalDetailInitialRender`) | default | +| GoalDetail | `FeatureGoalDetailExample` | reaction rapid-fire (`testRendering_goalDetailReactionRapidFire`) | default | +| ProofPhoto | `FeatureProofPhotoExample` | preview with fixture image (`testRendering_proofPhotoPreviewWithFixtureImage`) | `proof-photo-prefilled` | +| ProofPhoto | `FeatureProofPhotoExample` | comment typing (`testRendering_proofPhotoCommentTyping`) | `proof-photo-prefilled` | +| Stats | `FeatureStatsExample` | heavy initial (`testRendering_statsHeavyInitialRender`) | `stats-heavy` (200 cells) | +| Stats | `FeatureStatsExample` | heavy scroll (`testRendering_statsHeavyScroll`) | `stats-heavy` | + +### 2.2 What was NOT measured / out of scope + +- **Auth, Onboarding** — 현재 VoC 우선순위 아님. +- **SwiftUI template trace** — `xctrace --launch` + UITest 동시 attach + 파이프라인 부재 (attach 모드에서 "no SwiftUI data") → **Phase 2 follow-up**. +- **ProofPhoto 실제 PHPicker / 카메라 권한 flow** — OS picker 측정 자동화 + 미구현, fixture-image inject 시나리오로 우회 → **Phase 2 follow-up**. +- **GoalDetail photo-log scrollable** — 현재 detail view 에 ScrollView 가 + 없어 scroll rendering 시나리오 자체 불가 → view 구조 변경 시 **Phase 2**. +- **Stats `StatsDetailView` dateCellBackground 정리** — code-quality 정리 + 카테고리 → **Phase 2 cleanup**. +- **Settings nickname delayed transition** — 단일 Text update 는 rendering + signal 작음, loading-transition probe 분류로 **Phase 1.5 보류**. +- **Production app full path** — dedicated rendering scenarios 에 한정. + 실제 사용 환경의 fetch + scroll 혼합 패턴은 별도 시나리오 필요. +- **Pass 4 configuration note** — 후속 perf run 은 `PerfProfile` + configuration 을 사용한다. `PerfProfile` 은 Pass 3 의 `Profile` + 측정 조건과 동일한 Release-like symbol/strip 설정을 유지하면서 + `PERF_TESTING` compile condition 만 추가해 accessibility marker 를 + 일반 `Profile`/`Release` 빌드와 격리한다. + +### 2.3 측정 규칙 (Pass 3 합의) + +- **Authoritative metric = 실디바이스 xctrace trace.** XCTest pass/fail 은 + correctness 만, timing 은 driver/marker sanity 신호. +- **44pt PERF action harness 는 `-UITEST_PROBE_SCENARIO` 활성 시에만 활성화.** + Rendering scenario / smoke / 일반 UITest 에서는 비활성 — production layout + 보존. +- **`PerfRebuildProxyPing` 은 proxy.** 정확한 body evaluation count 아님. +- **Noise floor ≈ ±10.4%** (rep-to-rep total time). 단일 frame 도 ~15% + 미만은 decisive evidence 로 사용하지 않음. + +--- + +## 3. Measurement infrastructure (Phase B/C/D 요약) + +### 3.1 코드 신설/이동 + +- `Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift` — `PerfCounters` + `PerfRebuildProxyPing` +- `Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift` — `perfStateMarker`, `perfCounterMarkers`, `perfControl`, `perfCell`, `perfFeed`, `perfReadyMarker`, `perfRoot` +- `Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift` — `isEnabled` / `isProbeScenario` / `isRenderingScenario` static let 캐싱 +- `Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift` — `measureActionLatency`, `awaitPerfMarker`, `readPerfCounter` +- `Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift` — `PerfScenarioKind { probe, rendering }` +- `Projects/Feature/Home/Sources/Home/HomeView.swift` — `HomePerfActionHarness`, `PerfToastPresentationHarness` (probe 시만 활성) +- 8 시나리오용 RenderingTests (Home / GoalDetail / ProofPhoto / Stats) + +### 3.2 시나리오 분류 + +| 분류 | 정의 | 권위성 | +|---|---|---| +| **Rendering scenario** | xctrace recording 중 실행될 실제 UI 조작. UITest 는 deterministic driver | **authoritative** | +| **Probe scenario** | UITest driver/marker/counter/harness 의 sanity 점검 | **probe-only** | +| **Smoke test** | Example app launch / identifier 동작 확인 | 성능 해석 대상 아님 | + +### 3.3 contamination 기준 + +다음 중 하나라도 발견되면 trace 폐기 + 동일 rep 재수집: + +1. SpringBoard activation log (`Activate org.yapp.twix.example.*` 또는 반복 + `Open …`) +2. `Wait for com.apple.springboard to idle` 가 driver action phase 도중 + 발생 +3. BannerNotification interrupt (`Interrupting element BannerNotification …`) +4. wall time 이 baseline 의 ±50% 초과 +5. (ProofPhoto comment typing 한정) `feature.proof-photo.marker.comment-text.abcde` + marker 미도달 +6. xctrace 자체 에러 (`Target app exited mid-window` 등) + +--- + +## 4. Baseline (Phase E-batch1~4) + +### 4.1 수집 결과 (공식 48 traces) + +| Feature | template × scenario × reps | contam | discards | 비고 | +|---|---:|---:|---:|---| +| Home | 2 × 2 × 3 = 12 | 0 | 0 | feed-scroll 60s window, calendar-sweep 50s window | +| GoalDetail | 2 × 2 × 3 = 12 | 0 | 0 | initial 8s window, reaction 25s window | +| ProofPhoto | 2 × 2 × 3 = 12 | 0 | 0 | preview 8s window, comment-typing 12s window | +| Stats | 2 × 2 × 3 = 12 | 0 | 0 | initial 8s window, heavy-scroll 60s window | +| **Total** | **48** | **0** | **0** | trace root: `/tmp/twix-perf-traces/pass3-official-before///