diff --git a/.github/workflows/project-status-sync.yml b/.github/workflows/project-status-sync.yml new file mode 100644 index 0000000..f1c9edd --- /dev/null +++ b/.github/workflows/project-status-sync.yml @@ -0,0 +1,305 @@ +name: Sync Project Status from Issue Flow + +on: + issues: + types: [opened] + create: + push: + pull_request: + types: [opened, reopened, ready_for_review] + +permissions: + contents: read + +jobs: + sync-status: + runs-on: ubuntu-latest + + steps: + - name: Sync issue status in project + uses: actions/github-script@v7 + env: + PROJECT_OWNER: mastarTrack + PROJECT_NUMBER: "7" + + STATUS_BACKLOG: Backlog + STATUS_READY: Ready + STATUS_IN_PROGRESS: In progress + STATUS_IN_REVIEW: In review + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + + const PROJECT_OWNER = process.env.PROJECT_OWNER; + const PROJECT_NUMBER = Number(process.env.PROJECT_NUMBER); + + const STATUS = { + backlog: process.env.STATUS_BACKLOG || "Backlog", + ready: process.env.STATUS_READY || "Ready", + inProgress: process.env.STATUS_IN_PROGRESS || "In Progress", + inReview: process.env.STATUS_IN_REVIEW || "In Review", + }; + + let cachedProject = null; + let cachedStatusField = null; + + function uniqueNumbers(values) { + return [...new Set(values.filter(Boolean).map(Number))]; + } + + function extractIssueNumbersFromBranch(branchName) { + if (!branchName) return []; + const matches = [...branchName.matchAll(/#(\d+)/g)]; + return uniqueNumbers(matches.map(m => m[1])); + } + + function extractIssueNumbersFromRefText(text) { + if (!text) return []; + const matches = [ + ...text.matchAll(/(?:^|\s)refs?\s+#(\d+)\b/gi), + ...text.matchAll(/(?:^|\s)(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)\b/gi), + ]; + return uniqueNumbers(matches.map(m => m[1])); + } + + function extractIssueNumbersFromPR(pr) { + const fromBranch = extractIssueNumbersFromBranch(pr?.head?.ref || ""); + const fromText = extractIssueNumbersFromRefText( + `${pr?.title || ""}\n${pr?.body || ""}` + ); + return uniqueNumbers([...fromBranch, ...fromText]); + } + + async function getProject() { + if (cachedProject) return cachedProject; + + const data = await github.graphql( + ` + query($projectOwner: String!, $projectNumber: Int!) { + organization(login: $projectOwner) { + projectV2(number: $projectNumber) { + id + number + fields(first: 100) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + user(login: $projectOwner) { + projectV2(number: $projectNumber) { + id + number + fields(first: 100) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + `, + { + projectOwner: PROJECT_OWNER, + projectNumber: PROJECT_NUMBER, + } + ); + + cachedProject = data.organization?.projectV2 ?? data.user?.projectV2; + + if (!cachedProject) { + throw new Error(`Project not found: ${PROJECT_OWNER} / #${PROJECT_NUMBER}`); + } + + return cachedProject; + } + + async function getStatusField() { + if (cachedStatusField) return cachedStatusField; + + const project = await getProject(); + const statusField = project.fields.nodes.find( + field => field && field.name === "Status" + ); + + if (!statusField) { + throw new Error('Status field not found in the project.'); + } + + cachedStatusField = statusField; + return cachedStatusField; + } + + async function getIssueAndItem(issueNumber) { + const data = await github.graphql( + ` + query($owner: String!, $repo: String!, $issueNumber: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issueNumber) { + id + number + projectItems(first: 100) { + nodes { + id + project { + ... on ProjectV2 { + id + number + } + } + } + } + } + } + } + `, + { + owner: repoOwner, + repo: repoName, + issueNumber, + } + ); + + return data.repository?.issue ?? null; + } + + async function ensureProjectItem(issueNumber) { + const project = await getProject(); + const issue = await getIssueAndItem(issueNumber); + + if (!issue) { + core.warning(`Issue #${issueNumber} not found in ${repoOwner}/${repoName}`); + return null; + } + + let itemId = issue.projectItems.nodes.find( + node => node.project?.number === PROJECT_NUMBER + )?.id; + + if (!itemId) { + const added = await github.graphql( + ` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { + id + } + } + } + `, + { + projectId: project.id, + contentId: issue.id, + } + ); + + itemId = added.addProjectV2ItemById.item.id; + } + + return itemId; + } + + async function moveIssueToStatus(issueNumber, statusName) { + const project = await getProject(); + const statusField = await getStatusField(); + + const option = statusField.options.find(opt => opt.name === statusName); + if (!option) { + throw new Error(`Status option "${statusName}" not found.`); + } + + const itemId = await ensureProjectItem(issueNumber); + if (!itemId) return; + + await github.graphql( + ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + } + `, + { + projectId: project.id, + itemId, + fieldId: statusField.id, + optionId: option.id, + } + ); + + core.info(`Moved issue #${issueNumber} -> ${statusName}`); + } + + async function run() { + const eventName = context.eventName; + const action = context.payload.action; + let issueNumbers = []; + let targetStatus = null; + + if (eventName === "issues" && action === "opened") { + issueNumbers = [context.payload.issue.number]; + targetStatus = STATUS.backlog; + } + + if (eventName === "create" && context.payload.ref_type === "branch") { + issueNumbers = extractIssueNumbersFromBranch(context.payload.ref || ""); + targetStatus = STATUS.ready; + } + + if (eventName === "push") { + const commits = context.payload.commits || []; + issueNumbers = uniqueNumbers( + commits.flatMap(commit => extractIssueNumbersFromRefText(commit.message || "")) + ); + targetStatus = STATUS.inProgress; + } + + if (eventName === "pull_request") { + const pr = context.payload.pull_request; + + if ((action === "opened" || action === "reopened") && pr.draft) { + core.info("Draft PR detected. Waiting for ready_for_review."); + return; + } + + issueNumbers = extractIssueNumbersFromPR(pr); + targetStatus = STATUS.inReview; + } + + if (!targetStatus || issueNumbers.length === 0) { + core.info("No matching issue numbers or target status."); + return; + } + + for (const issueNumber of issueNumbers) { + await moveIssueToStatus(issueNumber, targetStatus); + } + } + + await run(); diff --git a/README.md b/README.md index 62a5688..3da44c8 100644 --- a/README.md +++ b/README.md @@ -1 +1,283 @@ -# RocketCall +# ๐Ÿš€ RocketCall + +> ์šฐ์ฃผ ํ…Œ๋งˆ๋ฅผ ์ ์šฉํ•œ ์‹œ๊ฐ„ ๊ด€๋ฆฌ iOS ์•ฑ +> ์•Œ๋žŒ, ํƒ€์ด๋จธ(๋ฏธ์…˜), ์Šคํ†ฑ์›Œ์น˜(์ž์œ  ํ•ญํ–‰) ๊ธฐ๋Šฅ์„ ํ†ตํ•ด ์‹œ๊ฐ„์„ ๋” ๋ชฐ์ž…๊ฐ ์žˆ๊ฒŒ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก ๋•๋Š” ์•ฑ + +
+ +## โœจ ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ + +**RocketCall**์€ ๋‹จ์กฐ๋กœ์šด ๊ธฐ๋ณธ ์‹œ๊ณ„ ์•ฑ์˜ ๊ฒฝํ—˜์—์„œ ๋ฒ—์–ด๋‚˜, +**์šฐ์ฃผ ํ•ญํ–‰**์ด๋ผ๋Š” ์ปจ์…‰์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์‹œ๊ฐ„์„ ๋” ์žฌ๋ฏธ์žˆ๊ณ  ์ง๊ด€์ ์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ธฐํšํ•œ ์•ฑ์ž…๋‹ˆ๋‹ค. + +์‚ฌ์šฉ์ž๋Š” ๋‹จ์ˆœํžˆ ์‹œ๊ฐ„์„ ์žฌ๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ, +ํ•˜๋‚˜์˜ ๋ฏธ์…˜์„ ์ˆ˜ํ–‰ํ•˜๋“ฏ ์•Œ๋žŒ์„ ์„ค์ •ํ•˜๊ณ , ํƒ€์ด๋จธ๋ฅผ ์ง„ํ–‰ํ•˜๊ณ , ์ž์œ ๋กญ๊ฒŒ ํ•ญํ–‰ํ•˜๋“ฏ ์Šคํ†ฑ์›Œ์น˜๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- **ํ”„๋กœ์ ํŠธ๋ช…**: RocketCall +- **ํŒ€๋ช…**: Alarara +- **๊ฐœ๋ฐœ ๊ธฐ๊ฐ„**: 2026.03.20 ~ 2026.03.30 + +
+ +## ๐ŸŽฏ ๊ธฐํš ๋ฐฐ๊ฒฝ + +๊ธฐ๋ณธ ์•Œ๋žŒ/ํƒ€์ด๋จธ ์•ฑ์€ ํ•„์ˆ˜ ๊ธฐ๋Šฅ์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ, +์‚ฌ์šฉ์ž๊ฐ€ ์‹œ๊ฐ„์„ **๋ชฐ์ž…๊ฐ ์žˆ๊ฒŒ ๊ฒฝํ—˜ํ•˜๋„๋ก ๋•๋Š” ์š”์†Œ**๋Š” ๋ถ€์กฑํ•˜๋‹ค๊ณ  ๋А๊ผˆ์Šต๋‹ˆ๋‹ค. + +RocketCall์€ ์ด ์ง€์ ์—์„œ ์ถœ๋ฐœํ–ˆ์Šต๋‹ˆ๋‹ค. + +- ์‹œ๊ฐ„ ๊ด€๋ฆฌ๋ฅผ ๋” ์žฌ๋ฏธ์žˆ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์—†์„๊นŒ? +- ๋ฝ€๋ชจ๋„๋กœ, ์•Œ๋žŒ, ์Šคํ†ฑ์›Œ์น˜๋ฅผ ํ•˜๋‚˜์˜ ํ†ต์ผ๋œ ์ปจ์…‰์œผ๋กœ ๋ฌถ์„ ์ˆ˜ ์—†์„๊นŒ? +- ๋‹จ์ˆœํ•œ ๊ธฐ๋Šฅ ์•ฑ์ด ์•„๋‹ˆ๋ผ, ์‚ฌ์šฉ์ž๊ฐ€ ์ƒํƒœ์™€ ํ๋ฆ„์„ ์ฒด๊ฐํ•  ์ˆ˜ ์žˆ๋Š” ์•ฑ์„ ๋งŒ๋“ค ์ˆ˜ ์—†์„๊นŒ? + +์ด ๊ณ ๋ฏผ์„ ๋ฐ”ํƒ•์œผ๋กœ **โ€œ์‹œ๊ฐ„ = ์šฐ์ฃผ ํ•ญํ–‰โ€** ์ด๋ผ๋Š” ์ปจ์…‰์„ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. + +
+ +## ๐ŸŒŒ ํ•ต์‹ฌ ์ปจ์…‰ + +> **์‹œ๊ฐ„ = ์šฐ์ฃผ ํ•ญํ–‰** + +RocketCall์€ ์‹œ๊ฐ„์„ ํ•˜๋‚˜์˜ ์šฐ์ฃผ ๋ฏธ์…˜์ฒ˜๋Ÿผ ํ•ด์„ํ•ฉ๋‹ˆ๋‹ค. + +- **์•Œ๋žŒ**: ๋ฐœ์‚ฌ ์นด์šดํŠธ๋‹ค์šด +- **ํƒ€์ด๋จธ**: ๊ณ„ํš๋œ ๋ฏธ์…˜ ์ˆ˜ํ–‰ +- **์Šคํ†ฑ์›Œ์น˜**: ์ž์œ  ํ•ญํ–‰ +- **ํ™ˆ ํ™”๋ฉด**: ๊ธฐ๋ก๊ณผ ํ˜„์žฌ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜๋Š” ๊ด€์ œ ์„ผํ„ฐ + +์ด๋ ‡๊ฒŒ ๊ฐ๊ฐ์˜ ๊ธฐ๋Šฅ์„ ํ•˜๋‚˜์˜ ์„ธ๊ณ„๊ด€ ์•ˆ์—์„œ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์—ฐ๊ฒฐํ•˜๊ณ ์ž ํ–ˆ์Šต๋‹ˆ๋‹ค. + +
+ +## ๐Ÿ›  ์ฃผ์š” ๊ธฐ๋Šฅ + +### 1. ํ™ˆ ํ™”๋ฉด +- ์ฃผ๊ฐ„ ๊ธฐ๋ก ํ‘œ์‹œ +- ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์•Œ๋žŒ ํ‘œ์‹œ + +### 2. ์•Œ๋žŒ ๊ธฐ๋Šฅ +- ์•Œ๋žŒ ๋ฆฌ์ŠคํŠธ ํ™•์ธ +- ์•Œ๋žŒ On / Off +- ์•Œ๋žŒ ์ถ”๊ฐ€ / ์ˆ˜์ • / ์‚ญ์ œ +- ์‹œ๊ฐ„ ์„ค์ • +- ๋ฐ˜๋ณต ์„ค์ • +- ๋ ˆ์ด๋ธ” ์ด๋ฆ„ ๋ฐ ์„ค๋ช… ์ž…๋ ฅ +- ์‚ฌ์šด๋“œ ์„ค์ • + - ํ–…ํ‹ฑ + - ๊ธฐ๋ณธ ์‚ฌ์šด๋“œ + - ๋ณด๊ด€ํ•จ ๋…ธ๋ž˜ ์„ ํƒ +- ๋‹ค์‹œ ์•Œ๋ฆผ ์„ค์ • + +### 3. ํƒ€์ด๋จธ ๊ธฐ๋Šฅ (๋ฏธ์…˜) +- ๋ฏธ์…˜ ๋ฆฌ์ŠคํŠธ ํ™•์ธ +- ์‹œ์ž‘ / ์ค‘์ง€ +- ์ˆ˜์ • / ์‚ญ์ œ +- ๋ฉ€ํ‹ฐ ํƒ€์ด๋จธ ๊ธฐ๋Šฅ +- ์‹ ๊ทœ ๋“ฑ๋ก ํ™”๋ฉด + - ๋น ๋ฅธ ์„ ํƒ ๋ฉ”๋‰ด + - ์ปค์Šคํ…€ ์„ค์ • +- ์ง„ํ–‰ ํ™”๋ฉด + - ์ง„ํ–‰ ์• ๋‹ˆ๋ฉ”์ด์…˜ + - ํ˜„์žฌ ์ง„ํ–‰ ์‹œ๊ฐ„ ํ‘œ์‹œ + +### 4. ์Šคํ†ฑ์›Œ์น˜ ๊ธฐ๋Šฅ (์ž์œ  ํ•ญํ–‰) +- ์‹œ์ž‘ / ์ผ์‹œ์ •์ง€ / ์ค‘๋‹จ +- Lap ๊ธฐ๋ก +- ์žฌ์„ค์ • + +
+ +## ๐Ÿงฉ ๋„์ „ ๊ธฐ๋Šฅ / ํ™•์žฅ ์•„์ด๋””์–ด + +๋‹ค์Œ ๊ธฐ๋Šฅ์€ ์‹ค์ œ ๊ตฌํ˜„ ๊ธฐ๋Šฅ๊ณผ ๋ณ„๋„๋กœ, ํ”„๋กœ์ ํŠธ ํ™•์žฅ ๋ฐฉํ–ฅ์œผ๋กœ ๊ธฐํšํ•œ ์•„์ด๋””์–ด์ž…๋‹ˆ๋‹ค. + +- ๋ฏธ์…˜ ๋‹ฌ์„ฑ ์‹œ **NASA API ๊ธฐ๋ฐ˜ ์ด๋ฏธ์ง€ ๋ณด์ƒ** +- **Dynamic Island** ํ™œ์šฉ +- ์„ธ๊ณ„ ์‹œ๊ณ„ ๊ธฐ๋Šฅ ํ™•์žฅ +- ๋ฏธ์…˜ ๋กœ๊ทธ ์นด๋“œ +- ์œ„์ ฏ ์ง€์› +- ์šฐ์ฃผ ์•ฐ๋น„์–ธํŠธ ์‚ฌ์šด๋“œ + +
+ +## ๐Ÿ— ๊ธฐ์ˆ  ์Šคํƒ + +### Language +- Swift + +### UI +- UIKit + +### Architecture +- MVVM +- RxSwift Input / Output Pattern + +### Persistence +- CoreData + +### Library +- RxSwift +- SnapKit +- Then +- Lottie + +### Collaboration +- GitFlow +- PR Review +- Gemini Code Assist + +
+ +## ๐Ÿ“ ํด๋” ๊ตฌ์กฐ + +```bash +RocketCall +โ”œโ”€โ”€ Model +โ”‚ โ”œโ”€โ”€ CoredataEntity +โ”‚ โ”œโ”€โ”€ AlarmModel +โ”‚ โ”œโ”€โ”€ CoreDataManager +โ”‚ โ”œโ”€โ”€ NotificationManager +โ”‚ โ”œโ”€โ”€ Payload +โ”‚ โ””โ”€โ”€ Model +โ”‚ +โ”œโ”€โ”€ Util +โ”‚ โ”œโ”€โ”€ TimeContainerView +โ”‚ โ”œโ”€โ”€ BaseCardView +โ”‚ โ”œโ”€โ”€ CircleButton +โ”‚ โ”œโ”€โ”€ GradientProgressView +โ”‚ โ”œโ”€โ”€ TitleView +โ”‚ โ””โ”€โ”€ UIViewController+ +โ”‚ +โ”œโ”€โ”€ View +โ”‚ โ”œโ”€โ”€ Alarm +โ”‚ โ”œโ”€โ”€ HomeTab +โ”‚ โ”‚ โ”œโ”€โ”€ Controller +โ”‚ โ”‚ โ””โ”€โ”€ View +โ”‚ โ”œโ”€โ”€ Mission +โ”‚ โ”‚ โ”œโ”€โ”€ Create +โ”‚ โ”‚ โ”œโ”€โ”€ List +โ”‚ โ”‚ โ””โ”€โ”€ MissionResultView +โ”‚ โ”œโ”€โ”€ Stopwatch +โ”‚ โ””โ”€โ”€ TimerAnimationView +โ”‚ +โ”œโ”€โ”€ ViewModel +โ”‚ โ”œโ”€โ”€ AlarmListViewModel +โ”‚ โ”œโ”€โ”€ AlarmSettingViewModel +โ”‚ โ”œโ”€โ”€ CreateMissionViewModel +โ”‚ โ”œโ”€โ”€ HomeViewModel +โ”‚ โ”œโ”€โ”€ MissionViewModel +โ”‚ โ”œโ”€โ”€ StopWatchViewModel +โ”‚ โ”œโ”€โ”€ TimerAnimationViewModel +โ”‚ โ”œโ”€โ”€ TimerViewModel +โ”‚ โ””โ”€โ”€ ViewModelProtocol +โ”‚ +โ”œโ”€โ”€ AppDelegate +โ”œโ”€โ”€ SceneDelegate +โ””โ”€โ”€ Assets +``` + +
+ +## ๐Ÿง  ์•„ํ‚คํ…์ฒ˜ + +RocketCall์€ MVVM ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. + +### View +- ์‚ฌ์šฉ์ž ์ž…๋ ฅ์„ ๋ฐ›๊ณ  ํ™”๋ฉด์„ ๊ตฌ์„ฑ +- ViewModel์˜ Output์„ ๋ฐ”์ธ๋”ฉํ•˜์—ฌ UI๋ฅผ ๊ฐฑ์‹  +### ViewModel +- View์—์„œ ์ „๋‹ฌํ•œ Input์„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ฒ˜๋ฆฌ +- ๊ฐ€๊ณต๋œ ์ƒํƒœ๋ฅผ Output์œผ๋กœ ์ „๋‹ฌ +### Model / Data Layer +- CoreData๋ฅผ ํ†ตํ•œ ๋ฐ์ดํ„ฐ ์ €์žฅ +- ์•Œ๋ฆผ ๊ด€๋ จ ๋กœ์ง ๊ด€๋ฆฌ +- ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ ๋ฐ Payload ๊ด€๋ฆฌ + +ํŠนํžˆ ์ƒํƒœ ๋ณ€ํ™”์™€ ์ด๋ฒคํŠธ ํ๋ฆ„์ด ๋งŽ์€ ํ”„๋กœ์ ํŠธ ํŠน์„ฑ์ƒ RxSwift Input / Output ํŒจํ„ด์„ ํ™œ์šฉํ•ด ํ™”๋ฉด๊ณผ ๋กœ์ง์˜ ํ๋ฆ„์„ ๋” ๋ช…ํ™•ํžˆ ๋‚˜๋ˆ„๊ณ ์ž ํ–ˆ์Šต๋‹ˆ๋‹ค. + +
+ +## ๐Ÿ’ก ๊ธฐ์ˆ ์  ์˜์‚ฌ๊ฒฐ์ • + +### 1. MVVM ์ ์šฉ +- ๊ธฐ๋Šฅ์ด ์•Œ๋žŒ, ํƒ€์ด๋จธ, ์Šคํ†ฑ์›Œ์น˜, ํ™ˆ ํ™”๋ฉด์œผ๋กœ ๋‚˜๋‰˜์–ด ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ํ™”๋ฉด ๋กœ์ง๊ณผ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋ถ„๋ฆฌํ•  ํ•„์š”๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +- MVVM์„ ์ ์šฉํ•ด ๊ฐ ๊ณ„์ธต์˜ ์—ญํ• ์„ ๋ช…ํ™•ํžˆ ๋‚˜๋ˆ„๊ณ , ๊ธฐ๋Šฅ๋ณ„๋กœ ViewModel์„ ๋ถ„๋ฆฌํ•ด ์œ ์ง€๋ณด์ˆ˜์„ฑ๊ณผ ํ™•์žฅ์„ฑ์„ ๋†’์ด๊ณ ์ž ํ–ˆ์Šต๋‹ˆ๋‹ค. + +### 2. RxSwift Input / Output ํŒจํ„ด ์‚ฌ์šฉ +- ์ด๋ฒˆ ํ”„๋กœ์ ํŠธ๋Š” ๋‹จ์ˆœ ํ™”๋ฉด ๊ตฌ์„ฑ๋ณด๋‹ค์ƒํƒœ ๋ณ€ํ™”, ์‚ฌ์šฉ์ž ์ž…๋ ฅ, ํƒ€์ด๋จธ ํ๋ฆ„, ํ™”๋ฉด ๋ฐ”์ธ๋”ฉ์ด ์ค‘์š”ํ•œ ํ”„๋กœ์ ํŠธ์˜€์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ViewController๊ฐ€ ์ง์ ‘ ๋ชจ๋“  ์ƒํƒœ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ๋ณด๋‹ค, Input์„ ์ „๋‹ฌํ•˜๊ณ  Output์„ ๊ตฌ๋…ํ•˜๋Š” ๊ตฌ์กฐ๋กœ ๊ตฌ์„ฑํ•ด ํ๋ฆ„์„ ๋” ์˜ˆ์ธก ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. + +### 3. CoreData ํ™œ์šฉ +- ์•Œ๋žŒ, ๋ฏธ์…˜, ๊ธฐ๋ก ๋“ฑ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ์ปฌ์— ์ €์žฅํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด CoreData๋ฅผ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. + +### 4. ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌ +- TitleView, BaseCardView, GradientProgressView ๋“ฑ ๊ณตํ†ต UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ณ„๋„๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋†’์ด๊ณ  ํ™”๋ฉด๋ณ„ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๊ณ ์ž ํ–ˆ์Šต๋‹ˆ๋‹ค. + +
+ +## ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ํŒ€์› ๋ฐ ์—ญํ•  +|์ด๋ฆ„ | ๋‹ด๋‹น | +|:-:|:-:| +|ํ•œ์ฃผํ—Œ | ์Šคํ†ฑ์›Œ์น˜ | +|๋ณ€์˜ˆ๋ฆฐ | ๋ฉ”์ธ ํ™”๋ฉด | +|์žฅ์˜ˆ์Šฌ | ํƒ€์ด๋จธ ์ง„ํ–‰ ํ™”๋ฉด(์• ๋‹ˆ๋ฉ”์ด์…˜) | +|์†์˜๋นˆ | ํƒ€์ด๋จธ ํ™”๋ฉด | +|๊น€์ฃผํฌ | ์•Œ๋žŒ ํ™”๋ฉด | +|๊ณตํ†ต | SA ์ž‘์„ฑ, ์Šคํฌ๋Ÿผ ์ผ์ง€ ์ •๋ฆฌ | + +
+ +## ๐Ÿค ํ˜‘์—… ๋ฐฉ์‹ +- Scrum + - ์˜ค์ „ ์Šคํฌ๋Ÿผ (11:00): ํšŒ์˜ ๋ฐ ์ฝ”๋“œ ๋ฆฌ๋ทฐ + - ์ €๋… ์Šคํฌ๋Ÿผ (20:00): ์ง„ํ–‰ ์ƒํ™ฉ ๊ณต์œ  +- PR ๊ทœ์น™ + - ๋ชจ๋‹์Šคํ„ฐ๋”” ์ดํ›„ ์ฝ”๋“œ๋ฆฌ๋ทฐ ์ง„ํ–‰ + - ํ•„์š” ์‹œ ์ถ”๊ฐ€ PR ์‹œ๊ฐ„ ์šด์˜ + - Approval 2๋ช… ์ด์ƒ ์‹œ Merge + - Merge ์ „ ํŒ€์› ๊ณต์œ  + - AI ๋ฆฌ๋ทฐ์–ด๋Š” ๋ณด์กฐ์ ์œผ๋กœ๋งŒ ํ™œ์šฉ + - Branch Strategy + - GitFlow ์ „๋žต ์‚ฌ์šฉ + - ๋ธŒ๋žœ์น˜ ์˜ˆ์‹œ: feature/Alarm#1 + +
+ +## โœ… ์ฝ”๋”ฉ ์ปจ๋ฒค์…˜ + +- API Design Guidelines ๊ธฐ๋ณธ ์ค€์ˆ˜ +- camelCase ์‚ฌ์šฉ +- ์•ฝ์–ด ์‚ฌ์šฉ ์ง€์–‘ +- ์ ‘๊ทผ ์ œ์–ด์ž ๋ช…์‹œ +- ๊ธฐ๋Šฅ ๋ฐ ํ™”๋ฉด ๊ธฐ์ค€ ๋””๋ ‰ํ† ๋ฆฌ ๋ถ„๋ฆฌ +- // MARK:๋ฅผ ํ™œ์šฉํ•œ ๊ตฌ๋ถ„ +- extension ๋‹จ์œ„๋กœ ๊ธฐ๋Šฅ ๋ถ„๋ฆฌ +- ์ปค๋ฐ‹ ๋‹จ์œ„ ์„ธ๋ถ„ํ™” + +
+ +## โœ… ์ปค๋ฐ‹ ์ปจ๋ฒค์…˜ + +- ๐ŸŒŸfeat : ๊ธฐ๋Šฅ ์ถ”๊ฐ€ +- โ™ป๏ธrefactor : ๊ธฐ๋Šฅ ๊ฐœ์„  / ์ „๋ฉด ์ˆ˜์ • +- โœ…test : ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ +- ๐Ÿฉนchore : ๋„ค์ด๋ฐ, ์ปจ๋ฒค์…˜ ๋“ฑ ์ˆ˜์ • +- ๐Ÿ›fix : ์˜ค๋ฅ˜ ์ˆ˜์ • +- ๐Ÿ“docs : ๋ฌธ์„œ ์ถ”๊ฐ€ ๋ฐ ์ˆ˜์ • +- ๐Ÿššbuild : ํŒŒ์ผ ์ด๋™ ๋ฐ ์ถ”๊ฐ€ + +
+ +## ๐Ÿ”ญ ํ–ฅํ›„ ๊ฐœ์„  ๋ฐฉํ–ฅ + +- NASA API๋ฅผ ํ™œ์šฉํ•œ ๋ณด์ƒํ˜• UX ๊ฐ•ํ™” +- Dynamic Island ์—ฐ๋™ +- ๋ฏธ์…˜ ๋กœ๊ทธ ์นด๋“œ ์ œ๊ณต +- ์œ„์ ฏ ์ง€์› +- ๋” ํ’๋ถ€ํ•œ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋ฐ ์‚ฌ์šด๋“œ ์—ฐ์ถœ + +
+ +## ๐Ÿ”— Links + +- GitHub: https://github.com/mastarTrack/RocketCall +- Figma: https://www.figma.com/design/ANioKmtZXqKMIYPgXVLUmH/์ œ๋ชฉ-์—†์Œ diff --git a/RocketCall.xcodeproj/project.pbxproj b/RocketCall.xcodeproj/project.pbxproj index 8ef409b..7d82fbb 100644 --- a/RocketCall.xcodeproj/project.pbxproj +++ b/RocketCall.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 8A65F74F2F710244000BCE97 /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 8A65F74E2F710244000BCE97 /* RxSwift */; }; 8A65F7522F710252000BCE97 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 8A65F7512F710252000BCE97 /* Then */; }; 8A65F7552F710287000BCE97 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 8A65F7542F710287000BCE97 /* Lottie */; }; - 8A65F7582F7102CF000BCE97 /* ReactorKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8A65F7572F7102CF000BCE97 /* ReactorKit */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -48,7 +47,6 @@ files = ( 8A65F7522F710252000BCE97 /* Then in Frameworks */, 8A65F74D2F710244000BCE97 /* RxRelay in Frameworks */, - 8A65F7582F7102CF000BCE97 /* ReactorKit in Frameworks */, 8A65F74B2F710244000BCE97 /* RxCocoa in Frameworks */, 8A65F74F2F710244000BCE97 /* RxSwift in Frameworks */, 8A65F7482F710225000BCE97 /* SnapKit in Frameworks */, @@ -101,7 +99,6 @@ 8A65F74E2F710244000BCE97 /* RxSwift */, 8A65F7512F710252000BCE97 /* Then */, 8A65F7542F710287000BCE97 /* Lottie */, - 8A65F7572F7102CF000BCE97 /* ReactorKit */, ); productName = RocketCall; productReference = 8A65F72B2F710136000BCE97 /* RocketCall.app */; @@ -136,7 +133,6 @@ 8A65F7492F710244000BCE97 /* XCRemoteSwiftPackageReference "RxSwift" */, 8A65F7502F710252000BCE97 /* XCRemoteSwiftPackageReference "Then" */, 8A65F7532F710287000BCE97 /* XCRemoteSwiftPackageReference "lottie-ios" */, - 8A65F7562F7102CF000BCE97 /* XCRemoteSwiftPackageReference "ReactorKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 8A65F72C2F710136000BCE97 /* Products */; @@ -172,10 +168,11 @@ 8A65F7422F710137000BCE97 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = RocketCall; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YSBKYH9JX6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = RocketCall/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -206,10 +203,11 @@ 8A65F7432F710137000BCE97 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = RocketCall; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YSBKYH9JX6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = RocketCall/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -412,14 +410,6 @@ minimumVersion = 4.6.0; }; }; - 8A65F7562F7102CF000BCE97 /* XCRemoteSwiftPackageReference "ReactorKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ReactorKit/ReactorKit.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.2.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -453,11 +443,6 @@ package = 8A65F7532F710287000BCE97 /* XCRemoteSwiftPackageReference "lottie-ios" */; productName = Lottie; }; - 8A65F7572F7102CF000BCE97 /* ReactorKit */ = { - isa = XCSwiftPackageProductDependency; - package = 8A65F7562F7102CF000BCE97 /* XCRemoteSwiftPackageReference "ReactorKit" */; - productName = ReactorKit; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 8A65F7232F710136000BCE97 /* Project object */; diff --git a/RocketCall.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RocketCall.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e17afb0..eb1c733 100644 --- a/RocketCall.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RocketCall.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a805a4d85e6368e4d86484261249e608b75c0f3ec117fc3f0e770256f7c68281", + "originHash" : "2e55f7fccefaa653f8d088ec6b0c203373b202bb6d0fee0e92d81f381607e85a", "pins" : [ { "identity" : "lottie-ios", @@ -10,15 +10,6 @@ "version" : "4.6.0" } }, - { - "identity" : "reactorkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactorKit/ReactorKit.git", - "state" : { - "revision" : "8fa33f09c6f6621a2aa536d739956d53b84dd139", - "version" : "3.2.0" - } - }, { "identity" : "rxswift", "kind" : "remoteSourceControl", @@ -45,15 +36,6 @@ "revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a", "version" : "3.0.0" } - }, - { - "identity" : "weakmaptable", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactorKit/WeakMapTable.git", - "state" : { - "revision" : "cb05d64cef2bbf51e85c53adee937df46540a74e", - "version" : "1.2.1" - } } ], "version" : 3 diff --git a/RocketCall/AlarmSound.wav b/RocketCall/AlarmSound.wav new file mode 100644 index 0000000..c15a63b Binary files /dev/null and b/RocketCall/AlarmSound.wav differ diff --git a/RocketCall/AppDelegate.swift b/RocketCall/AppDelegate.swift index 4138c7b..cc14eb4 100644 --- a/RocketCall/AppDelegate.swift +++ b/RocketCall/AppDelegate.swift @@ -11,10 +11,11 @@ import CoreData @main class AppDelegate: UIResponder, UIApplicationDelegate { - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + + // ์•ฑ ์ผœ์ง€์ž ๋งˆ์ž ์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ + NotificationManager.shared.requestAuthorization() + return true } @@ -31,51 +32,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - - // MARK: - Core Data stack - - lazy var persistentContainer: NSPersistentContainer = { - /* - The persistent container for the application. This implementation - creates and returns a container, having loaded the store for the - application to it. This property is optional since there are legitimate - error conditions that could cause the creation of the store to fail. - */ - let container = NSPersistentContainer(name: "RocketCall") - container.loadPersistentStores(completionHandler: { (storeDescription, error) in - if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - return container - }() - - // MARK: - Core Data Saving support - - func saveContext () { - let context = persistentContainer.viewContext - if context.hasChanges { - do { - try context.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nserror = error as NSError - fatalError("Unresolved error \(nserror), \(nserror.userInfo)") - } - } - } - } diff --git a/RocketCall/Assets.xcassets/backGroudImage.imageset/Contents.json b/RocketCall/Assets.xcassets/backGroudImage.imageset/Contents.json new file mode 100644 index 0000000..584dbbe --- /dev/null +++ b/RocketCall/Assets.xcassets/backGroudImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "backGroudImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/backGroudImage.imageset/backGroudImage.png b/RocketCall/Assets.xcassets/backGroudImage.imageset/backGroudImage.png new file mode 100644 index 0000000..35f5bff Binary files /dev/null and b/RocketCall/Assets.xcassets/backGroudImage.imageset/backGroudImage.png differ diff --git a/RocketCall/Assets.xcassets/label.colorset/Contents.json b/RocketCall/Assets.xcassets/mainLabel.colorset/Contents.json similarity index 100% rename from RocketCall/Assets.xcassets/label.colorset/Contents.json rename to RocketCall/Assets.xcassets/mainLabel.colorset/Contents.json diff --git a/RocketCall/Assets.xcassets/secondLabel.colorset/Contents.json b/RocketCall/Assets.xcassets/secondLabel.colorset/Contents.json new file mode 100644 index 0000000..244d935 --- /dev/null +++ b/RocketCall/Assets.xcassets/secondLabel.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA6", + "green" : "0x92", + "red" : "0x88" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA6", + "green" : "0x92", + "red" : "0x88" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/spaceShip.imageset/Contents.json b/RocketCall/Assets.xcassets/spaceShip.imageset/Contents.json new file mode 100644 index 0000000..fb94756 --- /dev/null +++ b/RocketCall/Assets.xcassets/spaceShip.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "spaceShip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/spaceShip.imageset/spaceShip.png b/RocketCall/Assets.xcassets/spaceShip.imageset/spaceShip.png new file mode 100644 index 0000000..94ab00f Binary files /dev/null and b/RocketCall/Assets.xcassets/spaceShip.imageset/spaceShip.png differ diff --git a/RocketCall/Assets.xcassets/star1.imageset/Contents.json b/RocketCall/Assets.xcassets/star1.imageset/Contents.json new file mode 100644 index 0000000..5c1f206 --- /dev/null +++ b/RocketCall/Assets.xcassets/star1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "star1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/star1.imageset/star1.png b/RocketCall/Assets.xcassets/star1.imageset/star1.png new file mode 100644 index 0000000..bc3c395 Binary files /dev/null and b/RocketCall/Assets.xcassets/star1.imageset/star1.png differ diff --git a/RocketCall/Assets.xcassets/star2.imageset/Contents.json b/RocketCall/Assets.xcassets/star2.imageset/Contents.json new file mode 100644 index 0000000..bba4e68 --- /dev/null +++ b/RocketCall/Assets.xcassets/star2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "star2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/star2.imageset/star2.png b/RocketCall/Assets.xcassets/star2.imageset/star2.png new file mode 100644 index 0000000..50c90a9 Binary files /dev/null and b/RocketCall/Assets.xcassets/star2.imageset/star2.png differ diff --git a/RocketCall/Assets.xcassets/star3.imageset/Contents.json b/RocketCall/Assets.xcassets/star3.imageset/Contents.json new file mode 100644 index 0000000..844ae1a --- /dev/null +++ b/RocketCall/Assets.xcassets/star3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "star3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/star3.imageset/star3.png b/RocketCall/Assets.xcassets/star3.imageset/star3.png new file mode 100644 index 0000000..66ea9b5 Binary files /dev/null and b/RocketCall/Assets.xcassets/star3.imageset/star3.png differ diff --git a/RocketCall/Assets.xcassets/star4.imageset/Contents.json b/RocketCall/Assets.xcassets/star4.imageset/Contents.json new file mode 100644 index 0000000..dabac2f --- /dev/null +++ b/RocketCall/Assets.xcassets/star4.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "star4.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/star4.imageset/star4.png b/RocketCall/Assets.xcassets/star4.imageset/star4.png new file mode 100644 index 0000000..6d06ef8 Binary files /dev/null and b/RocketCall/Assets.xcassets/star4.imageset/star4.png differ diff --git a/RocketCall/Assets.xcassets/star5.imageset/Contents.json b/RocketCall/Assets.xcassets/star5.imageset/Contents.json new file mode 100644 index 0000000..90ec038 --- /dev/null +++ b/RocketCall/Assets.xcassets/star5.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "star5.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/star5.imageset/star5.png b/RocketCall/Assets.xcassets/star5.imageset/star5.png new file mode 100644 index 0000000..a02c959 Binary files /dev/null and b/RocketCall/Assets.xcassets/star5.imageset/star5.png differ diff --git a/RocketCall/Assets.xcassets/star6.imageset/Contents.json b/RocketCall/Assets.xcassets/star6.imageset/Contents.json new file mode 100644 index 0000000..f4e7a57 --- /dev/null +++ b/RocketCall/Assets.xcassets/star6.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "star6.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Assets.xcassets/star6.imageset/star6.png b/RocketCall/Assets.xcassets/star6.imageset/star6.png new file mode 100644 index 0000000..5211c3c Binary files /dev/null and b/RocketCall/Assets.xcassets/star6.imageset/star6.png differ diff --git a/RocketCall/Assets.xcassets/thirdPoint.colorset/Contents.json b/RocketCall/Assets.xcassets/thirdPoint.colorset/Contents.json new file mode 100644 index 0000000..2d570f6 --- /dev/null +++ b/RocketCall/Assets.xcassets/thirdPoint.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x7A", + "red" : "0xC2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x5C", + "red" : "0xA8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RocketCall/Model/AlarmModel.swift b/RocketCall/Model/AlarmModel.swift new file mode 100644 index 0000000..3f2a9ce --- /dev/null +++ b/RocketCall/Model/AlarmModel.swift @@ -0,0 +1,48 @@ +// +// AlarmModel.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/25/26. +// + +import Foundation + +struct Alarm: Equatable, Identifiable, Hashable { + let id: UUID + var hour: Int + var minute: Int + var title: String + var repeatDays: [WeekDay] + var isOn: Bool +} + +enum WeekDay: Int, Equatable, Hashable { + case mon, tue, wed, thu, fri, sat, sun +} + +extension WeekDay { + var koreanName: String { + switch self { + case .mon: return "์›”" + case .tue: return "ํ™”" + case .wed: return "์ˆ˜" + case .thu: return "๋ชฉ" + case .fri: return "๊ธˆ" + case .sat: return "ํ† " + case .sun: return "์ผ" + } + } + + // ์• ํ”Œ ๊ธฐ์ค€ ์ˆซ์ž -> ์ผ~ํ† : 1~7 + var appleWeekDay: Int { + switch self { + case .sun: return 1 + case .mon: return 2 + case .tue: return 3 + case .wed: return 4 + case .thu: return 5 + case .fri: return 6 + case .sat: return 7 + } + } +} diff --git a/RocketCall/Model/CoreDataManager.swift b/RocketCall/Model/CoreDataManager.swift new file mode 100644 index 0000000..6a58f3d --- /dev/null +++ b/RocketCall/Model/CoreDataManager.swift @@ -0,0 +1,373 @@ +// +// CoredataManager.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// + +//MARK: CoreData Manager +import CoreData + +final class CoreDataManager { + //MARK: CoreData ๊ธฐ๋ณธ ์„ค์ • + // - Core Data stack + private let persistentContainer: NSPersistentContainer + + init() { + persistentContainer = NSPersistentContainer(name: "RocketCall") + persistentContainer.loadPersistentStores { storeDescription, error in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + } + + // - Core Data Saving support + // ๋ณ€๊ฒฝ์‚ฌํ•ญ ์กด์žฌ ์‹œ ์ฝ”์–ด๋ฐ์ดํ„ฐ context ์ €์žฅ + func saveContext () { + let context = persistentContainer.viewContext + if context.hasChanges { + do { + try context.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + } + + + // MARK: Custom ์„ค์ • + private var context: NSManagedObjectContext { + return persistentContainer.viewContext + } + + enum CoreDataError: Error { + case descriptionLoadFailed + case saveFailed + case loadFailed + case empty + } +} + +//MARK: Create +extension CoreDataManager { + // ์•Œ๋žŒ ์ƒ์„ฑ ๋ฉ”์†Œ๋“œ + func createAlarmEntity(alarm: borrowing AlarmPayload) throws { + guard let entityDescription = NSEntityDescription.entity(forEntityName: AlarmEntity.className, in: context) else { + throw CoreDataError.descriptionLoadFailed + } + + let newEntity = AlarmEntity(entity: entityDescription, insertInto: context) + + newEntity.id = alarm.id + newEntity.title = alarm.title + newEntity.hour = Int16(alarm.hour) + newEntity.minute = Int16(alarm.minute) + newEntity.isRepeat = alarm.isRepeat + newEntity.repeatDays = alarm.repeatDays.reduce("") { + $0.isEmpty ? "\(String($1))" + : "\(String($0))" + ",\(String($1))" + } + newEntity.isOn = alarm.isOn + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } + + // ๋ฏธ์…˜ ์ƒ์„ฑ ๋ฉ”์†Œ๋“œ + func createMissionEntity(mission: borrowing MissionPayload) throws { + guard let entityDescription = NSEntityDescription.entity(forEntityName: MissionEntity.className, in: context) else { + throw CoreDataError.descriptionLoadFailed + } + + let newEntity = MissionEntity(entity: entityDescription, insertInto: context) + + newEntity.id = mission.id + newEntity.title = mission.title + newEntity.concentrateTime = Int16(mission.concentrateTime) + newEntity.breakTime = Int16(mission.breakTime) + newEntity.cycle = Int16(mission.cycle) + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } + + // ๋ฏธ์…˜ ๊ฒฐ๊ณผ ์ƒ์„ฑ ๋ฉ”์†Œ๋“œ + func createMissionResultEntity(result: borrowing MissionResultPayload) throws { + guard let entityDescription = NSEntityDescription.entity(forEntityName: MissionResultEntity.className, in: context) else { + throw CoreDataError.descriptionLoadFailed + } + + let newEntity = MissionResultEntity(entity: entityDescription, insertInto: context) + + newEntity.id = result.id + newEntity.title = result.title + newEntity.start = result.start + newEntity.end = result.end + newEntity.studyTime = Int64(result.studyTime) + newEntity.isCompleted = result.isCompleted + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } +} + +//MARK: Read +extension CoreDataManager { + // ์•Œ๋žŒ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฉ”์†Œ๋“œ + // - ๊ฐœ๋ณ„ ์•Œ๋žŒ + func fetchAlarm(of alarmId: UUID) throws -> AlarmPayload { + let entity = try fetchAlarmEntity(of: alarmId) + + let repeatDays = entity.repeatDays.isEmpty ? [] + : entity.repeatDays.components(separatedBy: ",").map { Int($0) ?? -1 } + + return AlarmPayload( + id: entity.id, + title: entity.title, + hour: Int(entity.hour), + minute: Int(entity.minute), + isRepeat: entity.isRepeat, + repeatDays: repeatDays, + isOn: entity.isOn + ) + } + + // - ๋ชจ๋“  ์•Œ๋žŒ + func fetchAllAlarm() throws -> [AlarmPayload] { + let request: NSFetchRequest = AlarmEntity.fetchRequest() + + do { + let entities = try context.fetch(request) + guard !entities.isEmpty else { return [] } + + return entities.map { entity in + let repeatDays = entity.repeatDays.isEmpty ? [] + : entity.repeatDays.components(separatedBy: ",").map { Int($0) ?? -1 } + + return AlarmPayload( + id: entity.id, + title: entity.title, + hour: Int(entity.hour), + minute: Int(entity.minute), + isRepeat: entity.isRepeat, + repeatDays: repeatDays, + isOn: entity.isOn + ) + } + } catch { + throw CoreDataError.loadFailed + } + } + + // ๋ฏธ์…˜ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฉ”์†Œ๋“œ + // - ๊ฐœ๋ณ„ ๋ฏธ์…˜ + func fetchMission(of missionId: UUID) throws -> MissionPayload { + let entity = try fetchMissionEntity(of: missionId) + + return MissionPayload( + id: entity.id, + title: entity.title, + concentrateTime: Int(entity.concentrateTime), + breakTime: Int(entity.breakTime), + cycle: Int(entity.cycle) + ) + } + + // - ๋ชจ๋“  ๋ฏธ์…˜ + func fetchAllMission() throws -> [MissionPayload] { + let request: NSFetchRequest = MissionEntity.fetchRequest() + + do { + let entities = try context.fetch(request) + guard !entities.isEmpty else { return [] } + + return entities.map { entity in + MissionPayload( + id: entity.id, + title: entity.title, + concentrateTime: Int(entity.concentrateTime), + breakTime: Int(entity.breakTime), + cycle: Int(entity.cycle) + ) + } + } catch { + throw CoreDataError.loadFailed + } + } + + // ๋ฏธ์…˜ ๊ฒฐ๊ณผ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฉ”์†Œ๋“œ + // - ๊ฐœ๋ณ„ ๋ฏธ์…˜ ๊ฒฐ๊ณผ + func fetchMissionResult(of resultId: UUID) throws -> MissionResultPayload { + let entity = try fetchMissionResultEntity(of: resultId) + + return MissionResultPayload( + id: entity.id, + title: entity.title, + start: entity.start, + end: entity.end, + studyTime: Int(entity.studyTime), + isCompleted: entity.isCompleted + ) + } + + // - ๋ชจ๋“  ๋ฏธ์…˜ ๊ฒฐ๊ณผ + func fetchAllMissionResult() throws -> [MissionResultPayload] { + let request: NSFetchRequest = MissionResultEntity.fetchRequest() + + do { + let entities = try context.fetch(request) + guard !entities.isEmpty else { return [] } + + return entities.map { entity in + MissionResultPayload( + id: entity.id, + title: entity.title, + start: entity.start, + end: entity.end, + studyTime: Int(entity.studyTime), + isCompleted: entity.isCompleted + ) + } + } catch { + throw CoreDataError.loadFailed + } + } +} + +//MARK: Update +// - ๋ฏธ์…˜ ๊ฒฐ๊ณผ๋Š” ์ˆ˜์ •๋˜๋ฉด ์•ˆ๋˜๋ฏ€๋กœ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์Œ +extension CoreDataManager { + // ์•Œ๋žŒ ์—…๋ฐ์ดํŠธ ๋ฉ”์†Œ๋“œ + func updateAlarmEntity(of payload: borrowing AlarmPayload) throws { + let entity = try fetchAlarmEntity(of: payload.id) + + entity.id = payload.id + entity.title = payload.title + entity.hour = Int16(payload.hour) + entity.minute = Int16(payload.minute) + entity.isRepeat = payload.isRepeat + entity.repeatDays = payload.repeatDays.reduce("") { + $0.isEmpty ? "\(String($1))" + : "\(String($0))" + ",\(String($1))" + } + entity.isOn = payload.isOn + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } + + // ๋ฏธ์…˜ ์—…๋ฐ์ดํŠธ ๋ฉ”์†Œ๋“œ + func updateMissionEntity(of payload: borrowing MissionPayload) throws { + let entity = try fetchMissionEntity(of: payload.id) + + entity.id = payload.id + entity.title = payload.title + entity.concentrateTime = Int16(payload.concentrateTime) + entity.breakTime = Int16(payload.breakTime) + entity.cycle = Int16(payload.cycle) + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } +} + +//MARK: Delete +extension CoreDataManager { + // ์•Œ๋žŒ ์‚ญ์ œ ๋ฉ”์†Œ๋“œ + // - ๊ฐœ๋ณ„ ์•Œ๋žŒ ์‚ญ์ œ + func deleteAlarmEntity(of alarmId: UUID) throws { + let entity = try fetchAlarmEntity(of: alarmId) + context.delete(entity) + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } + + // ๋ฏธ์…˜ ์‚ญ์ œ ๋ฉ”์†Œ๋“œ + func deleteMissionEntity(of missionId: UUID) throws { + let entity = try fetchMissionEntity(of: missionId) + context.delete(entity) + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } + + // ๋ฏธ์…˜ ์‚ญ์ œ ๋ฉ”์†Œ๋“œ + func deleteMissionResultEntity(of resultId: UUID) throws { + let entity = try fetchMissionResultEntity(of: resultId) + context.delete(entity) + + do { + try context.save() + } catch { + throw CoreDataError.saveFailed + } + } +} + +//MARK: Fetch Entity +extension CoreDataManager { + private func fetchAlarmEntity(of id: UUID) throws -> AlarmEntity { + let request: NSFetchRequest = AlarmEntity.fetchRequest() + request.predicate = NSPredicate(format: "\(AlarmEntity.keys.id) == %@", id as CVarArg) + request.fetchLimit = 1 + + do { + guard let entity = try context.fetch(request).first else { throw CoreDataError.empty } + return entity + } catch { + throw CoreDataError.loadFailed + } + } + + private func fetchMissionEntity(of id: UUID) throws -> MissionEntity { + let request: NSFetchRequest = MissionEntity.fetchRequest() + request.predicate = NSPredicate(format: "\(MissionEntity.keys.id) == %@", id as CVarArg) + request.fetchLimit = 1 + + do { + guard let entity = try context.fetch(request).first else { throw CoreDataError.empty } + return entity + } catch { + throw CoreDataError.loadFailed + } + } + + private func fetchMissionResultEntity(of id: UUID) throws -> MissionResultEntity { + let request: NSFetchRequest = MissionResultEntity.fetchRequest() + request.predicate = NSPredicate(format: "\(MissionResultEntity.keys.id) == %@", id as CVarArg) + request.fetchLimit = 1 + + do { + guard let entity = try context.fetch(request).first else { throw CoreDataError.empty } + return entity + } catch { + throw CoreDataError.loadFailed + } + } +} diff --git a/RocketCall/Model/CoredataEntity/Alarm+CoreDataClass.swift b/RocketCall/Model/CoredataEntity/Alarm+CoreDataClass.swift new file mode 100644 index 0000000..5c478c1 --- /dev/null +++ b/RocketCall/Model/CoredataEntity/Alarm+CoreDataClass.swift @@ -0,0 +1,26 @@ +// +// Alarm+CoreDataClass.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// +// + +import Foundation +import CoreData + +typealias AlarmEntityCoreDataClassSet = NSSet + +@objc(AlarmEntity) +class AlarmEntity: NSManagedObject { + static let className = "AlarmEntity" + enum keys { + static let id = "id" + static let title = "title" + static let hour = "hour" + static let minute = "minute" + static let isRepeat = "isRepeat" + static let repeatDays = "repeatDays" + static let isOn = "isOn" + } +} diff --git a/RocketCall/Model/CoredataEntity/Alarm+CoreDataProperties.swift b/RocketCall/Model/CoredataEntity/Alarm+CoreDataProperties.swift new file mode 100644 index 0000000..1e8a03b --- /dev/null +++ b/RocketCall/Model/CoredataEntity/Alarm+CoreDataProperties.swift @@ -0,0 +1,33 @@ +// +// Alarm+CoreDataProperties.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// +// + +import Foundation +import CoreData + + +typealias AlarmEntityCoreDataPropertiesSet = NSSet + +extension AlarmEntity { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "AlarmEntity") + } + + @NSManaged var id: UUID + @NSManaged var title: String + @NSManaged var hour: Int16 + @NSManaged var minute: Int16 + @NSManaged var isRepeat: Bool + @NSManaged var repeatDays: String + @NSManaged var isOn: Bool + +} + +extension AlarmEntity : Identifiable { + +} diff --git a/RocketCall/Model/CoredataEntity/Mission+CoreDataClass.swift b/RocketCall/Model/CoredataEntity/Mission+CoreDataClass.swift new file mode 100644 index 0000000..830bda7 --- /dev/null +++ b/RocketCall/Model/CoredataEntity/Mission+CoreDataClass.swift @@ -0,0 +1,24 @@ +// +// Mission+CoreDataClass.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// +// + +import Foundation +import CoreData + +typealias MissionEntityCoreDataClassSet = NSSet + +@objc(MissionEntity) +class MissionEntity: NSManagedObject { + static let className = "MissionEntity" + enum keys { + static let id = "id" + static let title = "title" + static let concentrateTime = "concentrateTime" + static let breakTime = "breakTime" + static let cycle = "cycle" + } +} diff --git a/RocketCall/Model/CoredataEntity/Mission+CoreDataProperties.swift b/RocketCall/Model/CoredataEntity/Mission+CoreDataProperties.swift new file mode 100644 index 0000000..c797c42 --- /dev/null +++ b/RocketCall/Model/CoredataEntity/Mission+CoreDataProperties.swift @@ -0,0 +1,31 @@ +// +// Mission+CoreDataProperties.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// +// + +import Foundation +import CoreData + + +typealias MissionEntityCoreDataPropertiesSet = NSSet + +extension MissionEntity { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "MissionEntity") + } + + @NSManaged var id: UUID + @NSManaged var title: String + @NSManaged var concentrateTime: Int16 + @NSManaged var breakTime: Int16 + @NSManaged var cycle: Int16 + +} + +extension MissionEntity : Identifiable { + +} diff --git a/RocketCall/Model/CoredataEntity/MissionResult+CoreDataClass.swift b/RocketCall/Model/CoredataEntity/MissionResult+CoreDataClass.swift new file mode 100644 index 0000000..3ee0130 --- /dev/null +++ b/RocketCall/Model/CoredataEntity/MissionResult+CoreDataClass.swift @@ -0,0 +1,25 @@ +// +// MissionResult+CoreDataClass.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// +// + +import Foundation +import CoreData + +typealias MissionResultEntityCoreDataClassSet = NSSet + +@objc(MissionResultEntity) +class MissionResultEntity: NSManagedObject { + static let className = "MissionResultEntity" + enum keys { + static let id = "id" + static let title = "title" + static let start = "start" + static let end = "end" + static let studyTime = "studyTime" + static let isCompleted = "isCompleted" + } +} diff --git a/RocketCall/Model/CoredataEntity/MissionResult+CoreDataProperties.swift b/RocketCall/Model/CoredataEntity/MissionResult+CoreDataProperties.swift new file mode 100644 index 0000000..d5d8fa2 --- /dev/null +++ b/RocketCall/Model/CoredataEntity/MissionResult+CoreDataProperties.swift @@ -0,0 +1,31 @@ +// +// MissionResult+CoreDataProperties.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// +// + +import Foundation +import CoreData + +typealias MissionResultEntityCoreDataPropertiesSet = NSSet + +extension MissionResultEntity { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "MissionResultEntity") + } + + @NSManaged var id: UUID + @NSManaged var title: String + @NSManaged var start: Date + @NSManaged var end: Date + @NSManaged var studyTime: Int64 + @NSManaged var isCompleted: Bool + +} + +extension MissionResultEntity : Identifiable { + +} diff --git a/RocketCall/Model/NotificationManager.swift b/RocketCall/Model/NotificationManager.swift new file mode 100644 index 0000000..9fc980d --- /dev/null +++ b/RocketCall/Model/NotificationManager.swift @@ -0,0 +1,190 @@ +// +// NotificationManager.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/26/26. +// + +import Foundation +import UserNotifications +import RxSwift +import RxCocoa + +final class NotificationManager: NSObject, UNUserNotificationCenterDelegate { + + static let shared = NotificationManager() + + // ์•Œ๋žŒ ์šธ๋ฆฌ๋ฉด ์•Œ๋ฆฌ๊ธฐ + let alarmRingingEvent = PublishRelay<(String, UUID)>() + + let timerNotificationTapped = PublishRelay() + + private override init() { + super.init() + UNUserNotificationCenter.current().delegate = self + } + + + // MARK: - ์•Œ๋žŒ ๊ถŒํ•œ ์š”์ฒญ ๋ฉ”์„œ๋“œ + func requestAuthorization() { + let center = UNUserNotificationCenter.current() + let options: UNAuthorizationOptions = [.alert, .sound] + + center.requestAuthorization(options: options) { granted, error in + } + } + + + // MARK: - ์•Œ๋žŒ ์˜ˆ์•ฝ ๋ฉ”์„œ๋“œ + func addAlarm(_ alarm: Alarm) { + let center = UNUserNotificationCenter.current() + + // 1. ๋‚ด์šฉ + let content = UNMutableNotificationContent() + content.title = "Rocket Call" + content.body = alarm.title // ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์ •ํ•œ ์•Œ๋žŒ ์ด๋ฆ„ + content.sound = UNNotificationSound(named: UNNotificationSoundName("AlarmSound.wav")) + content.interruptionLevel = .timeSensitive // ๋ฐฉํ•ด ๊ธˆ์ง€์—ฌ๋„ ์•Œ๋žŒ + + // 2. ์‹œ๊ฐ„ ์„ค์ • + // ๋ฐ˜๋ณต ์—†์Œ + if alarm.repeatDays.isEmpty { + for i in 0..<4 { + var dateComponents = DateComponents() + dateComponents.hour = alarm.hour + dateComponents.minute = alarm.minute + dateComponents.second = i * 9 + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false) + let identifier = "\(alarm.id.uuidString)-\(i)" + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + center.add(request) + } + } else { + // ๋ฐ˜๋ณต ์žˆ์Œ + for day in alarm.repeatDays { + for i in 0..<4 { + var dateComponents = DateComponents() + dateComponents.weekday = day.appleWeekDay + dateComponents.hour = alarm.hour + dateComponents.minute = alarm.minute + dateComponents.second = i * 9 + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + let identifier = "\(alarm.id.uuidString)-\(day.rawValue)-\(i)" // id๊ฐ€ ๋™์ผํ•˜๋ฏ€๋กœ ๊ตฌ๋ถ„์ง€์–ด์ฃผ๊ธฐ ์œ„ํ•ด ๋’ค์— ์š”์ผ ์ถ”๊ฐ€ + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + center.add(request) + } + } + } + } + + + // MARK: - ์•Œ๋žŒ ์ทจ์†Œ ๋ฉ”์„œ๋“œ + func cancelAlarm(_ id: String) { + let center = UNUserNotificationCenter.current() + + center.getPendingNotificationRequests { requests in + // ์˜ˆ์•ฝ๋œ ์•Œ๋žŒ ์ค‘ ํ•ด๋‹น UUID๊ฐ€ ํฌํ•จ๋œ ์•Œ๋žŒ๋งŒ ๊ณจ๋ผ๋‚ด๊ธฐ + let identifiersToRemove = requests + .filter { $0.identifier.contains(id) } + .map { $0.identifier } + + center.removePendingNotificationRequests(withIdentifiers: identifiersToRemove) + } + } + + + // MARK: - ์Šค๋ˆ„์ฆˆ ์•Œ๋žŒ ์˜ˆ์•ฝ ๋ฉ”์„œ๋“œ + func addSnoozeAlarm(title: String, originalId: UUID) { + let center = UNUserNotificationCenter.current() + + // 1. ๋‚ด์šฉ + let content = UNMutableNotificationContent() + content.title = "Rocket Call (Snooze)" + content.body = title + content.sound = UNNotificationSound(named: UNNotificationSoundName("AlarmSound.wav")) + content.interruptionLevel = .timeSensitive // ๋ฐฉํ•ด ๊ธˆ์ง€์—ฌ๋„ ์•Œ๋žŒ + + // 2. ์‹œ๊ฐ„ ์„ค์ • + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false) + let identifier = "\(originalId.uuidString)-Snooze" + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + center.add(request) + } + + + // MARK: - ์•ฑ์ด ํ™”๋ฉด์— ์ผœ์ ธ์žˆ์„๋•Œ + var currentRingingId: UUID? = nil // ํ˜„์žฌ ์šธ๋ฆฌ๊ณ  ์žˆ๋Š” ์•Œ๋žŒ์˜ id๊ฐ’ ๊ธฐ์–ตํ•˜๊ธฐ + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + + // ์˜ˆ์•ฝํ•  ๋•Œ ๋„ฃ์—ˆ๋˜ ์•Œ๋žŒ ์ œ๋ชฉ ๊บผ๋‚ด๊ธฐ + let alarmTitle = notification.request.content.body + let idString = String(notification.request.identifier.prefix(36)) + + if let uuid = UUID(uuidString: idString) { + + if currentRingingId == uuid { // ๋™์ผํ•œ uuid์˜ ์•Œ๋žŒ์ด๋ฉด ์•Œ๋žŒ ํ™”๋ฉด ๋˜ ๋„์šธ ํ•„์š” ์—†์Œ + completionHandler([]) + return + } + + currentRingingId = uuid + alarmRingingEvent.accept((alarmTitle, uuid)) + } + + completionHandler([]) // ๋ฐฐ๋„ˆ ๋„์šธ ํ•„์š” ์—†์œผ๋ฏ€๋กœ ๋นˆ ๋ฐฐ์—ด + } + + + // MARK: - ์•ฑ์ด ๊บผ์ ธ์žˆ์„๋•Œ (๋ฐฑ๊ทธ๋ผ์šด๋“œ) + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + + // completionHandler๋Š” ํ•œ๋ฒˆ๋งŒ ํ˜ธ์ถœํ•ด์•ผ ํ•ด์„œ ํƒ€์ด๋จธ์ธ์ง€ ๋จผ์ € ํ™•์ธํ•ด์•ผ ํ•ด์š” ! + let identifier = response.notification.request.identifier + if identifier.hasPrefix("timer-") { + timerNotificationTapped.accept(()) + completionHandler() + return + } + + + let alarmTitle = response.notification.request.content.body + let idString = String(response.notification.request.identifier.prefix(36)) + + if let uuid = UUID(uuidString: idString) { + alarmRingingEvent.accept((alarmTitle, uuid)) + } + completionHandler() + } + +} + +extension NotificationManager { + func fetchNearestAlarm() async -> UUID? { + let center = UNUserNotificationCenter.current() + + let requests = await center.pendingNotificationRequests() + + let request = requests + .compactMap { request -> (request: UNNotificationRequest, date: Date)? in + guard let trigger = request.trigger as? UNCalendarNotificationTrigger, + let nextTriggerDate = trigger.nextTriggerDate() else { + return nil + } + return (request, nextTriggerDate) + } + .sorted(by: { $0.date < $1.date }) + .first?.request + + guard let id = request?.identifier.prefix(36) else { + return nil + } + + return UUID(uuidString: String(id)) + } +} diff --git a/RocketCall/Model/Payload.swift b/RocketCall/Model/Payload.swift new file mode 100644 index 0000000..afe2e87 --- /dev/null +++ b/RocketCall/Model/Payload.swift @@ -0,0 +1,52 @@ +// +// Payload.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// + +//MARK: CoreData ์ „๋‹ฌ์šฉ Payload +// - CoreDataManager์—์„œ ์†Œํ†ตํ•  ๋•Œ, CoreDataManager๋Š” Payload ๊ฐ์ฒด๋กœ ์ „๋‹ฌ๋ฐ›๊ณ  Payload ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. +import Foundation + +// ์•Œ๋žŒ +struct AlarmPayload { + var id: UUID + var title: String + var hour: Int + var minute: Int + var isRepeat: Bool // ์•Œ๋žŒ ๋ฐ˜๋ณต ์—ฌ๋ถ€ + var repeatDays: [Int] = [] // ๋ฐ˜๋ณต ์š”์ผ, ์—†์„ ๊ฒฝ์šฐ ๋นˆ ๋ฐฐ์—ด + var isOn: Bool +} + +// ์ปค์Šคํ…€ ๋ฏธ์…˜ - ๋ฝ€๋ชจ๋„๋กœ +struct MissionPayload: Hashable { + var id: UUID + var title: String + var concentrateTime: Int // ์ง‘์ค‘ ์‹œ๊ฐ„ + var breakTime: Int // ํœด์‹ ์‹œ๊ฐ„ + var cycle: Int // ์‚ฌ์ดํด ์ˆ˜ +} + +struct ActivatedMissionPayload: Hashable { + var id: UUID + var mission: MissionPayload // ๋ฏธ์…˜ ์ •๋ณด + var currentCycle: Int // ํ˜„์žฌ ์‚ฌ์ดํด + var remainingTime: Int // ๋‚จ์€ ์‹œ๊ฐ„ + var isConcentrating: Bool // ํ˜„์žฌ ์ƒํƒœ (์ง‘์ค‘? ํœด์‹?) + var startDate: Date // ์‹œ์ž‘ ์‹œ๊ฐ„ + var isPaused: Bool // ์ผ์‹œ์ •์ง€ ์—ฌ๋ถ€ + var studyTime: Int // ์ˆœ ๊ณต๋ถ€ ์‹œ๊ฐ„ + var pausedTime: Int // ์ผ์‹œ ์ •์ง€ ์‹œ๊ฐ„ +} + +// ๋ฏธ์…˜ ๊ฒฐ๊ณผ - ๋ฝ€๋ชจ๋„๋กœ +struct MissionResultPayload: Hashable { + var id: UUID + var title: String // Mission.title๊ณผ ๋™์ผ๊ฐ’ + var start: Date + var end: Date + var studyTime: Int // ๊ณต๋ถ€ ์‹œ๊ฐ„ + var isCompleted: Bool // ๋‹ฌ์„ฑ ์—ฌ๋ถ€ +} diff --git a/RocketCall/RocketCall.icon/Assets/Frame-18.png b/RocketCall/RocketCall.icon/Assets/Frame-18.png new file mode 100644 index 0000000..4ca9440 Binary files /dev/null and b/RocketCall/RocketCall.icon/Assets/Frame-18.png differ diff --git a/RocketCall/RocketCall.icon/icon.json b/RocketCall/RocketCall.icon/icon.json new file mode 100644 index 0000000..10fb4d6 --- /dev/null +++ b/RocketCall/RocketCall.icon/icon.json @@ -0,0 +1,50 @@ +{ + "fill-specializations" : [ + { + "value" : { + "automatic-gradient" : "display-p3:0.04228,0.05440,0.14664,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "display-p3:0.04228,0.05440,0.14664,1.00000" + } + } + ], + "groups" : [ + { + "layers" : [ + { + "fill" : "automatic", + "glass" : false, + "hidden" : false, + "image-name" : "Frame-18.png", + "name" : "Frame-18", + "opacity" : 1, + "position" : { + "scale" : 0.5, + "translation-in-points" : [ + 0, + -0.25 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/RocketCall/RocketCall.xcdatamodeld/RocketCall.xcdatamodel/contents b/RocketCall/RocketCall.xcdatamodeld/RocketCall.xcdatamodel/contents index 50d2514..e51e1ba 100644 --- a/RocketCall/RocketCall.xcdatamodeld/RocketCall.xcdatamodel/contents +++ b/RocketCall/RocketCall.xcdatamodeld/RocketCall.xcdatamodel/contents @@ -1,4 +1,27 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/RocketCall/SceneDelegate.swift b/RocketCall/SceneDelegate.swift index eb03d4f..310b138 100644 --- a/RocketCall/SceneDelegate.swift +++ b/RocketCall/SceneDelegate.swift @@ -15,7 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } let window = UIWindow(windowScene: windowScene) - window.rootViewController = ViewController() + window.rootViewController = MainController() window.makeKeyAndVisible() self.window = window @@ -41,6 +41,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneWillEnterForeground(_ scene: UIScene) { // Called as the scene transitions from the background to the foreground. // Use this method to undo the changes made on entering the background. + guard let vc = window?.rootViewController as? MainController else { return } + vc.timerViewModel.enterForeGround() + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + let timerIdentifiers = requests + .filter { $0.identifier.hasPrefix("timer") } + .map { $0.identifier } + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: timerIdentifiers) + } } func sceneDidEnterBackground(_ scene: UIScene) { @@ -49,7 +57,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // to restore the scene back to its current state. // Save changes in the application's managed object context when the application transitions to the background. - (UIApplication.shared.delegate as? AppDelegate)?.saveContext() + + guard let vc = window?.rootViewController as? MainController else { return } + vc.coreDataManager.saveContext() + vc.timerViewModel.backgroundEnterTime = Date() + vc.timerViewModel.cycleNotification() } diff --git a/RocketCall/Util/BaseCardView.swift b/RocketCall/Util/BaseCardView.swift new file mode 100644 index 0000000..eb99d38 --- /dev/null +++ b/RocketCall/Util/BaseCardView.swift @@ -0,0 +1,42 @@ +// +// BaseCardView.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/23/26. +// + +// let cardView = BaseCardView() +// cardView.isOn = true -> border ์ƒ‰์ƒ ๋ณด๋ผ์ƒ‰์œผ๋กœ ๋ณ€๊ฒฝ +// ๊ธฐ๋ณธ๊ฐ’: off (ํšŒ์ƒ‰) + + +import UIKit + +class BaseCardView: UIView { + + var isOn: Bool = false { + didSet { + let activeColor = UIColor(red: 108/255.0, green: 92/255.0, blue: 231/255.0, alpha: 0.8).cgColor + let inactiveColor = UIColor(red: 201/255.0, green: 209/255.0, blue: 232/255.0, alpha: 0.3).cgColor + + self.layer.borderColor = isOn ? activeColor : inactiveColor + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupStyle() + } + + required init?(coder: NSCoder) { + fatalError() + } + + private func setupStyle() { + self.backgroundColor = UIColor(red: 18/255.0, green: 26/255.0, blue: 48/255.0, alpha: 1.0) + self.layer.cornerRadius = 16 + self.layer.masksToBounds = true + self.layer.borderWidth = 1 + self.layer.borderColor = UIColor(red: 201/255.0, green: 209/255.0, blue: 232/255.0, alpha: 0.3).cgColor + } +} diff --git a/RocketCall/Util/CircleButton.swift b/RocketCall/Util/CircleButton.swift new file mode 100644 index 0000000..7b5d781 --- /dev/null +++ b/RocketCall/Util/CircleButton.swift @@ -0,0 +1,66 @@ +// +// Untitled.swift +// RocketCall +// +// Created by Yeseul Jang on 3/23/26. +// +import UIKit +import SnapKit + +/* + ์‚ฌ์šฉ์‹œ + let startButton = CircleButton( + size: 72, + backgroundColor: .mainPoint, + image: UIImage(systemName: "play.fill"), + tintColor: .white + ) + */ + + +final class CircleButton: UIButton { + + private let buttonSize: CGFloat + + init( + size: CGFloat = 56, + backgroundColor: UIColor = .mainPoint, + image: UIImage? = nil, + tintColor: UIColor = .white + ) { + self.buttonSize = size + super.init(frame: .zero) + + configureUI( + backgroundColor: backgroundColor, + image: image, + tintColor: tintColor + ) + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureUI( + backgroundColor: UIColor, + image: UIImage?, + tintColor: UIColor + ) { + self.backgroundColor = backgroundColor + self.tintColor = tintColor + + setImage(image, for: .normal) + imageView?.contentMode = .scaleAspectFit + + layer.cornerRadius = buttonSize / 2 + clipsToBounds = true + } + + private func configureLayout() { + snp.makeConstraints { + $0.width.height.equalTo(buttonSize) + } + } +} diff --git a/RocketCall/Util/CircleContainerView.swift b/RocketCall/Util/CircleContainerView.swift new file mode 100644 index 0000000..aef103b --- /dev/null +++ b/RocketCall/Util/CircleContainerView.swift @@ -0,0 +1,36 @@ +// +// circleContainerView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/23/26. +// +import UIKit +import SnapKit + +final class CircleContainerView: UIView { + + private let circleSize: CGFloat + + init(size: CGFloat, color: UIColor = .mainPoint) { + self.circleSize = size + super.init(frame: .zero) + configureUI(color: color) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureUI(color: UIColor) { + backgroundColor = color + layer.cornerRadius = circleSize / 2 + clipsToBounds = true + } + + private func setupLayout() { + self.snp.makeConstraints { + $0.size.equalTo(circleSize) + } + } +} diff --git a/RocketCall/Util/GradientProgressView.swift b/RocketCall/Util/GradientProgressView.swift new file mode 100644 index 0000000..3d52ff3 --- /dev/null +++ b/RocketCall/Util/GradientProgressView.swift @@ -0,0 +1,61 @@ +// +// TimeProgressView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// +import UIKit + +// ๊ทธ๋ผ๋””์–ธํŠธ ๋ ˆ์ด์–ด ์ƒ์„ฑ ๋ฉ”์„œ๋“œ +extension CAGradientLayer { + static func gradientLayer(frame: CGRect) -> Self { + let layer = Self() + let startColor = UIColor(red: 0.17, green: 0.50, blue: 1.00, alpha: 1.00) // HEX #2B7FFF + layer.colors = [startColor.cgColor, UIColor.mainPoint.cgColor] + layer.frame = frame + return layer + } +} + +class GradientProgressView: UIProgressView { + let gradientLayer: CAGradientLayer + + override init(frame: CGRect) { + // ๊ทธ๋ผ๋””์–ธํŠธ ๋ ˆ์ด์–ด ์„ค์ • + self.gradientLayer = .gradientLayer(frame: frame) + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + + super.init(frame: frame) + + // ์ƒ‰์ƒ ์„ค์ • + self.tintColor = .clear // ํ”„๋กœ๊ทธ๋ ˆ์Šค๋ฐ”๊ฐ€ ๋ชจ๋‘ ์ฑ„์›Œ์กŒ์„ ๋•Œ์˜ ์ƒ‰์ƒ - ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ํˆฌ๋ช… ์ฒ˜๋ฆฌ + self.trackTintColor = .clear // ํ”„๋กœ๊ทธ๋ ˆ์Šค๋ฐ”๊ฐ€ ๋ชจ๋‘ ์ฑ„์›Œ์ง€์ง€ ์•Š์•˜์„ ๋•Œ์˜ ์ƒ‰์ƒ - ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ํˆฌ๋ช… ์ฒ˜๋ฆฌ + self.backgroundColor = .mainPoint.withAlphaComponent(0.2) // ํ”„๋กœ๊ทธ๋ ˆ์Šค๋ฐ”์˜ ๋ฐฐ๊ฒฝ ์ƒ‰์ƒ + + // ๊ทธ๋ผ๋””์–ธํŠธ ๋ ˆ์ด์–ด ์ถ”๊ฐ€ + self.layer.addSublayer(gradientLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + updateGradientLayer(progress: self.progress) + } +} + +extension GradientProgressView { + // ํ”„๋กœ๊ทธ๋ ˆ์Šค๋ฐ”๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” gradientLayer์˜ ํฌ๊ธฐ ์กฐ์ • + private func updateGradientLayer(progress: Float) { + let width = self.bounds.width * CGFloat(progress) + gradientLayer.frame = CGRect(x: 0, y: 0, width: width, height: self.bounds.height) + } + + override func setProgress(_ progress: Float, animated: Bool) { + updateGradientLayer(progress: progress) + super.setProgress(progress, animated: animated) + } +} diff --git a/RocketCall/Util/LabelConfiguration.swift b/RocketCall/Util/LabelConfiguration.swift index 72661ee..d8fe17d 100644 --- a/RocketCall/Util/LabelConfiguration.swift +++ b/RocketCall/Util/LabelConfiguration.swift @@ -24,4 +24,76 @@ extension LabelConfiguration { color: .label, lines: 0 ) + + static let title = LabelConfiguration( + font: .systemFont(ofSize: 26, weight: .heavy), + color: .mainLabel, + lines: 1 + ) + + static let subTitle = LabelConfiguration( + font: .systemFont(ofSize: 14, weight: .medium), + color: .subLabel, + lines: 1 + ) + +} + +extension LabelConfiguration { + static let homeViewHeader = LabelConfiguration( + font: .systemFont(ofSize: 20, weight: .bold), + color: .mainLabel, + lines: 1 + ) + + static let missionTime = LabelConfiguration( + font: .systemFont(ofSize: 36, weight: .thin), + color: .mainLabel, + lines: 1 + ) + + static let missionLabel = LabelConfiguration( + font: .systemFont(ofSize: 16, weight: .medium), + color: .mainLabel, + lines: 1 + ) +} + +extension LabelConfiguration { + static let sub12 = LabelConfiguration( + font: .systemFont(ofSize: 12, weight: .medium), + color: .subLabel, + lines: 1 + ) + + static let sub14 = LabelConfiguration( + font: .systemFont(ofSize: 14, weight: .medium), + color: .subLabel, + lines: 1 + ) + + static let sub16 = LabelConfiguration( + font: .systemFont(ofSize: 16, weight: .medium), + color: .subLabel, + lines: 1 + ) + + static let main24 = LabelConfiguration( + font: .systemFont(ofSize: 24, weight: .medium), + color: .mainLabel, + lines: 1 + ) + + static let main30 = LabelConfiguration( + font: .systemFont(ofSize: 30, weight: .medium), + color: .mainLabel, + lines: 1 + ) + + static let main24Bold = LabelConfiguration( + font: .systemFont(ofSize: 24, weight: .bold), + color: .mainLabel, + lines: 1 + ) } + diff --git a/RocketCall/Util/RectangleButton.swift b/RocketCall/Util/RectangleButton.swift new file mode 100644 index 0000000..662661f --- /dev/null +++ b/RocketCall/Util/RectangleButton.swift @@ -0,0 +1,61 @@ +// +// rectangleButton.swift +// RocketCall +// +// Created by Yeseul Jang on 3/23/26. +// +import UIKit + + +// TitleEdgesInsets ๊ฒฝ๊ณ  -> Config๋กœ ๋ณ€๊ฒฝ +class RectangleButton: UIButton { + init(title: String, color: UIColor) { + super.init(frame: .zero) + setAttributes(title: title, color: color) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + + +// Text +extension RectangleButton { + private func setAttributes(title: String, color: UIColor) { + var config = UIButton.Configuration.filled() + config.title = title + config.baseForegroundColor = .white + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attribute = $0 + attribute.font = .boldSystemFont(ofSize: 16) + return attribute + } + config.baseBackgroundColor = color + config.cornerStyle = .fixed + config.background.cornerRadius = 10 + self.configuration = config + } +} + + +// Icon + text +extension RectangleButton { + convenience init( + title: String? = nil, + image: UIImage? = nil, + backgroundColor: UIColor, + tintColor: UIColor? = nil + ) { + self.init(title: title ?? "", color: backgroundColor) + + var config = self.configuration ?? UIButton.Configuration.filled() + config.image = image + config.baseForegroundColor = tintColor ?? .white + + if title != nil && image != nil { + config.imagePadding = 10 + } + self.configuration = config + } +} diff --git a/RocketCall/Util/SeparatorView.swift b/RocketCall/Util/SeparatorView.swift new file mode 100644 index 0000000..f779da6 --- /dev/null +++ b/RocketCall/Util/SeparatorView.swift @@ -0,0 +1,18 @@ +// +// SeparatorView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/27/26. +// +import UIKit + +class SeparatorView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.white.withAlphaComponent(0.1) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/RocketCall/Util/StateLabel.swift b/RocketCall/Util/StateLabel.swift new file mode 100644 index 0000000..7cc8c13 --- /dev/null +++ b/RocketCall/Util/StateLabel.swift @@ -0,0 +1,52 @@ +// +// StateLabel.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// + +import UIKit + +/// ์ƒํƒœ Label +class StateLabel: UILabel { + private let padding = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 10 + layer.masksToBounds = true + textAlignment = .center + } + + convenience init(text: String, config: StateLabelConfiguration) { + self.init() + self.text = text + self.font = config.font // ์ถ”๊ฐ€ + self.textColor = config.color // ๊ธ€์ž ์ƒ‰ + self.backgroundColor = config.backgroundColor // ๋ฐฐ๊ฒฝ ์ƒ‰ + + // ํ…Œ๋‘๋ฆฌ ์กด์žฌ ์‹œ + if let borderColor = config.borderColor { + self.layer.borderWidth = 1 + self.layer.borderColor = borderColor.cgColor // ํ…Œ๋‘๋ฆฌ ์ƒ‰ + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension StateLabel { + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: padding)) + } + + // ๊ณ ์œ  ํฌ๊ธฐ + override var intrinsicContentSize: CGSize { + var contentSize = super.intrinsicContentSize + contentSize.height += padding.top + padding.bottom + contentSize.width += padding.left + padding.right + return contentSize + } +} diff --git a/RocketCall/Util/StateLabelConfiguration.swift b/RocketCall/Util/StateLabelConfiguration.swift new file mode 100644 index 0000000..90c17fb --- /dev/null +++ b/RocketCall/Util/StateLabelConfiguration.swift @@ -0,0 +1,47 @@ +// +// StateLabelConfiguration.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// + +import UIKit + +struct StateLabelConfiguration { + let font: UIFont // ํฐํŠธ + let color: UIColor // ๊ธ€์ž ์ƒ‰์ƒ + let backgroundColor: UIColor // ๋ฐฐ๊ฒฝ ์ƒ‰์ƒ + let borderColor: UIColor? // ํ…Œ๋‘๋ฆฌ ์ƒ‰์ƒ + + init(font: UIFont, color: UIColor, backgroundColor: UIColor = UIColor.cardBackground, borderColor: UIColor? = nil) { + self.font = font + self.color = color + + self.borderColor = borderColor + self.backgroundColor = backgroundColor + } +} + +extension StateLabelConfiguration { + // ์˜ˆ์‹œ + static let complete = StateLabelConfiguration( + font: .systemFont(ofSize: 14, weight: .medium), + color: .systemGreen, + backgroundColor: UIColor(red: 0.88, green: 1.00, blue: 0.91, alpha: 1.00), + borderColor: UIColor.red + ) +} + +extension StateLabelConfiguration { + static let success = StateLabelConfiguration( + font: .systemFont(ofSize: 14, weight: .medium), + color: .systemGreen, + backgroundColor: UIColor.systemGreen.withAlphaComponent(0.2) + ) + + static let failure = StateLabelConfiguration( + font: .systemFont(ofSize: 14, weight: .medium), + color: .systemRed, + backgroundColor: UIColor.systemRed.withAlphaComponent(0.2) + ) +} diff --git a/RocketCall/Util/SymbolLabelStack.swift b/RocketCall/Util/SymbolLabelStack.swift new file mode 100644 index 0000000..847a950 --- /dev/null +++ b/RocketCall/Util/SymbolLabelStack.swift @@ -0,0 +1,41 @@ +// +// UIStackView+.swift +// RocketCall +// +// Created by t2025-m0143 on 3/24/26. +// + +import UIKit +import SnapKit + +// symbol label ๊ฐ€๋กœ ์Šคํƒ๋ทฐ ์ƒ์„ฑ์šฉ +class SymbolLabelStack: UIStackView { + + init(symbol: String, symbolColor: UIColor, label: UILabel) { + super.init(frame: .zero) + + let config = UIImage.SymbolConfiguration(scale: .small) + let symbol = UIImageView(image: UIImage(systemName: symbol, withConfiguration: config)) + + symbol.tintColor = symbolColor + + addArrangedSubview(symbol) + addArrangedSubview(label) + + axis = .horizontal + spacing = 5 + + symbol.setContentHuggingPriority(.required, for: .horizontal) + symbol.setContentCompressionResistancePriority(.required, for: .horizontal) + + self.snp.makeConstraints { + $0.height.equalTo(label.snp.height) + } + } + + + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/RocketCall/Util/TimeContainerView/ContainerInfoItem.swift b/RocketCall/Util/TimeContainerView/ContainerInfoItem.swift new file mode 100644 index 0000000..3fd439e --- /dev/null +++ b/RocketCall/Util/TimeContainerView/ContainerInfoItem.swift @@ -0,0 +1,19 @@ +// +// ContainerInfoItem.swift +// RocketCall +// +// Created by Yeseul Jang on 3/27/26. +// +import Foundation + +struct ContainerInfoItem { + let title: String + let value: String + let emoji: String? + + init(title: String, value: String, emoji: String? = nil) { + self.title = title + self.value = value + self.emoji = emoji + } +} diff --git a/RocketCall/Util/TimeContainerView/ContainerRowView.swift b/RocketCall/Util/TimeContainerView/ContainerRowView.swift new file mode 100644 index 0000000..3036206 --- /dev/null +++ b/RocketCall/Util/TimeContainerView/ContainerRowView.swift @@ -0,0 +1,81 @@ +// +// ContainerRowView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/27/26. +// +import UIKit +import SnapKit +import Then + +class ContainerRowView: UIView { + private let dotView = CircleContainerView(size: 8) + + private let titleLabel = UILabel(config: .sub16).then { + $0.font = .systemFont(ofSize: 16, weight: .bold) + } + + private let timeLabel = UILabel(config: .sub16).then { + $0.textColor = .systemBlue + } + + private let separatorView = SeparatorView() + + init(item: ContainerInfoItem, showsSeparator: Bool = true) { + super.init(frame: .zero) + configureUI() + setLayout() + configure(item: item, showsSeparator: showsSeparator) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureUI() { + addSubview(dotView) + addSubview(titleLabel) + addSubview(timeLabel) + addSubview(separatorView) + } + + // ๊ฐ’ ๋„ฃ์–ด์คŒ + private func configure(item: ContainerInfoItem, showsSeparator: Bool) { + if let iconText = item.emoji { + titleLabel.text = "\(iconText) \(item.title)" + } else { + titleLabel.text = item.title + } + + timeLabel.text = item.value + separatorView.isHidden = !showsSeparator + } + + private func setLayout() { + dotView.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.centerY.equalTo(titleLabel) + } + + // ์ œ๋ชฉ ๊ธฐ์ค€์œผ๋กœ ์žก์•„์„œ ์–˜ ๋”ฐ๋ผ์„œ ์žก์Œ(์œ„๊ฐ„๊ฒฉ ์—ฌ๊ธฐ์„œ) + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(18) + $0.leading.equalTo(dotView.snp.trailing).offset(20) + } + + timeLabel.snp.makeConstraints { + $0.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12) // ์ตœ์†Œ๊ฐ„๊ฒฉ๋งŒ ์คŒ + $0.trailing.equalToSuperview() + $0.centerY.equalTo(titleLabel) + } + + separatorView.snp.makeConstraints { + // ์•„๋ž˜๊ฐ„๊ฒฉ ์—ฌ๊ธฐ์„œ + $0.top.equalTo(titleLabel.snp.bottom).offset(18) + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview() + $0.height.equalTo(1) + $0.bottom.equalToSuperview() + } + } +} diff --git a/RocketCall/Util/TimeContainerView/Planet.swift b/RocketCall/Util/TimeContainerView/Planet.swift new file mode 100644 index 0000000..7b5d810 --- /dev/null +++ b/RocketCall/Util/TimeContainerView/Planet.swift @@ -0,0 +1,76 @@ +// +// TargetPlanet.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit + +// ํ–‰์„ฑ +enum Planet: Int, CaseIterable { + case earth = 0 + case moon + case mars + case venus + case mercury + case sun + case jupiter + case saturn + case uranus + case neptune + + var title: String { + switch self { + case .earth: "์ง€๊ตฌ" + case .moon: "๋‹ฌ" + case .mars: "ํ™”์„ฑ" + case .venus: "๊ธˆ์„ฑ" + case .mercury: "์ˆ˜์„ฑ" + case .sun: "ํƒœ์–‘" + case .jupiter: "๋ชฉ์„ฑ" + case .saturn: "ํ† ์„ฑ" + case .uranus: "์ฒœ์™•์„ฑ" + case .neptune: "ํ•ด์™•์„ฑ" + } + } + var emoji: String { + switch self { + case .earth: "๐ŸŒ" + case .moon: "๐ŸŒ™" + case .mars: "๐Ÿ”ด" + case .venus: "๐ŸŸก" + case .mercury: "โšช๏ธ" + case .sun: "โ˜€๏ธ" + case .jupiter: "๐ŸŸค" + case .saturn: "๐Ÿช" + case .uranus: "๐Ÿ”ต" + case .neptune: "๐Ÿ’ " + } + } + + var targetTime: Int { + switch self { + case .earth: 0 + case .moon: 2 // ์‹œ๊ฐ„ ๊ธฐ์ค€! ๋‹ฌ์€ 2์‹œ๊ฐ„ + case .mars: 10 // 10์‹œ๊ฐ„ + case .venus: 25 // ... + case .mercury: 55 + case .sun: 100 + case .jupiter: 250 + case .saturn: 500 + case .uranus: 1000 + case .neptune: 2000 + } + } +} + +extension Planet { + var listItem: ContainerInfoItem { + ContainerInfoItem( + title: title, + value: "\(targetTime)์‹œ๊ฐ„", + emoji: emoji + ) + } +} diff --git a/RocketCall/Util/TimeContainerView/TimeContainerView.swift b/RocketCall/Util/TimeContainerView/TimeContainerView.swift new file mode 100644 index 0000000..9e4bb6c --- /dev/null +++ b/RocketCall/Util/TimeContainerView/TimeContainerView.swift @@ -0,0 +1,105 @@ +// +// TimeContainerView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/27/26. +// +import UIKit +import SnapKit +import Then + +/* + +์˜ˆ๋ฆฐ๋‹˜ ์ด๋ ‡๊ฒŒ ์“ฐ์‹œ๋ฉด ๋ ๊ฑฐ ๊ฐ™์Šต๋‹ˆ๋‹ค ๐Ÿ‘ฝ (์ด๋ชจ์ง€ ๋ถ€๋ถ„์€ ์ƒ๋žต๊ฐ€๋Šฅ) + enum TargetPlanet: Int, CaseIterable { + case moon = 2 + case mars = 10 + case venus = 25 +....(์ƒ๋žต) + + var title: String { + switch self { + case .moon: return "๋‹ฌ" + case .mars: return "ํ™”์„ฑ" + case .venus: return "๊ธˆ์„ฑ" +....(์ƒ๋žต) + } + } + + var emoji: String { + switch self { + case .moon: return "๐ŸŒ™" + case .mars: return "๐Ÿ‘ฝ" + case .venus: return "๐Ÿ’ซ" +...(์ƒ๋žต) + } + } + } +// ์•„์ดํ…œ์œผ๋กœ ๋ฐ”๊ฟ”์ฃผ๋Š” ํ•จ์ˆ˜ ๋„ฃ์–ด์„œ ์“ฐ๋ฉด ํŽธํ• ๊ฑฐ๊ฐ™์Šต๋‹ˆ๋‹ค + extension TargetPlanet { + var listItem: InfoListItem { + InfoListItem( + title: title, + value: "\(rawValue)์‹œ๊ฐ„", + emoji: emoji + ) + } + } + + MARK: ์‚ฌ์šฉ์‹œ ----------------------- + + let items = TargetPlanet.allCases.map { $0.listItem } + lazy var containerView = TimeContainerView(items: self.items) + + ---------------------------------- + enum ์•ˆ์“ฐ๊ณ  ๋‹จ์ˆœํžˆ ๊ทธ๋ฆฌ๊ธฐ๋งŒ ํ• ๋•Œ (์ด๋ชจ์ง€ ๋„ฃ๋Š” ๋ถ€๋ถ„์€ ์ƒ๋žต๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค) + let items = [ + ContainerInfoItem(title: "์ œ๋ชฉ 1", value: "1์‹œ๊ฐ„", emoji: "๐Ÿ˜€"), + ContainerInfoItem(title: "์ œ๋ชฉ 2", value: "2์‹œ๊ฐ„", emoji: "๐Ÿ˜‡"), + ContainerInfoItem(title: "์ œ๋ชฉ 3", value: "3์‹œ๊ฐ„", emoji: "๐ŸŒ") + ] + lazy var containerView = TimeContainerView(items: self.items) + + */ + + + +final class TimeContainerView: BaseCardView { + + private let stackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 0 + } + + private let items: [ContainerInfoItem] + + init(items: [ContainerInfoItem]) { + self.items = items + super.init(frame: .zero) + + addSubview(stackView) + + setLayout() + setupRows() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // ํŒจ๋”ฉ๋งŒ์คŒ + private func setLayout() { + stackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(24) + } + } + + private func setupRows() { + for (index, item) in items.enumerated() { + // ๋ง‰์ค„๋งŒ ๊ตฌ๋ถ„์„  ์ง€์›Œ์คŒ + let isLast = index == items.count - 1 + let rowView = ContainerRowView(item: item, showsSeparator: !isLast) + stackView.addArrangedSubview(rowView) + } + } +} diff --git a/RocketCall/Util/TitleView.swift b/RocketCall/Util/TitleView.swift new file mode 100644 index 0000000..31bd3eb --- /dev/null +++ b/RocketCall/Util/TitleView.swift @@ -0,0 +1,78 @@ +// +// TitleView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// + +import UIKit +import SnapKit + +/// ๊ฐ ํ™”๋ฉด์˜ ์ƒ๋‹จ์— ํƒ€์ดํ‹€์„ ํ‘œ์‹œํ•˜๋Š” TitleView์ž…๋‹ˆ๋‹ค. +/// ์‚ฌ์šฉํ•˜์‹ค ๋•Œ๋Š” navigationBar๋ฅผ Hidden ์‹œ์ผœ์ฃผ์„ธ์š”. +/// - Parameters: +/// - title: ํ™”๋ฉด์˜ ๋ฉ”์ธ ํƒ€์ดํ‹€ +/// - subTitle: ํ™”๋ฉด์˜ ์„œ๋ธŒ ํƒ€์ดํ‹€(๋ถ€๊ฐ€ ์„ค๋ช…) +/// - hasButton: addButton ํ‘œ์‹œ ์—ฌ๋ถ€ +/// - **Example** +///```swift +/// TitleView( +/// title: "์•Œ๋žŒ", +/// subTitle: "์•Œ๋žŒ์„ ์„ค์ •ํ•ด์ฃผ์„ธ์š”", +/// hasButton: true // addButton ํ‘œ์‹œ ์—ฌ๋ถ€ +/// ) +/// +/// // ์‚ฌ์šฉ๋ถ€์—์„œ๋Š” navigationBar๋ฅผ Hidden ์‹œ์ผœ์ฃผ์„ธ์š” +/// navigationController?.isNavigationBarHidden = true +/// ``` + +class TitleView: UIView { + let titleLabel: UILabel + let subTitleLabel: UILabel + let addButton: UIButton = UIButton(configuration: .plain()) + + init(title: String, subTitle: String, hasButton: Bool) { + titleLabel = UILabel(text: title, config: LabelConfiguration.title) + subTitleLabel = UILabel(text: subTitle, config: LabelConfiguration.subTitle) + addButton.isHidden = !hasButton + super.init(frame: .zero) + + setAttributes() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension TitleView { + private func setAttributes() { + backgroundColor = .clear + + let symbolConfig = UIImage.SymbolConfiguration(weight: .heavy) + addButton.setImage(UIImage(systemName: "plus.circle.fill", withConfiguration: symbolConfig), for: .normal) + addButton.tintColor = .mainPoint + } + + private func setLayout() { + let stackView = UIStackView(arrangedSubviews: [titleLabel, subTitleLabel]) + stackView.axis = .vertical + stackView.spacing = 5 + stackView.alignment = .leading + + addSubview(stackView) + addSubview(addButton) + + stackView.snp.makeConstraints { + $0.top.bottom.leading.equalToSuperview().inset(20) + $0.trailing.equalTo(addButton.snp.leading).offset(-10) + } + + addButton.snp.makeConstraints { + $0.centerY.equalTo(stackView) + $0.trailing.equalToSuperview().inset(20) + $0.width.height.equalTo(45) + } + } +} diff --git a/RocketCall/Util/UILabel+.swift b/RocketCall/Util/UILabel+.swift index 70c24ca..2408e1b 100644 --- a/RocketCall/Util/UILabel+.swift +++ b/RocketCall/Util/UILabel+.swift @@ -13,7 +13,7 @@ extension UILabel { self.numberOfLines = config.lines } - convenience init(text: String, config: LabelConfiguration) { + convenience init(text: String = "", config: LabelConfiguration) { self.init() self.text = text apply(config) diff --git a/RocketCall/Util/UIViewController+.swift b/RocketCall/Util/UIViewController+.swift new file mode 100644 index 0000000..256bed1 --- /dev/null +++ b/RocketCall/Util/UIViewController+.swift @@ -0,0 +1,21 @@ +// +// UIViewController+.swift +// RocketCall +// +// Created by t2025-m0143 on 3/26/26. +// + +import UIKit +import RxSwift +import RxCocoa + +// ViewController์˜ ๋ฉ”์„œ๋“œ๋ฅผ Observable ๊ฐ์ฒด๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ™•์žฅ +extension Reactive where Base: UIViewController { + var viewWillAppear: Observable { + methodInvoked(#selector(Base.viewWillAppear(_:))).map { _ in } + } + + var viewDidAppear: Observable { + methodInvoked(#selector(Base.viewDidAppear(_:))).map { _ in } + } +} diff --git a/RocketCall/View/Alarm/AlarmListViewCell.swift b/RocketCall/View/Alarm/AlarmListViewCell.swift new file mode 100644 index 0000000..d197a98 --- /dev/null +++ b/RocketCall/View/Alarm/AlarmListViewCell.swift @@ -0,0 +1,173 @@ +// +// AlarmListViewCell.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/23/26. +// + +import UIKit +import SnapKit +import Then + + +final class AlarmListViewCell: UICollectionViewCell { + + static let identifier = "AlarmListViewCell" + var onToggleTapped: ((Bool) -> Void)? + + + // MARK: - UI Components + private let containerView = BaseCardView() + + private let timeLabel = UILabel().then { + $0.font = UIFont.systemFont(ofSize: 60, weight: .light) + $0.textColor = .mainLabel + } + + private let toggle = UISwitch().then { + $0.onTintColor = .mainPoint + } + + private let titleStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 6 + } + + private let alarmIcon = UIImageView() + + private let titleLabel = UILabel().then { + $0.apply(.missionLabel) + } + + private let dateStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 6 + $0.alignment = .leading + } + + + // MARK: - prepareForReuse + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + timeLabel.text = nil + alarmIcon.image = nil + onToggleTapped = nil + dateStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + } + + + // MARK: - init + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + toggle.addTarget(self, action: #selector(toggleChanged), for: .valueChanged) + } + + required init?(coder: NSCoder) { fatalError() } + + + // MARK: - setup Layout + private func setupLayout() { + + contentView.addSubview(containerView) + + [timeLabel, toggle, titleStackView, dateStackView].forEach { containerView.addSubview($0) } + [alarmIcon, titleLabel].forEach { titleStackView.addArrangedSubview($0) } + + containerView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(20) + $0.verticalEdges.equalToSuperview().inset(6) + $0.height.equalTo(184).priority(999) + } + + timeLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(15) + $0.leading.equalToSuperview().inset(20) + } + + toggle.snp.makeConstraints { + $0.centerY.equalTo(timeLabel) + $0.trailing.equalToSuperview().inset(20) + } + + titleStackView.snp.makeConstraints { + $0.top.equalTo(timeLabel.snp.bottom).offset(6) + $0.leading.equalTo(timeLabel.snp.leading) + } + + dateStackView.snp.makeConstraints { + $0.leading.equalTo(timeLabel.snp.leading) + $0.bottom.equalToSuperview().inset(20) + } + } + + + // MARK: - ๋ฐ์ดํ„ฐ ์ž…๋ ฅ + func configureAlarmListViewCell(with alarm: Alarm) { + timeLabel.text = String(format: "%02d:%02d", alarm.hour, alarm.minute) + titleLabel.text = alarm.title + + toggle.isOn = alarm.isOn + updateUI(isOn: alarm.isOn) + + dateStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } // ์…€ ์žฌ์‚ฌ์šฉ ๋ฒ„๊ทธ ๋ฐฉ์ง€ + let sortedDays = alarm.repeatDays.sorted { $0.rawValue < $1.rawValue } + + // 7์ผ ๋ชจ๋‘ ์„ ํƒํ–ˆ์„ ๋•Œ + if sortedDays.count == 7 { + let badge = createDateBadge(text: "๋งค์ผ") + dateStackView.addArrangedSubview(badge) + + } else { + for day in sortedDays { + let badge = createDateBadge(text: day.koreanName) + dateStackView.addArrangedSubview(badge) + } + } + } + + private func updateUI(isOn: Bool) { + containerView.isOn = isOn + alarmIcon.image = isOn ? UIImage(systemName: "alarm.waves.left.and.right.fill") : UIImage(systemName: "alarm") + alarmIcon.tintColor = isOn ? .mainPoint : .subLabel + } + + @objc private func toggleChanged() { + let isOn = toggle.isOn + updateUI(isOn: isOn) + onToggleTapped?(isOn) + } + + + // MARK: - ๋ฑƒ์ง€ ์ฐ์–ด๋‚ด๋Š” ๋ฉ”์„œ๋“œ + private func createDateBadge(text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 13) + label.textAlignment = .center + label.layer.cornerRadius = 10 + label.layer.masksToBounds = true + + // ๋ฑƒ์ง€ ์ƒ‰์ƒ + if text == "๋งค์ผ" { + label.textColor = .mainLabel + label.backgroundColor = .subLabel.withAlphaComponent(0.2) + } else { + label.textColor = .mainPoint + label.backgroundColor = .mainPoint.withAlphaComponent(0.2) + } + + // ๋ฑƒ์ง€ ๋„ˆ๋น„ + label.snp.makeConstraints { + if label.text == "๋งค์ผ" { + $0.width.equalTo(42) + } else { + $0.width.equalTo(30) + } + $0.height.equalTo(24) + } + return label + } +} diff --git a/RocketCall/View/Alarm/AlarmListViewController.swift b/RocketCall/View/Alarm/AlarmListViewController.swift new file mode 100644 index 0000000..1d264c3 --- /dev/null +++ b/RocketCall/View/Alarm/AlarmListViewController.swift @@ -0,0 +1,192 @@ +// +// AlarmListViewController.swift +// +// +// Created by ๊น€์ฃผํฌ on 3/23/26. +// + +import UIKit +import SnapKit +import Then +import RxSwift +import RxCocoa + + +final class AlarmListViewController: UIViewController { + + private let disposeBag = DisposeBag() + private let viewWillAppearTrigger = PublishRelay() // ํ™”๋ฉด ์ƒ์„ฑ ๋ฒจ + private let refreshTrigger = PublishRelay() // ์ƒˆ๋กœ๊ณ ์นจ ๋ฒจ + private let deleteEvent = PublishRelay() // ์‚ญ์ œ ๋ฒจ + private let toggleEvent = PublishRelay<(UUID, Bool)>() // ํ† ๊ธ€ ๋ฒจ + + + // MARK: - UI Components + private let titleView = TitleView(title: "์•Œ๋žŒ", subTitle: "์•Œ๋žŒ์„ ์„ค์ •ํ•ด์ฃผ์„ธ์š”", hasButton: true) + + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()).then { + $0.backgroundColor = .background + $0.register(AlarmListViewCell.self, forCellWithReuseIdentifier: AlarmListViewCell.identifier) + } + + private func createLayout() -> UICollectionViewLayout { + + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.showsSeparators = false + config.backgroundColor = .background + + // ์Šค์™€์ดํ”„ ์‚ญ์ œ ์•ก์…˜ + config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + self?.makeDeleteSwipeAction(for: indexPath) + } + return UICollectionViewCompositionalLayout.list(using: config) + } + + private func makeDeleteSwipeAction(for indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard let alarm = dataSource.itemIdentifier(for: indexPath) else { return nil } + + let deleteAction = UIContextualAction(style: .destructive, title: "") { [weak self] _, _, completion in + self?.deleteEvent.accept(alarm) + completion(true) + } + deleteAction.image = UIImage(systemName: "trash.fill") + + return UISwipeActionsConfiguration(actions: [deleteAction]) + } + + + // MARK: - Diffable DataSource + enum Section { + case main + } + + typealias Item = Alarm + + private var dataSource: UICollectionViewDiffableDataSource! + + private func setDataSource() { + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, alarmItem in + + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AlarmListViewCell.identifier, for: indexPath) as? AlarmListViewCell else { + return UICollectionViewCell() + } + // ์…€์— ๋ฐ์ดํ„ฐ ์‚ฝ์ž… + cell.configureAlarmListViewCell(with: alarmItem) + + // ์…€์—์„œ ์ „๋‹ฌ๋ฐ›์•„ ํ† ๊ธ€์ด๋ฒคํŠธ์— ๋„ฃ์–ด์คŒ + cell.onToggleTapped = { [weak self] isOn in + self?.toggleEvent.accept((alarmItem.id, isOn)) + } + return cell + } + } + + private func applySnapshot(with alarms: [Alarm]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(alarms, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: true) + } + + + // MARK: - ์ดˆ๊ธฐํ™” + private let viewModel: AlarmListViewModel + + init(viewModel: AlarmListViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setDataSource() + bind() + + navigationController?.isNavigationBarHidden = true + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewWillAppearTrigger.accept(()) + } + + + // MARK: - Setup UI + private func setupUI() { + view.backgroundColor = .background + view.addSubview(titleView) + view.addSubview(collectionView) + + titleView.snp.makeConstraints { + $0.top.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(titleView.snp.bottom).offset(10) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + + + // MARK: - Binding + private func bind() { + + // MARK: Input + let input = AlarmListViewModel.Input( + viewWillAppear: viewWillAppearTrigger.asObservable(), + refreshTrigger: refreshTrigger.asObservable(), + deleteAlarm: deleteEvent.asObservable(), + addTapped: titleView.addButton.rx.tap.asObservable(), + itemSelected: collectionView.rx.itemSelected.compactMap { [weak self] in self?.dataSource.itemIdentifier(for: $0) }, + toggleAlarm: toggleEvent.asObservable() + ) + + + // MARK: Input -> VM + let output = viewModel.transform(input) + + + // MARK: VM -> Output + // ์ตœ์‹  ์•Œ๋žŒ ๋ชฉ๋ก์œผ๋กœ ๊ฐฑ์‹ ํ•ด์„œ ๊ทธ๋ฆฌ๊ธฐ + output.alarms + .drive(onNext: { [weak self] alarms in + self?.applySnapshot(with: alarms) + }) + .disposed(by: disposeBag) + + // ๋ชจ๋‹ฌ ๋„์šฐ๊ธฐ + output.showSettingModal + .drive(onNext: { [weak self] payload in + let vc = AlarmSettingViewController() + vc.viewModel = AlarmSettingViewModel(existingAlarm: payload) + vc.onSaveSuccess = { + self?.refreshTrigger.accept(()) + } + self?.present(vc, animated: true) + + if let selected = self?.collectionView.indexPathsForSelectedItems?.first { + self?.collectionView.deselectItem(at: selected, animated: true) + } + }) + .disposed(by: disposeBag) + + // ์•Œ๋žŒ ํ™”๋ฉด ๋„์šฐ๊ธฐ + NotificationManager.shared.alarmRingingEvent + .observe(on: MainScheduler.instance) + .bind(with: self) { owner, data in + let (title, id) = data + + let ringVC = AlarmRingViewController(title: title, alarmID: id) + + ringVC.modalPresentationStyle = .fullScreen + + owner.present(ringVC, animated: true) + + } + .disposed(by: disposeBag) + } +} + diff --git a/RocketCall/View/Alarm/AlarmRingView.swift b/RocketCall/View/Alarm/AlarmRingView.swift new file mode 100644 index 0000000..82bffe3 --- /dev/null +++ b/RocketCall/View/Alarm/AlarmRingView.swift @@ -0,0 +1,252 @@ +// +// AlarmRingView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/23/26. +// +import UIKit +import SnapKit +import Then + +/* + ์ƒ์„ฑ์‹œ ์˜ˆ์‹œ + let alarmView = AlarmRingView( + time: "07:00", + date: "3์›” 23์ผ ์›”์š”์ผ", + title: "๊ธฐ์ƒ" + ) +*/ + +final class AlarmRingView: UIView { + private let time: String + private let date: String + private let title: String + private let notificationCenter: NotificationCenter + + private let circleContainerView = CircleContainerView(size: 180) + + private let alarmImageView = UIImageView().then { + $0.image = UIImage(systemName: "bell") + $0.tintColor = .white + $0.contentMode = .scaleAspectFit + } + + let timeLabel = UILabel().then { + $0.textColor = .white + $0.font = .systemFont(ofSize: 90) + $0.textAlignment = .center + } + + let dateLabel = UILabel().then { + $0.textColor = UIColor.subLabel.withAlphaComponent(0.7) + $0.font = .systemFont(ofSize: 20, weight: .semibold) + $0.textAlignment = .center + } + + private let alarmInfoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 10 + $0.alignment = .center + } + + private let dotView = CircleContainerView(size: 12) + + let alarmTitleLabel = UILabel().then { + $0.textColor = .white + $0.font = .systemFont(ofSize: 20, weight: .bold) + $0.textAlignment = .center + } + + let stopButton = RectangleButton(title: "์ค‘์ง€", color: .mainPoint).then { + $0.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + } + + let snoozeButton = RectangleButton(title: "๋‹ค์‹œ ์•Œ๋ฆผ (5๋ถ„)", color: .subLabel.withAlphaComponent(0.2)).then { + $0.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + } + + private let bottomGuideStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 12 + $0.alignment = .center + } + + private let leftLine = UIView().then { + $0.backgroundColor = UIColor.white.withAlphaComponent(0.2) + $0.layer.cornerRadius = 2 + } + + let guideLabel = UILabel().then { + $0.text = "์œ„๋กœ ์Šค์™€์ดํ”„ํ•˜์—ฌ ์ค‘์ง€" + $0.textColor = UIColor.white.withAlphaComponent(0.4) + $0.font = .systemFont(ofSize: 16, weight: .medium) + $0.textAlignment = .center + } + + private let rightLine = UIView().then { + $0.backgroundColor = UIColor.white.withAlphaComponent(0.2) + $0.layer.cornerRadius = 2 + } + + init( + time: String, + date: String, + title: String, + notificationCenter: NotificationCenter = .default + ) { + self.time = time + self.date = date + self.title = title + self.notificationCenter = notificationCenter + super.init(frame: .zero) + + configureUI() + setupLayout() + bindData() + + // ์•ฑ ์ƒํƒœ๋ฅผ ๊ฐ์ง€ํ•˜๋Š” ๋ถ€๋ถ„ + // didBecomeActiveNotification(์•ฑ์ด ๋‹ค์‹œ ํ™œ์„ฑํ™” ๋  ๋•Œ ํ˜ธ์ถœ๋จ) + // ์•ฑ์ด ์ผœ์ง€๋ฉด handleDidBecomeActive๋ฅผ ์‹คํ–‰ํ•˜๋ผ๊ณ  ์„ค์ •ํ•จ + notificationCenter.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + startAnimations() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // ์ˆœํ™˜์ฐธ์กฐ๋ฅผ ๋ง‰๋Š” ์˜ต์ €๋ฒ„ ์ œ๊ฑฐ ๋ถ€๋ถ„ + deinit { + notificationCenter.removeObserver(self) + } + + // ๋ทฐ๊ฐ€ ํ™”๋ฉด์— ๋ถ™๊ณ /๋–จ์–ด์งˆ๋•Œ ํ˜ธ์ถœ๋จ - ํ™”๋ฉด์ด ๋ณด์ด๋Š”๊ฑธ ๋ถ„๊ธฐ๋กœ ์‹คํ–‰์„ ๋‚˜๋ˆ” + override func didMoveToWindow() { + super.didMoveToWindow() + + if window != nil { + restartAnimations() + } else { + stopAnimations() + } + } + + @objc private func handleDidBecomeActive() { + restartAnimations() + } + + // ์• ๋‹ˆ๋ฉ”์ด์…˜์€ ๊ผฌ์ผ ์ˆ˜ ์žˆ๊ธฐ๋•Œ๋ฌธ์— ๋ฉˆ์ถ”๊ณ  ๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผํ•จ + private func restartAnimations() { + stopAnimations() + startAnimations() + } + + // ํ˜„์žฌ ๊ฐ€์ง„ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๊ฐ•์ œ ์ข…๋ฃŒ + private func stopAnimations() { + layer.removeAllAnimations() + circleContainerView.layer.removeAllAnimations() + alarmImageView.layer.removeAllAnimations() + + // .identity(์›๋ž˜์ƒํƒœ)๋กœ ๋ฐ”๊พธ์–ด์คŒ + circleContainerView.transform = .identity + alarmImageView.transform = .identity + } + + private func bindData() { + timeLabel.text = time + dateLabel.text = date + alarmTitleLabel.text = title + } + + private func configureUI() { + backgroundColor = UIColor.background + + addSubview(circleContainerView) + circleContainerView.addSubview(alarmImageView) + + addSubview(timeLabel) + addSubview(dateLabel) + + addSubview(alarmInfoStackView) + alarmInfoStackView.addArrangedSubview(dotView) + alarmInfoStackView.addArrangedSubview(alarmTitleLabel) + + addSubview(stopButton) + addSubview(snoozeButton) + } + + private func setupLayout() { + circleContainerView.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(45) + $0.centerX.equalToSuperview() + } + + alarmImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(CGSize(width: 90, height: 90)) + } + + timeLabel.snp.makeConstraints { + $0.top.equalTo(circleContainerView.snp.bottom).offset(10) + $0.leading.trailing.equalToSuperview().inset(24) + } + + dateLabel.snp.makeConstraints { + $0.top.equalTo(timeLabel.snp.bottom) + $0.centerX.equalToSuperview() + } + + alarmInfoStackView.snp.makeConstraints { + $0.top.equalTo(dateLabel.snp.bottom).offset(15) + $0.centerX.equalToSuperview() + } + + snoozeButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(40) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-75) + $0.height.equalTo(65) + } + + stopButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(40) + $0.bottom.equalTo(snoozeButton.snp.top).offset(-16) + $0.height.equalTo(65) + } + } +} + +extension AlarmRingView { + private func startAnimations() { + startPulseAnimation() + startFloatingAnimation() + } + + private func startPulseAnimation() { + UIView.animate( + withDuration: 0.8, + delay: 0, + options: [.autoreverse, .repeat, .allowUserInteraction], + animations: { + self.circleContainerView.transform = CGAffineTransform(scaleX: 1.06, y: 1.06) + } + ) + } + + private func startFloatingAnimation() { + UIView.animate( + withDuration: 0.4, + delay: 0, + options: [.autoreverse, .repeat, .allowUserInteraction], + animations: { + self.alarmImageView.transform = CGAffineTransform(scaleX: 1.08, y: 1.08) + } + ) + } +} + diff --git a/RocketCall/View/Alarm/AlarmRingViewController.swift b/RocketCall/View/Alarm/AlarmRingViewController.swift new file mode 100644 index 0000000..aa4f07f --- /dev/null +++ b/RocketCall/View/Alarm/AlarmRingViewController.swift @@ -0,0 +1,180 @@ +// +// Untitled.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/26/26. +// + +import UIKit +import Foundation +import AVFoundation +import RxSwift +import RxCocoa +import AudioToolbox + +final class AlarmRingViewController: UIViewController { + + private var audioPlayer: AVAudioPlayer? + private var vibrationTimer: Timer? + private let disposeBag = DisposeBag() + private let alarmRingTitle: String + private let alarmId: UUID + private let coreDataManager = CoreDataManager() + + private lazy var alarmRingView: AlarmRingView = { + let now = Date() // ํ˜„ ์‹œ๊ฐ„ ๊ฐ€์ ธ์˜ด + + // ์‹œ๊ฐ„ + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "HH:mm" + let currentTimeString = timeFormatter.string(from: now) + + // ๋‚ ์งœ + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "ko_KR") // ํ•œ๊ตญ์–ด ์„ธํŒ… + dateFormatter.dateFormat = "M์›” d์ผ EEEE" + let currentDateString = dateFormatter.string(from: now) + + return AlarmRingView(time: currentTimeString, date: currentDateString, title: alarmRingTitle) + }() + + init(title: String, alarmID: UUID) { + self.alarmRingTitle = title + self.alarmId = alarmID + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + + override func loadView() { + view = alarmRingView + } + + override func viewDidLoad() { + super.viewDidLoad() + + playSound() + startVibration() + bind() + } + + + // MARK: - ์†Œ๋ฆฌ ๋ฌดํ•œ ์žฌ์ƒ + private func playSound() { + guard let url = Bundle.main.url(forResource: "AlarmSound", withExtension: "wav") else { + print("์‚ฌ์šด๋“œ ํŒŒ์ผ ์˜ค๋ฅ˜") + return + } + + do { + // ๋ฌด์Œ๋ชจ๋“œ ์ผ๋•Œ๋„ ์†Œ๋ฆฌ๋‚˜๊ฒŒ ํ•˜๊ธฐ + try AVAudioSession.sharedInstance().setCategory(.playback, mode: . default) + try AVAudioSession.sharedInstance().setActive(true) + + // ํ”Œ๋ ˆ์ด์–ด ์„ธํŒ…ํ•˜๊ธฐ + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.numberOfLoops = -1 // ๋ฌดํ•œ ๋ฐ˜๋ณต + audioPlayer?.volume = 1.0 // ์ตœ๋Œ€ ๋ณผ๋ฅจ + + audioPlayer?.play() + } catch { + print("ํ”Œ๋ ˆ์ด์–ด ์žฌ์ƒ ์‹คํŒจ: \(error)") + } + } + + + // MARK: - ์ง„๋™ ๋ฌดํ•œ ์žฌ์ƒ + private func startVibration() { + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) // ํ™”๋ฉด ํ‚ค์ž๋งˆ์ž ์ง„๋™ + + // 2์ดˆ์— ํ•œ๋ฒˆ์”ฉ ์ง„๋™ + vibrationTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + } + } + + + // MARK: - ์•Œ๋žŒ ํ•ด์ œ ๋กœ์ง + private func bind() { + + // ์•Œ๋žŒ ๋„๊ธฐ ๋ฒ„ํŠผ + alarmRingView.stopButton.rx.tap + .bind(onNext: { [weak self] _ in + guard let self = self else { return } + self.audioPlayer?.stop() + self.vibrationTimer?.invalidate() + self.vibrationTimer = nil + + NotificationManager.shared.cancelAlarm(String(self.alarmId.uuidString.prefix(36))) // ์•Œ๋žŒ cancel + + NotificationManager.shared.currentRingingId = nil + + do { + let payload = try self.coreDataManager.fetchAlarm(of: self.alarmId) + + if payload.repeatDays.isEmpty { + var updatedPayload = payload + updatedPayload.isOn = false // ์š”์ผ ๋ฐ˜๋ณต ์—†๋Š” ์•Œ๋žŒ์ด๋ฉด ํ† ๊ธ€ off + try self.coreDataManager.updateAlarmEntity(of: updatedPayload) + + + } else { // ์š”์ผ ๋ฐ˜๋ณตํ•˜๋Š” ์•Œ๋žŒ์ด๋ฉด ์•Œ๋žŒ ๋‹ค์‹œ ๋“ฑ๋ก + + // ์•ฑ์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ƒํƒœ์—ฌ๋„ ์•ฑ์ด ์ฃฝ์ง€์•Š๋„๋ก ์š”์ฒญ + var bgTask: UIBackgroundTaskIdentifier = .invalid + bgTask = UIApplication.shared.beginBackgroundTask(withName: "RescheduleAlarm") { + // ๋งŒ์•ฝ ์‹œ์Šคํ…œ์ด ์‹œ๊ฐ„์„ ๋” ์ด์ƒ ๋ชป ์ฃผ๊ฒ ๋‹ค๊ณ  ํ•˜๋ฉด ํƒœ์Šคํฌ ์ข…๋ฃŒ + UIApplication.shared.endBackgroundTask(bgTask) + bgTask = .invalid + } + + // 24์ดˆ ๋”œ๋ ˆ์ด + DispatchQueue.global().asyncAfter(deadline: .now() + 27.0) { + + let alarmToReschedule = Alarm( + id: payload.id, + hour: payload.hour, + minute: payload.minute, + title: payload.title, + repeatDays: payload.repeatDays.compactMap { WeekDay(rawValue: $0) }, + isOn: payload.isOn + ) + + NotificationManager.shared.addAlarm(alarmToReschedule) + + // ์žฌ๋“ฑ๋ก์ด ๋๋‚ฌ์œผ๋‹ˆ,์ข…๋ฃŒ + UIApplication.shared.endBackgroundTask(bgTask) + bgTask = .invalid + } + + } + } catch { + print("์•Œ๋žŒ ํ† ๊ธ€ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์‹คํŒจ: \(error)") + } + + self.dismiss(animated: true) + }) + .disposed(by: disposeBag) + + + // ์Šค๋ˆ„์ฆˆ ๋ฒ„ํŠผ + alarmRingView.snoozeButton.rx.tap + .bind(with: self) { owner, _ in + owner.audioPlayer?.stop() + owner.vibrationTimer?.invalidate() + owner.vibrationTimer = nil + + NotificationManager.shared.cancelAlarm(String(self.alarmId.uuidString.prefix(36))) // ์•Œ๋žŒ cancel + + NotificationManager.shared.addSnoozeAlarm(title: owner.alarmRingTitle, originalId: owner.alarmId) + + NotificationManager.shared.currentRingingId = nil + + + owner.dismiss(animated: true) + } + .disposed(by: disposeBag) + + } +} diff --git a/RocketCall/View/Alarm/AlarmSettingView.swift b/RocketCall/View/Alarm/AlarmSettingView.swift new file mode 100644 index 0000000..045af3f --- /dev/null +++ b/RocketCall/View/Alarm/AlarmSettingView.swift @@ -0,0 +1,241 @@ +// +// presentSettingView.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/23/26. +// + +import UIKit +import SnapKit +import Then +import RxSwift +import RxCocoa + + +final class AlarmSettingView: UIView { + + + // MARK: - UI Components + private let containerView = BaseCardView() + + private let titleLabel = UILabel().then { + $0.text = "์•Œ๋žŒ ์„ค์ •" + $0.apply(.title) + } + + let closeButton = UIButton().then { + let config = UIImage.SymbolConfiguration(weight: .semibold) + $0.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal) + $0.tintColor = .mainLabel + } + + private let alarmTimeStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .center + } + + private let watchImage = UIImageView().then { + $0.image = UIImage(systemName: "alarm") + $0.tintColor = .subPoint + } + + private let alarmTimeLabel = UILabel().then { + $0.text = "์•Œ๋žŒ ์‹œ๊ฐ„" + $0.textColor = .subPoint + $0.font = .systemFont(ofSize: 16, weight: .bold) + } + + let timePickerView = UIDatePicker().then { + $0.datePickerMode = .time + $0.preferredDatePickerStyle = .wheels + $0.overrideUserInterfaceStyle = .dark + } + + private let alarmNameTitle = UILabel().then { + $0.font = .systemFont(ofSize: 16, weight: .bold) + $0.text = "์•Œ๋žŒ ์ด๋ฆ„" + $0.textColor = .mainLabel + } + + let alarmTextField = UITextField().then { + $0.attributedPlaceholder = NSAttributedString( + string: "์˜ˆ: ๊ธฐ์ƒ", + attributes: [.foregroundColor: UIColor.subLabel.withAlphaComponent(0.8)] + ) + $0.textColor = .mainLabel + $0.layer.cornerRadius = 12 + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.subLabel.withAlphaComponent(0.6).cgColor + + // ์™ผ์ชฝ ์—ฌ๋ฐฑ + $0.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 14, height: 40)) + $0.leftViewMode = .always + } + + private let repeatDayTitleLabel = UILabel().then { + $0.font = .systemFont(ofSize: 16, weight: .bold) + $0.text = "๋ฐ˜๋ณต ์š”์ผ" + $0.textColor = .mainLabel + } + + private let dayButtonsStackView = UIStackView().then { + $0.axis = .horizontal + $0.distribution = .equalSpacing + $0.alignment = .center + $0.spacing = 6 + } + + private(set) var dayButtons: [DayButton] = [] + + private func setupDayButtons() { + let days = ["์›”", "ํ™”", "์ˆ˜", "๋ชฉ", "๊ธˆ", "ํ† ", "์ผ"] + + for (index, day) in days.enumerated() { + let button = DayButton(title: day) + button.tag = index + button.addTarget(self, action: #selector(dayButtonTapped), for: .touchUpInside) + + button.snp.makeConstraints { + $0.width.equalTo(38) + $0.height.equalTo(50) + } + + dayButtonsStackView.addArrangedSubview(button) + dayButtons.append(button) + } + } + + @objc private func dayButtonTapped(_ sender: DayButton) { + sender.isSelected.toggle() + } + + private let buttonStackView = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .center + $0.spacing = 12 + $0.distribution = .fillEqually + } + + let cancelButton = RectangleButton(title: "์ทจ์†Œ", color: .subLabel.withAlphaComponent(0.2)) + let saveButton = RectangleButton(title: "์ €์žฅ", color: .mainPoint) + + + // MARK: - init + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupDayButtons() + } + + required init?(coder: NSCoder) { fatalError() } + + + // MARK: - setup UI + private func setupUI() { + containerView.isOn = true + + self.addSubview(containerView) + + [titleLabel, closeButton, alarmTimeStackView, timePickerView, alarmNameTitle, alarmTextField, repeatDayTitleLabel, dayButtonsStackView, buttonStackView].forEach { containerView.addSubview($0) } + + [watchImage, alarmTimeLabel].forEach { alarmTimeStackView.addArrangedSubview($0) } + + [cancelButton, saveButton].forEach { buttonStackView.addArrangedSubview($0) } + + containerView.snp.makeConstraints { + $0.centerY.equalToSuperview().offset(-40) + $0.height.equalTo(590) + $0.horizontalEdges.equalToSuperview().inset(20) + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(25) + $0.leading.equalToSuperview().inset(25) + } + + closeButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalToSuperview() + $0.height.width.equalTo(70) + } + + alarmTimeStackView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(20) + $0.centerX.equalToSuperview() + } + + timePickerView.snp.makeConstraints { + $0.top.equalTo(alarmTimeStackView.snp.bottom) + $0.centerX.equalToSuperview() + } + + alarmNameTitle.snp.makeConstraints { + $0.top.equalTo(timePickerView.snp.bottom) + $0.leading.equalTo(titleLabel.snp.leading) + } + + alarmTextField.snp.makeConstraints { + $0.top.equalTo(alarmNameTitle.snp.bottom).offset(8) + $0.leading.equalTo(titleLabel.snp.leading) + $0.trailing.equalToSuperview().inset(25) + $0.height.equalTo(50) + } + + repeatDayTitleLabel.snp.makeConstraints { + $0.top.equalTo(alarmTextField.snp.bottom).offset(25) + $0.leading.equalTo(titleLabel.snp.leading) + } + + dayButtonsStackView.snp.makeConstraints { + $0.top.equalTo(repeatDayTitleLabel.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + + buttonStackView.snp.makeConstraints { + $0.leading.bottom.trailing.equalToSuperview().inset(25) + $0.centerX.equalToSuperview() + } + + cancelButton.snp.makeConstraints { + $0.height.equalTo(50) + } + + saveButton.snp.makeConstraints { + $0.height.equalTo(cancelButton) + } + } +} + + +// ์š”์ผ๋ฒ„ํŠผ ์ƒ์„ฑ๊ธฐ +final class DayButton: UIButton { + + override var isSelected: Bool { + didSet { + updateColors() + } + } + + init(title: String) { + super.init(frame: .zero) + + self.setTitle(title, for: .normal) + self.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium) + self.layer.cornerRadius = 12 + self.layer.masksToBounds = true + updateColors() + } + + required init?(coder: NSCoder) { fatalError() } + + private func updateColors() { + if isSelected { + setTitleColor(.mainPoint, for: .normal) + backgroundColor = .mainPoint.withAlphaComponent(0.2) + } else { + setTitleColor(.mainLabel, for: .normal) + backgroundColor = .subLabel.withAlphaComponent(0.2) + } + } +} diff --git a/RocketCall/View/Alarm/AlarmSettingViewController.swift b/RocketCall/View/Alarm/AlarmSettingViewController.swift new file mode 100644 index 0000000..2c8b7bf --- /dev/null +++ b/RocketCall/View/Alarm/AlarmSettingViewController.swift @@ -0,0 +1,101 @@ +// +// AlarmSettingiewController.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/23/26. +// + +import UIKit +import RxSwift +import RxCocoa + + +final class AlarmSettingViewController: UIViewController { + + private let disposeBag = DisposeBag() + private let alarmSettingView = AlarmSettingView() + var viewModel = AlarmSettingViewModel(existingAlarm: nil) + var onSaveSuccess: (() -> Void)? // ์ €์žฅ ์„ฑ๊ณตํ–ˆ์„๋•Œ ListVC๋กœ ๋ณด๋‚ผ ํด๋กœ์ € + + + override func loadView() { + view = alarmSettingView + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .background + bind() + } + + + // MARK: - Binding + private func bind() { + // ์š”์ผ ๋ฒ„ํŠผ tag ์ „๋‹ฌ + let dayToggled = Observable.merge( + alarmSettingView.dayButtons.map { button in + button.rx.tap.map { button.tag } + } + ) + + // ์ทจ์†Œ,๋‹ซ๊ธฐ ๋ฒ„ํŠผ merge + let cancelOrClose = Observable.merge( + alarmSettingView.cancelButton.rx.tap.asObservable(), + alarmSettingView.closeButton.rx.tap.asObservable() + ) + + // MARK: Input + let input = AlarmSettingViewModel.Input( + timeSelected: alarmSettingView.timePickerView.rx.date.asObservable(), + titleText: alarmSettingView.alarmTextField.rx.text.asObservable(), + dayToggled: dayToggled, + cancelButtonTapped: cancelOrClose, + saveButtonTapped: alarmSettingView.saveButton.rx.tap.asObservable() + ) + + + // MARK: Input -> VM + let output = viewModel.transform(input) + + + // MARK: VM -> Output + // ์ˆ˜์ •๋ชจ๋“œ์ผ๋•Œ ๋ฐ์ดํ„ฐ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + output.initialSetup + .drive(onNext: { [weak self] payload in + guard let self = self, let payload = payload else { return } + + // DatePicker ์„ธํŒ… + var components = DateComponents() + components.hour = payload.hour + components.minute = payload.minute + if let date = Calendar.current.date(from: components) { + self.alarmSettingView.timePickerView.date = date + } + + // ํ…์ŠคํŠธํ•„๋“œ ์„ธํŒ… + self.alarmSettingView.alarmTextField.text = payload.title + + // ์š”์ผ ๋ฒ„ํŠผ ํ™œ์„ฑํ™” ์„ธํŒ… + payload.repeatDays.forEach { dayIndex in + if dayIndex < self.alarmSettingView.dayButtons.count { + self.alarmSettingView.dayButtons[dayIndex].isSelected = true + } + } + }) + .disposed(by: disposeBag) + + // ์ €์žฅ ์„ฑ๊ณต + output.saveCompleted + .drive(onNext: { [weak self] in + self?.onSaveSuccess?() + }) + .disposed(by: disposeBag) + + // ํ™”๋ฉด ๋‹ซ๊ธฐ + output.dismissView + .drive(onNext: { [weak self] in + self?.dismiss(animated: true) + }) + .disposed(by: disposeBag) + } +} diff --git a/RocketCall/View/CustomButton.swift b/RocketCall/View/CustomButton.swift deleted file mode 100644 index 9cfc979..0000000 --- a/RocketCall/View/CustomButton.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// CustomButton.swift -// RocketCall -// -// Created by Yeseul Jang on 3/23/26. -// -import UIKit - -class CustomButton: UIButton { - init(title: String, color: UIColor) { - super.init(frame: .zero) - setAttributes(title: title, color: color) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -extension CustomButton { - private func setAttributes(title: String, color: UIColor) { - self.setTitle(title, for: .normal) - self.setTitleColor(.white, for: .normal) - self.titleLabel?.font = .boldSystemFont(ofSize: 16) - self.titleLabel?.textAlignment = .center - self.backgroundColor = color - - self.layer.cornerRadius = 10 - self.clipsToBounds = true - - } -} diff --git a/RocketCall/View/CustomNavigationController.swift b/RocketCall/View/CustomNavigationController.swift new file mode 100644 index 0000000..f1ab438 --- /dev/null +++ b/RocketCall/View/CustomNavigationController.swift @@ -0,0 +1,18 @@ +// +// CustomNavigationController.swift +// RocketCall +// +// Created by Yeseul Jang on 3/27/26. +// + +import UIKit + +final class CustomNavigationController: UINavigationController { + override var preferredStatusBarStyle: UIStatusBarStyle { + .lightContent + } + + override var childForStatusBarStyle: UIViewController? { + nil + } +} diff --git a/RocketCall/View/HomeTab/Controller/HomeDetailViewController.swift b/RocketCall/View/HomeTab/Controller/HomeDetailViewController.swift new file mode 100644 index 0000000..e7886fb --- /dev/null +++ b/RocketCall/View/HomeTab/Controller/HomeDetailViewController.swift @@ -0,0 +1,185 @@ +// +// HomeDetailViewController.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit +import RxSwift +import RxCocoa + +final class HomeDetailViewController: UIViewController { + let detailView = HomeDetailView() + let viewModel: HomeViewModel + let disposeBag = DisposeBag() + + //MARK: init + init(viewModel: HomeViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: View LifeCycle + override func loadView() { + self.view = detailView + } + + override func viewDidLoad() { + super.viewDidLoad() + bind() + } +} + +extension HomeDetailViewController { + private func bind() { + //MARK: Input + let viewWillAppear = rx.viewWillAppear + let didBecomeActive = NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification) + .map { _ in } + .skip(until: rx.viewDidAppear) + + let input = HomeViewModel.Input( + fetchData: Observable.merge(viewWillAppear, didBecomeActive) + ) + + //MARK: Output + let output = viewModel.transform(input) + + //MARK: set collectionView snapSHot + // Sum Section Item + let sum: Observable<[HomeViewModel.SumResult]> = output.sum + .map { [weak self] result in + switch result { + case .success(let results): + return results + case .failure(let error): + self?.showAlert(error: error) + return [] + } + } + .share() + + let sumItems = sum + .map { sum in + let totalTime = DetailCollectionView.Item.sum(sum[TotalCardView.CardCategory.totalTime.rawValue]) + let leftTime = DetailCollectionView.Item.sum(sum[TotalCardView.CardCategory.leftTime.rawValue]) + let complete = DetailCollectionView.Item.sum(sum[TotalCardView.CardCategory.totalCount.rawValue]) + let streak = DetailCollectionView.Item.sum(sum[TotalCardView.CardCategory.streak.rawValue]) + + return [totalTime, leftTime, complete, streak] + } + .share() + + // Chart Section Item + let chartData = output.chartRawData + .map { [weak self] result in + switch result { + case .success(let data): + return data + case .failure(let error): + self?.showAlert(error: error) + return [:] + } + } + .share() + + let chartItem = chartData + .map { + return [DetailCollectionView.Item.chart($0)] + } + .share() + + // Progress Section Item + let progress = output.progressStatus + .map { [weak self] result in + switch result { + case .success(let status): + return status + case .failure(let error): + self?.showAlert(error: error) + return HomeViewModel.ProgressStatus(current: .earth, target: .moon, progress: 0) + } + } + .share() + + let progressItem = progress + .map { + return [DetailCollectionView.Item.progress($0)] + } + .share() + + // Result Section Item + let results = output.missionResultList + .map { [weak self] result in + switch result { + case .success(let results): + return results + case .failure(let error): + self?.showAlert(error: error) + return [] + } + } + .share() + + let resultItems = results + .map { results in + let allResults = results.compactMap { DetailCollectionView.Item.result($0) } + return allResults.count >= 5 ? Array(allResults[0...4]) : allResults + } + .share() + + // CollectionView ์—…๋ฐ์ดํŠธ + Observable + .combineLatest(sumItems, chartItem, progressItem, resultItems) + .subscribe(onNext: { [detailView] sum, chart, progress, result in + detailView.setSnapshot(with: [sum, chart, progress, result]) + }) + .disposed(by: disposeBag) + + //MARK: collectionView event + // progressCell ์ •๋ณด ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ - ํ–‰์„ฑ ๋ชฉ๋ก์„ ๋™์ž‘ + detailView.infoButtonTappedRelay + .subscribe(onNext: { [weak self] item in + self?.present(HomeTimeContainerViewController(), animated: true) + }) + .disposed(by: disposeBag) + + // ๋ฏธ์…˜ ๊ฒฐ๊ณผ ์…€ ์„ ํƒ ์‹œ - ๋ฏธ์…˜ ๊ฒฐ๊ณผ๋ฅผ ๋„์šฐ๋Š” ๋™์ž‘ + detailView.collectionView.rx.itemSelected + .subscribe(onNext: { [weak self] indexPath in + guard let self else { return } + guard let item = self.detailView.dataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .result(let result): + let vc = MissionResultViewController(coreDataManager: self.viewModel.coreDataManager, resultId: result.id) + self.present(vc, animated: true) + default: + break + } + }) + .disposed(by: disposeBag) + + // ๋ฏธ์…˜ ๊ฒฐ๊ณผ ์„น์…˜ '๋”๋ณด๊ธฐ' ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ - ๋ชจ๋“  ๋ฏธ์…˜ ๊ฒฐ๊ณผ๋ฅผ ๋„์šฐ๋Š” ๋™์ž‘ + detailView.detailButtonTappedRelay + .subscribe(onNext: { [weak self] in + guard let self else { return } + let vc = HomeResultListViewController(viewModel: self.viewModel) + self.navigationController?.pushViewController(vc, animated: true) + }) + .disposed(by: disposeBag) + } +} + +extension HomeDetailViewController { + private func showAlert(error: Error) { + let alert = UIAlertController(title: "์˜ค๋ฅ˜", message: "\(error.localizedDescription)", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default, handler: nil)) + present(alert, animated: true) + } +} diff --git a/RocketCall/View/HomeTab/Controller/HomeMainViewController.swift b/RocketCall/View/HomeTab/Controller/HomeMainViewController.swift new file mode 100644 index 0000000..ae751d9 --- /dev/null +++ b/RocketCall/View/HomeTab/Controller/HomeMainViewController.swift @@ -0,0 +1,142 @@ +// +// HomeMainViewController.swift +// RocketCall +// +// Created by t2025-m0143 on 3/24/26. +// + +import UIKit +import SwiftUI +import RxSwift +import RxCocoa + +class HomeMainViewController: UIViewController { + let mainController: MainController // ํƒญ๋ฐ” ์ปจํŠธ๋กค๋Ÿฌ + let homeMainView: HomeMainView + let viewModel: HomeViewModel + + let disposeBag = DisposeBag() + + override func loadView() { + view = homeMainView + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationController?.navigationBar.isHidden = true + + addChild(homeMainView.chartHostingController) // UIHostingVC์™€ ํ˜„์žฌ VC์˜ ์ƒ๋ช…์ฃผ๊ธฐ ๋™๊ธฐํ™” + homeMainView.chartHostingController.didMove(toParent: self) // ์ž์‹ VC(hostingVC)์—๊ฒŒ VC ๊ณ„์ธต์— ์ถ”๊ฐ€๋˜์—ˆ์Œ์„ ์•Œ๋ฆผ + + bind() + } + + //MARK: init + init(mainController: MainController, viewModel: HomeViewModel) { + self.mainController = mainController + self.viewModel = viewModel + self.homeMainView = HomeMainView(data: viewModel.weeklyData) // viewModel์˜ weeklyData์™€ ๋ฐ”์ธ๋”ฉ + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension HomeMainViewController { + private func bind() { + let viewWillAppear = rx.viewWillAppear + let didBecomeActive = NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification) + .map { _ in } + .skip(until: rx.viewDidAppear) + + let input = HomeViewModel.Input( + fetchData: Observable.merge(viewWillAppear, didBecomeActive) + ) + + let output = viewModel.transform(input) + + // ์•Œ๋žŒ ์นด๋“œ๋ทฐ ์—…๋ฐ์ดํŠธ + output.alarm + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self, cardView = homeMainView.alarmCardView] result in + switch result { + case .success(let alarm): + cardView.configure(alarm: alarm) + + case .failure(let error): + self?.showAlert(error: error) // ์ถ”ํ›„ ์ฒ˜๋ฆฌ ํ•„์š” + } + }) + .disposed(by: disposeBag) + + // ํ†ต๊ณ„ ์นด๋“œ ์—…๋ฐ์ดํŠธ + output.sum + .subscribe(onNext: { [weak self, homeMainView] result in + switch result { + case .success(let results): + homeMainView.totalTimeCardView.configure(results[TotalCardView.CardCategory.totalTime.rawValue]) + homeMainView.missionCardView.configure(results[TotalCardView.CardCategory.totalCount.rawValue]) + case .failure(let error): + self?.showAlert(error: error) + } + }) + .disposed(by: disposeBag) + + // ์ฐจํŠธ๋ทฐ ๋ฐ์ดํ„ฐ์†Œ์Šค - ์—๋Ÿฌ ์ฒ˜๋ฆฌ์šฉ + output.chartRawData + .subscribe(onNext: { [weak self] result in + switch result { + case .success(_): + break + case .failure(let error): + self?.showAlert(error: error) + } + }) + .disposed(by: disposeBag) + + // ์•Œ๋žŒ ์นด๋“œ ๋ทฐ ์ œ์Šค์ฒ˜ + let alarmCardTapGesture = UITapGestureRecognizer() + homeMainView.alarmCardView.addGestureRecognizer(alarmCardTapGesture) // ์ œ์Šค์ฒ˜ ์ถ”๊ฐ€ + + alarmCardTapGesture.rx.event // ํƒญ ์ด๋ฒคํŠธ + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .map { _ in } + .subscribe(onNext: { [weak self] in + self?.mainController.selectedIndex = 1 // ์ด๋ฒคํŠธ๊ฐ€ ๋“ค์–ด์˜ค๋ฉด tabBarController์˜ ์„ ํƒ๋œ ์ธ๋ฑ์Šค๋ฅผ ์•Œ๋žŒํƒญ์œผ๋กœ ๋ณ€๊ฒฝ + + }) + .disposed(by: disposeBag) + + // ์ฐจํŠธ ๋ทฐ ์ œ์Šค์ฒ˜ + let chartTapGesture = UITapGestureRecognizer() + homeMainView.chartBaseCardView.addGestureRecognizer(chartTapGesture) // ์ œ์Šค์ฒ˜ ์ถ”๊ฐ€ + + let chartViewTapped = chartTapGesture.rx.event + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .map { _ in } + .share() + + let chartDetailButtonTapped = homeMainView.rx.detailButtonTap + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .share() + + // ์ƒ์„ธ ๊ธฐ๋ก ํ™”๋ฉด ๋„์šฐ๊ธฐ + Observable + .merge(chartViewTapped, chartDetailButtonTapped) + .subscribe(onNext: { [weak self] in + guard let self else { return } + self.navigationController?.pushViewController(HomeDetailViewController(viewModel: self.viewModel), animated: true) + }) + .disposed(by: disposeBag) + } +} + +extension HomeMainViewController { + private func showAlert(error: Error) { + let alert = UIAlertController(title: "์˜ค๋ฅ˜", message: "\(error.localizedDescription)", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default, handler: nil)) + present(alert, animated: true) + } +} diff --git a/RocketCall/View/HomeTab/Controller/HomeResultListViewController.swift b/RocketCall/View/HomeTab/Controller/HomeResultListViewController.swift new file mode 100644 index 0000000..6a94fff --- /dev/null +++ b/RocketCall/View/HomeTab/Controller/HomeResultListViewController.swift @@ -0,0 +1,93 @@ +// +// HomeResultListViewController.swift +// RocketCall +// +// Created by t2025-m0143 on 3/29/26. +// + +import UIKit +import RxSwift +import RxCocoa + +final class HomeResultListViewController: UIViewController { + let viewModel: HomeViewModel + let listView = HomeResultListView() + let disposeBag = DisposeBag() + + override func loadView() { + view = listView + } + + override func viewDidLoad() { + super.viewDidLoad() + bind() + } + + init(viewModel: HomeViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension HomeResultListViewController { + private func bind() { + //MARK: Input + let viewWillAppear = rx.viewWillAppear + let didBecomeActive = NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification) + .map { _ in } + .skip(until: rx.viewDidAppear) + + let input = HomeViewModel.Input( + fetchData: Observable.merge(viewWillAppear, didBecomeActive) + ) + + //MARK: Output + let output = viewModel.transform(input) + + output.missionResultList + .subscribe(onNext: { [weak self] result in + guard let self else { return } + switch result { + case .success(let results): + let items = self.converToItem(results) + listView.setSnapshot(with: items) + case .failure(let error): + self.showAlert(error: error) + } + }) + .disposed(by: disposeBag) + + listView.collectionView.rx.itemSelected + .subscribe(onNext: { [weak self] indexPath in + guard let self else { return } + guard let item = listView.dataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .result(let result): + let vc = MissionResultViewController(coreDataManager: self.viewModel.coreDataManager, resultId: result.id) + self.present(vc, animated: true) + default: + break + } + }) + .disposed(by: disposeBag) + } + + private func converToItem(_ results: [HomeViewModel.MissionResultList]) -> [DetailCollectionView.Item] { + results.map { + DetailCollectionView.Item.result($0) + } + } +} + +extension HomeResultListViewController { + private func showAlert(error: Error) { + let alert = UIAlertController(title: "์˜ค๋ฅ˜", message: "\(error.localizedDescription)", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default, handler: nil)) + present(alert, animated: true) + } +} diff --git a/RocketCall/View/HomeTab/Controller/HomeTimeContainerViewController.swift b/RocketCall/View/HomeTab/Controller/HomeTimeContainerViewController.swift new file mode 100644 index 0000000..7dc7b04 --- /dev/null +++ b/RocketCall/View/HomeTab/Controller/HomeTimeContainerViewController.swift @@ -0,0 +1,32 @@ +// +// HomeTimeContainerViewController.swift +// RocketCall +// +// Created by t2025-m0143 on 3/29/26. +// + +import UIKit +import SnapKit + +final class HomeTimeContainerViewController: UIViewController { + let titleView = TitleView(title: "ํ–‰์„ฑ ๋ชฉ๋ก", subTitle: "๋ˆ„์  ์ง‘์ค‘ ์‹œ๊ฐ„์— ๋”ฐ๋ผ ๋‹ค์Œ ํ–‰์„ฑ์œผ๋กœ ํ–ฅํ•ฉ๋‹ˆ๋‹ค", hasButton: false) + let timeContainer = TimeContainerView(items: Planet.allCases.map { $0.listItem }) + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .background + + view.addSubview(titleView) + view.addSubview(timeContainer) + + titleView.snp.makeConstraints { + $0.top.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + } + + timeContainer.snp.makeConstraints { + $0.top.equalTo(titleView.snp.bottom).offset(10) + $0.horizontalEdges.equalTo(view.safeAreaLayoutGuide).inset(20) + } + } +} diff --git a/RocketCall/View/HomeTab/View/AlarmCardView.swift b/RocketCall/View/HomeTab/View/AlarmCardView.swift new file mode 100644 index 0000000..f68dd9c --- /dev/null +++ b/RocketCall/View/HomeTab/View/AlarmCardView.swift @@ -0,0 +1,159 @@ +// +// AlarmCard.swift +// RocketCall +// +// Created by t2025-m0143 on 3/24/26. +// +import UIKit +import SnapKit +import Then + +class AlarmCardView: BaseCardView { + //MARK: set Attributes + // - ์•Œ๋žŒ ์กด์žฌ ์‹œ + let colorChip = UIView().then { + $0.backgroundColor = .subPoint + $0.clipsToBounds = true + } + + let repeatDaysLabel = UILabel().then { + $0.textColor = .subLabel + $0.font = .systemFont(ofSize: 12, weight: .medium) + } + + private lazy var repeatDaysStackView = SymbolLabelStack(symbol: "calendar", symbolColor: .subPoint, label: repeatDaysLabel) + + let titleLabel = UILabel().then { + $0.textColor = .mainLabel + $0.font = .systemFont(ofSize: 20, weight: .semibold) + } + + let bar = UIView().then { + $0.backgroundColor = UIColor(red: 201/255.0, green: 209/255.0, blue: 232/255.0, alpha: 0.3) // cardView border์™€ ๋™์ผ + $0.snp.makeConstraints { + $0.height.equalTo(1) + } + } + + let timeTitle = UILabel().then { + $0.textColor = .subLabel + $0.font = .systemFont(ofSize: 12, weight: .medium) + $0.text = "์•Œ๋žŒ ์‹œ๊ฐ„" + } + + let timeLabel = UILabel().then { + $0.textColor = .mainLabel + $0.font = .systemFont(ofSize: 16, weight: .medium) + } + + // - ์•Œ๋žŒ์ด ์—†์„ ์‹œ + let emptyAlarmImage = UIImageView().then { + let config = UIImage.SymbolConfiguration(weight: .medium) + $0.image = UIImage(systemName: "bell.slash", withConfiguration: config) + $0.tintColor = .subPoint.withAlphaComponent(0.4) + $0.isHidden = true + + $0.setContentHuggingPriority(UILayoutPriority(249), for: .vertical) + $0.snp.makeConstraints { + $0.width.height.equalTo(40) + } + } + + let emptyAlarmLabel = UILabel(text: "ํ™œ์„ฑํ™” ๋œ ์•Œ๋žŒ์ด ์—†์Šต๋‹ˆ๋‹ค", config: LabelConfiguration.sub14).then { + $0.isHidden = true + } + + override init(frame: CGRect) { + super.init(frame: .zero) + + setLayout() + } + + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension AlarmCardView { + private func setLayout() { + + addSubview(colorChip) + addSubview(repeatDaysStackView) + addSubview(titleLabel) + addSubview(bar) + addSubview(timeTitle) + addSubview(timeLabel) + + colorChip.snp.makeConstraints { + $0.leading.verticalEdges.equalToSuperview() + $0.width.equalToSuperview().multipliedBy(0.02) + } + + repeatDaysStackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.leading.equalTo(colorChip.snp.trailing).offset(10) + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(repeatDaysStackView.snp.bottom).offset(10) + $0.trailing.equalToSuperview().inset(10) + $0.leading.equalTo(colorChip.snp.trailing).offset(10) + } + + bar.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(10) + $0.leading.equalTo(colorChip.snp.trailing).offset(10) + $0.trailing.equalToSuperview().inset(10) + } + + timeTitle.snp.makeConstraints { + $0.top.equalTo(bar.snp.bottom).offset(12) + $0.leading.equalTo(colorChip.snp.trailing).offset(10) + $0.trailing.equalToSuperview().inset(10) + } + + timeLabel.snp.makeConstraints { + $0.top.equalTo(timeTitle.snp.bottom).offset(5) + $0.leading.equalTo(colorChip.snp.trailing).offset(10) + $0.trailing.equalToSuperview().inset(10) + $0.bottom.equalToSuperview().offset(-15) + } + + addSubview(emptyAlarmImage) + addSubview(emptyAlarmLabel) + + emptyAlarmImage.snp.makeConstraints { + $0.top.equalToSuperview().offset(30) + $0.centerX.equalToSuperview() + } + + emptyAlarmLabel.snp.makeConstraints { + $0.top.equalTo(emptyAlarmImage.snp.bottom).offset(12) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().offset(-30) + } + } + + private func toggleIsHidden() { + repeatDaysStackView.isHidden.toggle() + titleLabel.isHidden.toggle() + bar.isHidden.toggle() + timeTitle.isHidden.toggle() + timeLabel.isHidden.toggle() + + emptyAlarmImage.isHidden.toggle() + emptyAlarmLabel.isHidden.toggle() + } + + func configure(alarm: Alarm?) { + if let alarm { + emptyAlarmImage.isHidden ? () : toggleIsHidden() + + repeatDaysLabel.text = alarm.repeatDays.map { $0.koreanName }.joined(separator: " ") + titleLabel.text = alarm.title + timeLabel.text = String(format: "%02d:%02d", alarm.hour, alarm.minute) + } else { + emptyAlarmImage.isHidden ? toggleIsHidden() : () + } + } +} diff --git a/RocketCall/View/HomeTab/View/ChartView.swift b/RocketCall/View/HomeTab/View/ChartView.swift new file mode 100644 index 0000000..637257a --- /dev/null +++ b/RocketCall/View/HomeTab/View/ChartView.swift @@ -0,0 +1,105 @@ +// +// ChartView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/25/26. +// + +import SwiftUI +import Charts +import Combine + +class WeeklyData: ObservableObject { + // ์ฐจํŠธ์—์„œ ์‚ฌ์šฉํ•  ๋ฐ์ดํ„ฐ์†Œ์Šค ํƒ€์ž… + struct WeeklyResult: Identifiable { + let id: Int // WeekDay rawValue๋กœ ์‚ฌ์šฉ + let weekDay: String + let studyTime: Int + } + + @Published var weeklyResult: [WeeklyResult] = [] // ๋ณ€ํ™” ์‹œ ์ฐจํŠธ๋ทฐ์— ์ž๋™์œผ๋กœ ์•Œ๋ฆผ + + // weeklyResult ์—…๋ฐ์ดํŠธ์šฉ ์™ธ๋ถ€ ํ˜ธ์ถœ ํ•จ์ˆ˜ + func newValue(_ rawData: [Int: Int]) { + weeklyResult = convertToWeeklyResult(from: rawData) + } + + // ๋”•์…”๋„ˆ๋ฆฌ -> ์ฐจํŠธ ์‚ฌ์šฉ ๋ฐ์ดํ„ฐ๋กœ์˜ ๋ณ€ํ™˜ ๋ฒ ์„œ๋“œ + private func convertToWeeklyResult(from rawData: [Int: Int]) -> [WeeklyResult] { + let sorted = rawData.sorted(by: { $0.key < $1.key }) + + var results = Array(repeating: 0, count: 7) + + for data in sorted { + results[data.key] = data.value + } + + return results.enumerated().reduce(into: [WeeklyResult]()) { + guard let weekday = WeekDay(rawValue: $1.offset) else { return } + + $0.append( + WeeklyResult( + id: weekday.rawValue, + weekDay: weekday.koreanName, + studyTime: $1.element + )) + } + } +} + +struct ChartView: View { + @ObservedObject var data: WeeklyData // data(WeeklyData)์— ๋ณ€ํ™”๊ฐ€ ์žˆ์„ ์‹œ ์ž๋™์œผ๋กœ ๋ทฐ ๊ฐฑ์‹  + + var body: some View { + Chart { + ForEach(data.weeklyResult) { result in + BarMark( // ๋ฐ” ๋งˆํฌ ์ƒ์„ฑ + x: .value("์š”์ผ", result.weekDay), + y: .value("์ง‘์ค‘ ์‹œ๊ฐ„", result.studyTime), + width: .automatic + ) + .foregroundStyle(Color.mainPoint) + .annotation(position: .top, spacing: 5) { + // ๋ฐ” ๋งˆํฌ ์œ„์— ํ‘œ์‹œ๋  ๊ฐ’ ๋ ˆ์ด๋ธ” + Text("\(result.studyTime)") + .foregroundStyle(Color.mainLabel) + .font(.caption) + } + .clipShape( + // ๋ฐ” ๋งˆํฌ ์ƒ๋‹จ๋ถ€๋งŒ cornerRadius ์ ์šฉ + UnevenRoundedRectangle( + topLeadingRadius: 4, + bottomLeadingRadius: 0, + bottomTrailingRadius: 0, + topTrailingRadius: 4, + style: .circular + ) + ) + } + } + .chartXAxis { // X์ถ• ์„ค์ • + AxisMarks(values: .automatic) { + AxisGridLine() // X์ถ• Grid ์„  + .foregroundStyle(Color.subLabel.opacity(0.5)) + + AxisValueLabel() // X์ถ• ๊ฐ’ ๋ ˆ์ด๋ธ” + .foregroundStyle(Color.mainLabel) + .font(.system(size: 12, weight: .medium)) + } + } + .chartYAxis { // Y์ถ• ์„ค์ • + AxisMarks(position: .leading) { value in + AxisGridLine() // Y์ถ• Grid ์„  + .foregroundStyle(Color.subLabel.opacity(0.5)) + + AxisValueLabel { // Y์ถ• ๊ฐ’ ๋ ˆ์ด๋ธ” + if let minute = value.as(Int.self) { + Text("\(minute.formatted(.number))") + } + } + .foregroundStyle(Color.mainLabel) + .font(.system(size: 12, weight: .medium)) + } + } + } +} diff --git a/RocketCall/View/HomeTab/View/CollectionView/ChartCell.swift b/RocketCall/View/HomeTab/View/CollectionView/ChartCell.swift new file mode 100644 index 0000000..319f393 --- /dev/null +++ b/RocketCall/View/HomeTab/View/CollectionView/ChartCell.swift @@ -0,0 +1,45 @@ +// +// ChartCell.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit +import SnapKit +import SwiftUI + +final class ChartCell: UICollectionViewCell { + private let chartBaseCardView = BaseCardView() + private let chartHostingController = UIHostingController(rootView: ChartView(data: DetailCollectionView.Item.weeklyData)) + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 16 + + chartHostingController.view.backgroundColor = .clear + + addSubview(chartBaseCardView) + addSubview(chartHostingController.view) + + chartBaseCardView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + chartHostingController.view.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.horizontalEdges.equalToSuperview().inset(15) + $0.bottom.equalToSuperview().inset(10) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ChartCell { + func update(with data: [Int: Int]) { + + } +} diff --git a/RocketCall/View/HomeTab/View/CollectionView/HomeCollectionHeaderView.swift b/RocketCall/View/HomeTab/View/CollectionView/HomeCollectionHeaderView.swift new file mode 100644 index 0000000..5701f0f --- /dev/null +++ b/RocketCall/View/HomeTab/View/CollectionView/HomeCollectionHeaderView.swift @@ -0,0 +1,37 @@ +// +// HomeCollectionHeaderView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/29/26. +// +import UIKit +import SnapKit +import RxSwift + +final class HomeCollectionHeaderView: UICollectionReusableView { + private(set) var disposeBag = DisposeBag() + let headerView = HomeHeaderView() + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(headerView) + + headerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() // ๊ตฌ๋… ์ดˆ๊ธฐํ™” + } + + func configure(title: String, hasButton: Bool, buttonTitle: String = "") { + headerView.configure(title: title, hasButton: hasButton, buttonTitle: buttonTitle) + } +} diff --git a/RocketCall/View/HomeTab/View/CollectionView/ProgressCell.swift b/RocketCall/View/HomeTab/View/CollectionView/ProgressCell.swift new file mode 100644 index 0000000..bbca08c --- /dev/null +++ b/RocketCall/View/HomeTab/View/CollectionView/ProgressCell.swift @@ -0,0 +1,157 @@ +// +// ProgressCell.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit +import SnapKit +import Then +import RxSwift +import RxCocoa + +final class ProgressCell: UICollectionViewCell { + private let cardView = BaseCardView().then { + $0.isOn = true + $0.backgroundColor = .mainPoint.withAlphaComponent(0.2) + } + + private let titleLabel = UILabel().then { + $0.text = "ํƒœ์–‘๊ณ„ ์ •๋ณต๋„" + $0.font = .boldSystemFont(ofSize: 16) + $0.textColor = .mainLabel + } + private let subTitleLabel = UILabel(text: "์ง‘์ค‘ํ• ์ˆ˜๋ก ํ–‰์„ฑ์— ๊ฐ€๊นŒ์›Œ์ ธ์š”", config: .sub14) + + private let startPlanet = UILabel().then { + $0.text = Planet.earth.emoji + $0.font = .systemFont(ofSize: 30) + } + private let targetPlanet = UILabel().then { + $0.text = Planet.moon.emoji + $0.font = .systemFont(ofSize: 30) + } + + private let progressView = GradientProgressView() + private let progressLabel = UILabel().then { + $0.font = .systemFont(ofSize: 14, weight: .semibold) + $0.textColor = .mainLabel + $0.text = "\(Planet.earth.title) โ†’ \(Planet.moon.title)" + } + private let targetTimeLabel = UILabel(text: "์‹œ๊ฐ„", config: .sub12).then { + $0.textAlignment = .right + } + + fileprivate let infoButton = CircleButton( + size: 45, + backgroundColor: .clear, + image: UIImage(systemName: "info.circle"), + tintColor: .subLabel + ) + + private(set) var disposeBag = DisposeBag() + + override init(frame: CGRect) { + super.init(frame: frame) + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() + } +} + +extension ProgressCell { + private func setLayout() { + let titleStack = UIStackView(arrangedSubviews: [titleLabel, subTitleLabel]).then { + $0.axis = .vertical + $0.spacing = 5 + + titleLabel.setContentHuggingPriority(.required, for: .vertical) + titleLabel.setContentCompressionResistancePriority(.required, for: .vertical) + } + let progressStack = generateProgressStackView() + + contentView.addSubview(cardView) + + cardView.addSubview(titleStack) + cardView.addSubview(infoButton) + cardView.addSubview(progressStack) + + cardView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + titleStack.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.equalTo(infoButton.snp.leading) + } + + infoButton.snp.makeConstraints { + $0.bottom.equalTo(titleStack.snp.bottom) + $0.trailing.equalToSuperview().inset(20) + } + + progressStack.snp.makeConstraints { + $0.top.equalTo(titleStack.snp.bottom).offset(10) + $0.bottom.horizontalEdges.equalToSuperview().inset(20) + } + } + + private func generateProgressStackView() -> UIStackView { + let labelStack = UIStackView(arrangedSubviews: [progressLabel, targetTimeLabel]).then { + $0.axis = .horizontal + + targetTimeLabel.setContentHuggingPriority(.required, for: .horizontal) + targetTimeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + let barStack = UIStackView(arrangedSubviews: [labelStack, progressView]).then { + $0.axis = .vertical + $0.spacing = 5 + + progressView.setContentHuggingPriority(.required, for: .vertical) + progressView.setContentCompressionResistancePriority(.required, for: .vertical) + } + + let progressStack = UIStackView(arrangedSubviews: [startPlanet, barStack, targetPlanet]).then { + $0.axis = .horizontal + $0.spacing = 10 + $0.alignment = .center + + barStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + barStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + startPlanet.setContentHuggingPriority(.required, for: .horizontal) + startPlanet.setContentCompressionResistancePriority(.required, for: .horizontal) + targetPlanet.setContentHuggingPriority(.required, for: .horizontal) + targetPlanet.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + return progressStack + } +} + +extension ProgressCell { + func configure(status: HomeViewModel.ProgressStatus) { + progressLabel.text = "\(status.current.title) โ†’ \(status.target?.title ?? "")" + targetTimeLabel.text = "\(status.target?.targetTime ?? 0)์‹œ๊ฐ„" + + startPlanet.text = status.current.emoji + targetPlanet.text = status.target?.emoji + + progressView.setProgress(status.progress, animated: true) + } +} + +extension Reactive where Base :ProgressCell { + var infoButtonTap: ControlEvent { + base.infoButton.rx.tap + } +} diff --git a/RocketCall/View/HomeTab/View/CollectionView/ResultListCell.swift b/RocketCall/View/HomeTab/View/CollectionView/ResultListCell.swift new file mode 100644 index 0000000..768e20c --- /dev/null +++ b/RocketCall/View/HomeTab/View/CollectionView/ResultListCell.swift @@ -0,0 +1,116 @@ +// +// ResultListCell.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit +import SnapKit +import Then + +final class ResultListCell: UICollectionViewCell { + private let cardView = BaseCardView() + + private let titleLabel = UILabel(text: "๋ฏธ์…˜", config: .sub14).then { + $0.font = .systemFont(ofSize: 14, weight: .semibold) + } + private let timeLabel = UILabel( + text: "์‹œ๊ฐ„", + config: LabelConfiguration(font: .boldSystemFont(ofSize: 18), color: .mainLabel, lines: 1)).then { + $0.textAlignment = .right + } + + private let dateLabel = UILabel(text: "๋‚ ์งœ", config: .sub12) + private let cycleLabel = UILabel(text: "์‚ฌ์ดํด", config: .sub12).then { + $0.textAlignment = .right + $0.isHidden = true // ์ฝ”์–ด๋ฐ์ดํ„ฐ์— cycle ์ •๋ณด๊ฐ€ ์—†์–ด์„œ hidden ์ฒ˜๋ฆฌ + } + + private let stateLabel = StateLabel(text: "โœ”๏ธ ์„ฑ๊ณต", config: .success) + + override init(frame: CGRect) { + super.init(frame: frame) + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ResultListCell { + private func setAttributes() { + contentView.backgroundColor = UIColor(red: 18/255.0, green: 26/255.0, blue: 48/255.0, alpha: 1.0) + contentView.layer.cornerRadius = 16 + contentView.layer.masksToBounds = true + contentView.layer.borderWidth = 1 + contentView.layer.borderColor = UIColor(red: 201/255.0, green: 209/255.0, blue: 232/255.0, alpha: 0.3).cgColor + } + + private func setLayout() { + let firstStack = UIStackView(arrangedSubviews: [titleLabel, timeLabel]).then { + $0.axis = .horizontal + $0.alignment = .bottom + $0.spacing = 5 + + timeLabel.setContentHuggingPriority(.required, for: .horizontal) + timeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + let secondStack = UIStackView(arrangedSubviews: [dateLabel, cycleLabel]).then { + $0.axis = .horizontal + $0.spacing = 5 + + cycleLabel.setContentHuggingPriority(.required, for: .horizontal) + cycleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + + contentView.addSubview(cardView) + cardView.addSubview(firstStack) + cardView.addSubview(secondStack) + cardView.addSubview(stateLabel) + + cardView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + firstStack.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview().inset(20) + } + + secondStack.snp.makeConstraints { + $0.top.equalTo(firstStack.snp.bottom).offset(5) + $0.horizontalEdges.equalToSuperview().inset(20) + } + + stateLabel.snp.makeConstraints { + $0.top.equalTo(secondStack.snp.bottom).offset(5) + $0.leading.bottom.equalToSuperview().inset(20) + } + } +} + +extension ResultListCell { + func configure(with result: HomeViewModel.MissionResultList) { + titleLabel.text = result.title + timeLabel.text = "\(result.studyTime / 60)h \(result.studyTime % 60)m" + + dateLabel.text = result.date + + configureStateLabel(isCompleted: result.isCompleted) + } + + private func configureStateLabel(isCompleted: Bool) { + if isCompleted { + stateLabel.backgroundColor = StateLabelConfiguration.success.backgroundColor + stateLabel.textColor = StateLabelConfiguration.success.color + stateLabel.text = "โœ“ ์„ฑ๊ณต" + } else { + stateLabel.backgroundColor = StateLabelConfiguration.failure.backgroundColor + stateLabel.textColor = StateLabelConfiguration.failure.color + stateLabel.text = "x ์‹คํŒจ" + } + } +} diff --git a/RocketCall/View/HomeTab/View/CollectionView/SumCardCell.swift b/RocketCall/View/HomeTab/View/CollectionView/SumCardCell.swift new file mode 100644 index 0000000..3bf6a42 --- /dev/null +++ b/RocketCall/View/HomeTab/View/CollectionView/SumCardCell.swift @@ -0,0 +1,36 @@ +// +// sumCardCell.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit +import SnapKit + +final class SumCardCell: UICollectionViewCell { + private let cardView = TotalCardView(type: .totalCount) + + override init(frame: CGRect) { + super.init(frame: frame) + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension SumCardCell { + private func setLayout() { + contentView.addSubview(cardView) + + cardView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func configure(_ data: HomeViewModel.SumResult) { + cardView.configure(data) + } +} diff --git a/RocketCall/View/HomeTab/View/DetailCollectionView.swift b/RocketCall/View/HomeTab/View/DetailCollectionView.swift new file mode 100644 index 0000000..e7d432c --- /dev/null +++ b/RocketCall/View/HomeTab/View/DetailCollectionView.swift @@ -0,0 +1,205 @@ +// +// DetailCollectionView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit +import SnapKit + +final class DetailCollectionView: UICollectionView { + enum Section: Int { + case sum = 0 + case chart + case progress + case result + } + + //TODO: ํƒ€์ž… ๋ณ€๊ฒฝ ํ•„์š” + enum Item: Hashable { + case sum(HomeViewModel.SumResult) + case chart([Int: Int]) + case progress(HomeViewModel.ProgressStatus) + case result(HomeViewModel.MissionResultList) + + func hash(into hasher: inout Hasher) { + switch self { + case .sum(let result): + hasher.combine("total") + hasher.combine(result) + case .chart(let rawData): + hasher.combine("chart") + hasher.combine(rawData) + case .progress(let status): + hasher.combine("progress") + hasher.combine(status) + case .result(let payload): + hasher.combine("result") + hasher.combine(payload.id) + } + } + + static let weeklyData = WeeklyData() + } + + override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { + super.init(frame: frame, collectionViewLayout: UICollectionViewLayout()) + collectionViewLayout = makeCompositionalLayout() + layoutMargins = .init(top: 0, left: 20, bottom: 0, right: 20) + contentInset = .init(top: 0, left: 0, bottom: 50, right: 0) + backgroundColor = .background + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +extension DetailCollectionView { + private func makeCompositionalLayout() -> UICollectionViewLayout { + let configuration = UICollectionViewCompositionalLayoutConfiguration() + configuration.interSectionSpacing = 5 + configuration.contentInsetsReference = .layoutMargins + + return UICollectionViewCompositionalLayout (sectionProvider: { [weak self] sectionIndex, environment in + let headerItem = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(35) + ), + elementKind: "HeaderKind", + alignment: .top + ) + + switch Section(rawValue: sectionIndex) { + case .sum: + let section = self?.sumSectionLayout(environment: environment) + return section + case .chart: + let section = self?.chartSectionLayout() + section?.boundarySupplementaryItems = [headerItem] + return section + case .progress: + let section = self?.progressSectionLayout() + return section + case .result: + let section = self?.resultSectionLayout(environment: environment) + section?.boundarySupplementaryItems = [headerItem] + return section + case .none: + return self?.chartSectionLayout() + } + }, configuration: configuration) + } + + private func sumSectionLayout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { + let environmentWidth = environment.container.effectiveContentSize.width + let spacing: CGFloat = 8 + let itemWidth = (environmentWidth - 8) / 2 + + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemWidth * 0.55) + ) + ) + + // ๊ฐ€๋กœ ๊ทธ๋ฃน - 2๊ฐœ ํ‘œ์‹œ + let innerGroup = NSCollectionLayoutGroup.horizontal( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemWidth * 0.55) + ), + repeatingSubitem: item, + count: 2 + ) + + innerGroup.interItemSpacing = .fixed(spacing) + + // ์„ธ๋กœ ๊ทธ๋ฃน - 4๊ฐœ ํ‘œ์‹œ + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute((itemWidth * 1.1) + 8) + ), + repeatingSubitem: innerGroup, + count: 2 + ) + + group.interItemSpacing = .fixed(spacing) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .init(top: 0, leading: 0, bottom: 10, trailing: 0) + + return section + } + + private func chartSectionLayout() -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.8) + ) + ) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.8) + ), + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .init(top: 5, leading: 0, bottom: 0, trailing: 0) + + return section + } + + private func progressSectionLayout() -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.35) + ) + ) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.35) + ), + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .init(top: 0, leading: 0, bottom: 10, trailing: 0) + + return section + } + + private func resultSectionLayout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.28) + ) + ) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.28) + ), + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 8 + section.contentInsets = .init(top: 5, leading: 0, bottom: 0, trailing: 0) + + return section + } +} diff --git a/RocketCall/View/HomeTab/View/HomeDetailView.swift b/RocketCall/View/HomeTab/View/HomeDetailView.swift new file mode 100644 index 0000000..cd7ec55 --- /dev/null +++ b/RocketCall/View/HomeTab/View/HomeDetailView.swift @@ -0,0 +1,144 @@ +// +// HomeDetailView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/27/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +final class HomeDetailView: UIView { + private let titleView = TitleView(title: "์ƒ์„ธ ๊ธฐ๋ก", subTitle: "๋‹น์‹ ์˜ ์šฐ์ฃผ ์—ฌ์ •", hasButton: false) + let collectionView = DetailCollectionView() + private(set) lazy var dataSource = makeCollectionViewDiffableDataSource(collectionView) + + let infoButtonTappedRelay = PublishRelay() + let detailButtonTappedRelay = PublishRelay() + let disposeBag = DisposeBag() + + override init(frame: CGRect) { + super.init(frame: frame) + + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension HomeDetailView { + private func setLayout() { + addSubview(titleView) + addSubview(collectionView) + + titleView.snp.makeConstraints { + $0.top.horizontalEdges.equalTo(safeAreaLayoutGuide) + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(titleView.snp.bottom) + $0.horizontalEdges.bottom.equalToSuperview() + } + } +} + +//MARK: CollectionView +extension HomeDetailView { + private func makeCollectionViewDiffableDataSource(_ collectionView: UICollectionView) -> UICollectionViewDiffableDataSource { + + let headerViewRegistration = UICollectionView.SupplementaryRegistration(elementKind: "HeaderKind") { [weak self] supplementaryView, elementKind, indexPath in + guard let self else { return } + switch DetailCollectionView.Section(rawValue: indexPath.section) { + case .chart: + supplementaryView.configure(title: "์ฃผ๊ฐ„ ๊ธฐ๋ก", hasButton: false) + case .result: + supplementaryView.configure(title: "๋ฏธ์…˜ ๊ฒฐ๊ณผ", hasButton: true, buttonTitle: "๋” ๋ณด๊ธฐ") + supplementaryView.headerView.rx.detailButtonTap + .bind(to: self.detailButtonTappedRelay) + .disposed(by: supplementaryView.disposeBag) + default: + break + } + } + + let sumCardCellRegistration = UICollectionView.CellRegistration { cell, indexPath, item in + switch item { + case .sum(let result): + cell.configure(result) + default: + break + } + } + + let chartCellRegistration = UICollectionView.CellRegistration { cell, indexPath, item in + switch item { + case .chart(let rawData): + DetailCollectionView.Item.weeklyData.newValue(rawData) // ์ฐจํŠธ๋ทฐ ๋ฐ์ดํ„ฐ ๊ฐฑ์‹  + default: + break + } + } + + let progressCellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, item in + guard let self else { return } + + switch item { + case .progress(let status): + cell.configure(status: status) + + cell.rx.infoButtonTap + .bind(to: self.infoButtonTappedRelay) + .disposed(by: cell.disposeBag) + + default: + break + } + } + + let resultCellRegistration = UICollectionView.CellRegistration { cell, indexPath, item in + switch item { + case .result(let payload): + cell.configure(with: payload) + default: + break + } + } + + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch DetailCollectionView.Section(rawValue: indexPath.section) { + case .sum: + return collectionView.dequeueConfiguredReusableCell(using: sumCardCellRegistration, for: indexPath, item: item) + case .chart: + return collectionView.dequeueConfiguredReusableCell(using: chartCellRegistration, for: indexPath, item: item) + case .progress: + return collectionView.dequeueConfiguredReusableCell(using: progressCellRegistration, for: indexPath, item: item) + case .result: + return collectionView.dequeueConfiguredReusableCell(using: resultCellRegistration, for: indexPath, item: item) + default: + fatalError("DetailCollectionView: ์œ ํšจํ•˜์ง€ ์•Š์€ ์„น์…˜์ž…๋‹ˆ๋‹ค") + } + } + + dataSource.supplementaryViewProvider = { + collectionView.dequeueConfiguredReusableSupplementary(using: headerViewRegistration, for: $2) + } + + return dataSource + } + + func setSnapshot(with data: [[DetailCollectionView.Item]]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.sum, .chart, .progress, .result]) + + snapshot.appendItems(data[DetailCollectionView.Section.sum.rawValue], toSection: .sum) + snapshot.appendItems(data[DetailCollectionView.Section.chart.rawValue], toSection: .chart) + snapshot.appendItems(data[DetailCollectionView.Section.progress.rawValue], toSection: .progress) + snapshot.appendItems(data[DetailCollectionView.Section.result.rawValue], toSection: .result) + + dataSource.apply(snapshot, animatingDifferences: false) + } +} diff --git a/RocketCall/View/HomeTab/View/HomeHeaderView.swift b/RocketCall/View/HomeTab/View/HomeHeaderView.swift new file mode 100644 index 0000000..45bbb7b --- /dev/null +++ b/RocketCall/View/HomeTab/View/HomeHeaderView.swift @@ -0,0 +1,68 @@ +// +// HomeHeaderView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/29/26. +// + +import UIKit +import SnapKit +import Then +import RxSwift +import RxCocoa + +final class HomeHeaderView: UIView { + private let titleLabel = UILabel(text: "์ œ๋ชฉ", config: LabelConfiguration.homeViewHeader) + fileprivate let detailButton = UIButton(configuration: .plain()).then { + $0.backgroundColor = .clear + $0.tintColor = .mainPoint + $0.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold) + } + let unitLabel = UILabel().then { + $0.text = "(๋‹จ์œ„: ๋ถ„)" + $0.textColor = .subLabel + $0.textAlignment = .left + $0.font = .systemFont(ofSize: 12, weight: .medium) + $0.isHidden = true + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + let stackView = UIStackView(arrangedSubviews: [titleLabel, unitLabel, detailButton]).then { + $0.axis = .horizontal + $0.spacing = 5 + $0.alignment = .lastBaseline + + detailButton.setContentHuggingPriority(.required, for: .horizontal) + detailButton.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + addSubview(stackView) + + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.bottom.equalToSuperview().offset(-5) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, hasButton: Bool, buttonTitle: String = "") { + titleLabel.text = title + detailButton.isHidden = !hasButton + detailButton.setTitle(buttonTitle, for: .normal) + } +} + +extension Reactive where Base: HomeHeaderView { + var detailButtonTap: ControlEvent { + base.detailButton.rx.tap + } +} diff --git a/RocketCall/View/HomeTab/View/HomeMainView.swift b/RocketCall/View/HomeTab/View/HomeMainView.swift new file mode 100644 index 0000000..35ec324 --- /dev/null +++ b/RocketCall/View/HomeTab/View/HomeMainView.swift @@ -0,0 +1,140 @@ +// +// HomeMainView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/24/26. +// + +import UIKit +import SnapKit +import Then +import SwiftUI +import RxSwift +import RxCocoa + +final class HomeMainView: UIView { + //MARK: set attributes + private let titleView = TitleView(title: "ํ•ญํ–‰์ผ์ง€", subTitle: "์šฐ์ฃผ ํƒ์‚ฌ ๋Œ€์‹œ๋ณด๋“œ", hasButton: false) + + private let alarmCardTitle = UILabel(text: "๋‹ค๊ฐ€์˜ค๋Š” ์•Œ๋žŒ", config: LabelConfiguration.homeViewHeader) + let alarmCardView = AlarmCardView() + + let chartHeaderView = HomeHeaderView().then { + $0.configure(title: "์ฃผ๊ฐ„ ๊ธฐ๋ก", hasButton: true, buttonTitle: "์ƒ์„ธ ๋ณด๊ธฐ") + $0.unitLabel.isHidden = false + } + let chartBaseCardView = BaseCardView() + + // SwiftUI๋กœ ์ƒ์„ฑ๋œ ChartView๋ฅผ UIKit์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ HostingController + private(set) var chartHostingController: UIHostingController + + let totalTimeCardView = TotalCardView(type: .totalTime) + let missionCardView = TotalCardView(type: .totalCount) + + init(data: WeeklyData) { + let chartView = ChartView(data: data) + self.chartHostingController = UIHostingController(rootView: chartView).then { + $0.view.backgroundColor = .clear + + $0.sizingOptions = .intrinsicContentSize + $0.view.setContentHuggingPriority(UILayoutPriority(1), for: .vertical) + } + super.init(frame: .zero) + + backgroundColor = .background + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setLayout() { + let chartViewTitle = generateChartTitleStack() + let smallCardStackView = UIStackView(arrangedSubviews: [totalTimeCardView, missionCardView]).then { + $0.axis = .horizontal + $0.spacing = 8 + $0.distribution = .fillEqually + } + + addSubview(titleView) + + addSubview(alarmCardTitle) + addSubview(alarmCardView) + +// addSubview(chartViewTitle) + addSubview(chartHeaderView) + addSubview(chartBaseCardView) + chartBaseCardView.addSubview(chartHostingController.view) + + addSubview(smallCardStackView) + + titleView.snp.makeConstraints { + $0.top.horizontalEdges.equalTo(safeAreaLayoutGuide) + } + + alarmCardTitle.snp.makeConstraints { + $0.top.equalTo(titleView.snp.bottom) + $0.horizontalEdges.equalTo(safeAreaLayoutGuide).inset(20) + } + + alarmCardView.snp.makeConstraints { + $0.top.equalTo(alarmCardTitle.snp.bottom).offset(10) + $0.horizontalEdges.equalTo(safeAreaLayoutGuide).inset(20) + } + + chartHeaderView.snp.makeConstraints { + $0.top.equalTo(alarmCardView.snp.bottom).offset(5) + $0.horizontalEdges.equalTo(safeAreaLayoutGuide).inset(20) + } + + chartBaseCardView.snp.makeConstraints { + $0.top.equalTo(chartHeaderView.snp.bottom) + $0.horizontalEdges.equalTo(safeAreaLayoutGuide).inset(20) + } + + chartHostingController.view.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.horizontalEdges.equalToSuperview().inset(15) + $0.bottom.equalToSuperview().inset(10) + } + + smallCardStackView.snp.makeConstraints { + $0.top.equalTo(chartBaseCardView.snp.bottom).offset(10) + $0.bottom.horizontalEdges.equalTo(safeAreaLayoutGuide).inset(20) + $0.height.equalTo(92) + } + } +} + +extension HomeMainView { + private func generateChartTitleStack() -> UIStackView { + let chartViewTitle = UILabel(text: "์ฃผ๊ฐ„ ๊ธฐ๋ก", config: .homeViewHeader) + let unitLabel = UILabel().then { + $0.text = "(๋‹จ์œ„: ๋ถ„)" + $0.textColor = .subLabel + $0.font = .systemFont(ofSize: 12, weight: .medium) + } + + let stackView = UIStackView(arrangedSubviews: [chartViewTitle, unitLabel]).then { + $0.axis = .horizontal + $0.spacing = 5 + $0.alignment = .lastBaseline + + chartViewTitle.setContentHuggingPriority(.required, for: .horizontal) + chartViewTitle.setContentCompressionResistancePriority(.required, for: .horizontal) + + $0.snp.makeConstraints { + $0.height.equalTo(chartViewTitle.snp.height) + } + } + + return stackView + } +} + +extension Reactive where Base: HomeMainView { + var detailButtonTap: ControlEvent { + base.chartHeaderView.rx.detailButtonTap + } +} diff --git a/RocketCall/View/HomeTab/View/HomeResultListView.swift b/RocketCall/View/HomeTab/View/HomeResultListView.swift new file mode 100644 index 0000000..6e5f2e8 --- /dev/null +++ b/RocketCall/View/HomeTab/View/HomeResultListView.swift @@ -0,0 +1,104 @@ +// +// ResultListView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/29/26. +// + +import UIKit +import SnapKit +import Then + +final class HomeResultListView: UIView { + private let titleView = TitleView(title: "๋ฏธ์…˜ ๊ฒฐ๊ณผ", subTitle: "์ง€๊ธˆ๊นŒ์ง€ ์ˆ˜ํ–‰ํ•œ ๋ฏธ์…˜ ๊ฒฐ๊ณผ๋ฅผ ํ™•์ธํ•˜์„ธ์š”", hasButton: false) + + private(set) lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCompositionalLayout()).then { + $0.backgroundColor = .background + $0.contentInset = .init(top: 0, left: 0, bottom: 50, right: 0) + } + + private(set) lazy var dataSource = makeCollectionViewDiffableDataSource(collectionView) + + override init(frame: CGRect) { + super.init(frame: frame) + collectionView.layoutMargins = .init(top: 0, left: 20, bottom: 0, right: 20) + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension HomeResultListView { + private func setLayout() { + addSubview(titleView) + addSubview(collectionView) + + titleView.snp.makeConstraints { + $0.top.horizontalEdges.equalTo(safeAreaLayoutGuide) + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(titleView.snp.bottom) + $0.horizontalEdges.bottom.equalToSuperview() + } + } +} + +//MARK: set CollectionView +extension HomeResultListView { + private func makeCompositionalLayout() -> UICollectionViewLayout { + let configuration = UICollectionViewCompositionalLayoutConfiguration() + configuration.contentInsetsReference = .layoutMargins + + return UICollectionViewCompositionalLayout (sectionProvider: { sectionIndex, environment in + + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.28) + ) + ) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.28) + ), + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 8 + + return section + }, configuration: configuration) + } + + private func makeCollectionViewDiffableDataSource(_ collectionView: UICollectionView) -> UICollectionViewDiffableDataSource { + + let resultCellRegistration = UICollectionView.CellRegistration { cell, indexPath, item in + switch item { + case .result(let payload): + cell.configure(with: payload) + default: + break + } + } + + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell(using: resultCellRegistration, for: indexPath, item: item) + } + + return dataSource + } + + func setSnapshot(with data: [DetailCollectionView.Item]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(data, toSection: 0) + + dataSource.apply(snapshot, animatingDifferences: false) + } +} diff --git a/RocketCall/View/HomeTab/View/TotalCardView.swift b/RocketCall/View/HomeTab/View/TotalCardView.swift new file mode 100644 index 0000000..96cb382 --- /dev/null +++ b/RocketCall/View/HomeTab/View/TotalCardView.swift @@ -0,0 +1,158 @@ +// +// SmallCardView.swift +// RocketCall +// +// Created by t2025-m0143 on 3/25/26. +// + +import UIKit +import SnapKit +import Then + +class TotalCardView: BaseCardView { + //MARK: Category Enum + enum CardCategory: Int, Hashable { + case totalTime = 0 + case leftTime + case totalCount + case streak + + var title: String { + switch self { + case .totalTime: "์ด ์‹œ๊ฐ„" + case .leftTime: "๋‚จ์€ ํ•ญํ–‰ ์‹œ๊ฐ„" + case .totalCount: "๋ฏธ์…˜" + case .streak: "์—ฐ์† ๊ธฐ๋ก" + } + } + + var symbol: String { + switch self { + case .totalTime: "chart.bar.fill" + case .leftTime: "hourglass" + case .totalCount: "medal" + case .streak: "bolt" + } + } + + var color: UIColor { + switch self { + case .totalTime: + return UIColor(red: 0.17, green: 0.50, blue: 1.00, alpha: 1.00) // HEX #2B7FFF + case .leftTime: + return UIColor(red: 0.00, green: 0.79, blue: 0.31, alpha: 1.00) // HEX #00C950 + case .totalCount: + return UIColor(red: 1.00, green: 0.41, blue: 0.00, alpha: 1.00) // HEX #FF6900 + case .streak: + return UIColor(red: 0.68, green: 0.27, blue: 1.00, alpha: 1.00) // HEX #AD46FF + } + } + + var titleColor: UIColor { + switch self { + case .totalTime: + return UIColor(red: 0.32, green: 0.64, blue: 1.00, alpha: 1.00) // HEX #51A2FF + case .leftTime: + return UIColor(red: 0.02, green: 0.87, blue: 0.45, alpha: 1.00) // HEX #05DF72 + case .totalCount: + return UIColor(red: 1.00, green: 0.54, blue: 0.02, alpha: 1.00) // HEX #FF8904 + case .streak: + return UIColor(red: 0.76, green: 0.48, blue: 1.00, alpha: 1.00) // HEX #C27AFF + } + } + } + + //MARK: set Attributes + private var type: CardCategory = .totalTime + private let symbolConfig = UIImage.SymbolConfiguration(scale: .small) + + private lazy var symbol = UIImageView().then { + $0.image = UIImage(systemName: type.symbol, withConfiguration: symbolConfig) + $0.tintColor = type.titleColor + } + + private lazy var title = UILabel().then { + $0.text = type.title + $0.textColor = type.titleColor + $0.font = .systemFont(ofSize: 12, weight: .semibold) + } + + private let valueLabel = UILabel(text: "", config: .homeViewHeader) + private let detailLabel = UILabel(text: "", config: .subTitle) + + convenience init(type: CardCategory) { + self.init(frame: .zero) + + backgroundColor = type.color.withAlphaComponent(0.2) + layer.borderColor = type.color.withAlphaComponent(0.3).cgColor + + setLayout(type: type) + } +} + +extension TotalCardView { + private func setLayout(type: CardCategory) { +// let titleStackView = SymbolLabelStack(symbol: type.symbol, symbolColor: type.titleColor, label: title) + + let titleStackView = UIStackView(arrangedSubviews: [symbol, title]).then { + $0.axis = .horizontal + $0.spacing = 5 + + symbol.setContentHuggingPriority(.required, for: .horizontal) + symbol.setContentCompressionResistancePriority(.required, for: .horizontal) + + $0.snp.makeConstraints { + $0.height.equalTo(title.snp.height) + } + } + + let stackView = UIStackView(arrangedSubviews: [titleStackView, valueLabel, detailLabel]).then { + $0.axis = .vertical + $0.spacing = 5 + $0.alignment = .leading + + detailLabel.setContentHuggingPriority(.defaultLow, for: .vertical) + detailLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + } + + addSubview(stackView) + + stackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(15) + } + } +} + +extension TotalCardView { + func configure(_ data: HomeViewModel.SumResult) { + // ์ƒ‰์ƒ ์„ค์ • + backgroundColor = data.cardType.color.withAlphaComponent(0.2) + layer.borderColor = data.cardType.color.withAlphaComponent(0.3).cgColor + + title.text = data.cardType.title + title.textColor = data.cardType.titleColor + + symbol.image = UIImage(systemName: data.cardType.symbol, withConfiguration: symbolConfig) + symbol.tintColor = data.cardType.titleColor + + // text ์„ค์ • + configureLabelText(data: data) + } + + private func configureLabelText(data: HomeViewModel.SumResult) { + switch data.cardType { + case .totalTime, .leftTime: + valueLabel.text = "\(data.value)์‹œ๊ฐ„" + detailLabel.text = "\(data.detail)๋ถ„" + + case .totalCount: + valueLabel.text = "\(data.value)ํšŒ" + detailLabel.text = "์™„๋ฃŒ" + + case .streak: + valueLabel.text = "\(data.value)์ผ" + detailLabel.text = "ํ•ญํ•ด ์ค‘๐Ÿš€" + } + } +} diff --git a/RocketCall/View/MainController.swift b/RocketCall/View/MainController.swift new file mode 100644 index 0000000..30b1722 --- /dev/null +++ b/RocketCall/View/MainController.swift @@ -0,0 +1,75 @@ +// +// MainController.swift +// RocketCall +// +// Created by t2025-m0143 on 3/23/26. +// +import UIKit +import SnapKit +import RxSwift + +class MainController: UITabBarController { + let coreDataManager = CoreDataManager() + lazy var timerViewModel = TimerViewModel(coreDataManager: coreDataManager) + + private let disposeBag = DisposeBag() + + override var childForStatusBarStyle: UIViewController? { + selectedViewController + } + + override func viewDidLoad() { + super.viewDidLoad() + configure() + tabBar.tintColor = .mainPoint + self.view.backgroundColor = .background + + // ํƒ€์ด๋จธ ์•Œ๋ฆผ ๋ˆ„๋ฅด๋ฉด MissionViewController๋กœ ์—ฐ๊ฒฐ + NotificationManager.shared.timerNotificationTapped + .subscribe(onNext: { [weak self] in + self?.selectedIndex = 2 + }) + .disposed(by: disposeBag) + + // ์‚ฌ์šฉ์ž๊ฐ€ ์•ฑ์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์„๋•Œ ๋ฏธ์…˜๊ฒฐ๊ณผํ™”๋ฉด์„ ๋„์šฐ๋„๋ก ํ•จ + timerViewModel.missionResult + .observe(on: MainScheduler.instance) // ๋ฉ”์ธ์Šค๋ ˆ๋“œ ์ฒ˜๋ฆฌํ•˜๊ธฐ ํ•„์ˆ˜!! + // ๊ฒฐ๊ณผ id๋กœ ๋ฏธ์…˜๊ฒฐ๊ณผ์ฐฝ ํ˜ธ์ถœ + .subscribe(onNext: { [weak self] resultId in + self?.showMissionResult(resultId: resultId) + }) + .disposed(by: disposeBag) + } +} + +extension MainController { + private func configure() { + let firstVC = CustomNavigationController(rootViewController: HomeMainViewController(mainController: self, viewModel: HomeViewModel(coreDataManager: coreDataManager, notificationManager: NotificationManager.shared))) + let secondVC = CustomNavigationController(rootViewController: AlarmListViewController(viewModel: AlarmListViewModel(coreDataManager: coreDataManager))) + let thirdVC = UINavigationController(rootViewController: MissionViewController(coreDataManager: coreDataManager, timerViewModel: timerViewModel)) + let fourthVC = CustomNavigationController(rootViewController: StopWatchViewController()) + + firstVC.tabBarItem = UITabBarItem(title: "ํ™ˆ", image: UIImage(systemName: "house"), tag: 0) + secondVC.tabBarItem = UITabBarItem(title: "์•Œ๋žŒ", image: UIImage(systemName: "alarm"), tag: 1) + thirdVC.tabBarItem = UITabBarItem(title: "๋ฏธ์…˜", image: UIImage(systemName: "timer"), tag: 2) + fourthVC.tabBarItem = UITabBarItem(title: "์ž์œ ํ•ญํ–‰", image: UIImage(systemName: "stopwatch"), tag: 3) + + viewControllers = [firstVC, secondVC, thirdVC, fourthVC] + } + // ๊ฒฐ๊ณผ๊ฐ€ ์˜ค๋ฉด ๋ฏธ์…˜๊ฒฐ๊ณผ ๋„์›Œ์ฃผ๋Š” ๋ฉ”์„œ๋“œ + private func showMissionResult(resultId: UUID) { + selectedIndex = 2 + // ๋„ค๋น„๊ฒŒ์ด์…˜ ์ปจํŠธ๋กค๋Ÿฌ ๊บผ๋ƒ„ + guard let missionNavigationController = viewControllers?[2] as? UINavigationController else { return } + // ๊ฒฐ๊ณผ ํ™”๋ฉด ์ „ ๋ฏธ์…˜ ๋ชฉ๋ก ํ™”๋ฉด๊นŒ์ง€ ์Šคํƒ ์ •๋ฆฌ + if let missionViewController = missionNavigationController.viewControllers.first(where: { $0 is MissionViewController }) { + missionNavigationController.popToViewController(missionViewController, animated: false) + } + // ์•„๋‹๊ฒฝ์šฐ ๊ฒฐ๊ณผVC ๋„์›€ + let resultViewController = MissionResultViewController( + coreDataManager: coreDataManager, + resultId: resultId + ) + missionNavigationController.pushViewController(resultViewController, animated: true) + } +} diff --git a/RocketCall/View/Mission/Create/CreateMissionView.swift b/RocketCall/View/Mission/Create/CreateMissionView.swift new file mode 100644 index 0000000..2fbb7f5 --- /dev/null +++ b/RocketCall/View/Mission/Create/CreateMissionView.swift @@ -0,0 +1,234 @@ +// +// CreateMissionView.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/25/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +class CreateMissionView: UIView { + + let quickItemTapped = PublishSubject() + private let disposeBag = DisposeBag() + + private let titleView = TitleView(title: "๋ฏธ์…˜ ๊ณ„ํš", subTitle: "์ปค์Šคํ…€ ๋ฝ€๋ชจ๋„๋กœ๋ฅผ ์„ค์ •ํ•˜์„ธ์š”.", hasButton: false) + + private let scrollView = UIScrollView() + private let contentStackView = UIStackView() + + private let quickTitleLabel = UILabel() + private let quickGridStackView = UIStackView() + private var quickCardViews: [BaseCardView] = [] + private let quickItems: [(icon: String, title: String, subtitle: String)] = [ + ("moon.stars.fill", "25๋ถ„ ๋ฏธ์…˜", "25๋ถ„ ์ง‘์ค‘ / 5๋ถ„ ํœด์‹"), + ("moon.stars.fill", "50๋ถ„ ๋ฏธ์…˜", "50๋ถ„ ์ง‘์ค‘ / 10๋ถ„ ํœด์‹"), + ("moon.stars.fill", "90๋ถ„ ๋ฏธ์…˜", "90๋ถ„ ์ง‘์ค‘ / 20๋ถ„ ํœด์‹"), + ("moon.stars.fill", "120๋ถ„ ๋ฏธ์…˜", "120๋ถ„ ์ง‘์ค‘ / 20๋ถ„ ํœด์‹") + ] + + private let missionNameLabel = UILabel() + let missionNameField = UITextField() + + let studyStepper = CustomStepper(title: "์ง‘์ค‘ ์‹œ๊ฐ„", subtitle: "๋ถ„ ๋‹จ์œ„๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", initialValue: 0) + let restStepper = CustomStepper(title: "ํœด์‹ ์‹œ๊ฐ„", subtitle: "๋ถ„ ๋‹จ์œ„๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", initialValue: 0) + let cycleStepper = CustomStepper(title: "๋ฐ˜๋ณต ํšŸ์ˆ˜", subtitle: "๋ฐ˜๋ณตํ•  ํšŸ์ˆ˜๋ฅผ ์„ค์ •ํ•ด์ฃผ์„ธ์š”.", initialValue: 0) + + private let summaryCard = BaseCardView() + private let totalTimeLabel = UILabel() + let totalTimeValueLabel = UILabel() + private let intervalLabel = UILabel() + let intervalValueLabel = UILabel() + + let createButton = RectangleButton(title: "๋ฏธ์…˜ ์ƒ์„ฑ", image: UIImage(systemName: "play"), backgroundColor: .mainPoint) + + override init(frame: CGRect) { + super.init(frame: frame) + setAttributes() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension CreateMissionView { + private func setAttributes() { + self.backgroundColor = .background + scrollView.showsVerticalScrollIndicator = false + + let labelConfig = LabelConfiguration.missionLabel + let sub16Config = LabelConfiguration.sub16 + + quickTitleLabel.text = "๋น ๋ฅธ ์„ ํƒ" + quickTitleLabel.font = .systemFont(ofSize: 14, weight: .semibold) + quickTitleLabel.textColor = .subLabel + + quickGridStackView.axis = .vertical + quickGridStackView.spacing = 10 + + missionNameLabel.text = "๋ฏธ์…˜๋ช…" + missionNameLabel.font = labelConfig.font + missionNameLabel.textColor = labelConfig.color + missionNameLabel.setContentHuggingPriority(.required, for: .horizontal) + + missionNameField.attributedPlaceholder = NSAttributedString( + string: "๋ฏธ์…˜๋ช…์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", + attributes: [.foregroundColor: UIColor.secondLabel ] + ) + missionNameField.font = labelConfig.font + missionNameField.textColor = labelConfig.color + missionNameField.layer.cornerRadius = 10 + missionNameField.backgroundColor = .cardBackground + missionNameField.layer.borderWidth = 1 + missionNameField.layer.borderColor = UIColor(red: 201/255.0, green: 209/255.0, blue: 232/255.0, alpha: 0.3).cgColor + missionNameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 0)) + missionNameField.leftViewMode = .always + + totalTimeLabel.text = "์ด ์†Œ์š” ์‹œ๊ฐ„" + totalTimeLabel.font = sub16Config.font + totalTimeLabel.textColor = sub16Config.color + + totalTimeValueLabel.font = LabelConfiguration.main24Bold.font + totalTimeValueLabel.textColor = LabelConfiguration.main24Bold.color + totalTimeValueLabel.textAlignment = .right + + intervalLabel.text = "๋ฐ˜๋ณต ์ฃผ๊ธฐ" + intervalLabel.font = sub16Config.font + intervalLabel.textColor = sub16Config.color + + intervalValueLabel.font = sub16Config.font + intervalValueLabel.textColor = sub16Config.color + intervalValueLabel.textAlignment = .right + + + + } + private func setLayout() { + [titleView,scrollView].forEach { addSubview($0) } + scrollView.addSubview(contentStackView) + + titleView.snp.makeConstraints { + $0.top.leading.equalTo(safeAreaLayoutGuide) + } + + scrollView.snp.makeConstraints { + $0.top.equalTo(titleView.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() + } + + contentStackView.axis = .vertical + contentStackView.spacing = 20 + + contentStackView.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().offset(-20) + $0.width.equalTo(scrollView).offset(-40) + } + + createGrid() //Grid ์ƒ์„ฑ + + let missionNameRowStack = UIStackView(arrangedSubviews: [missionNameLabel,missionNameField]) + missionNameRowStack.axis = .horizontal + missionNameRowStack.spacing = 30 + missionNameRowStack.alignment = .center + + missionNameField.snp.makeConstraints { + $0.height.equalTo(50) + } + + let totalRowStack = UIStackView(arrangedSubviews: [totalTimeLabel, totalTimeValueLabel]) + totalRowStack.axis = .horizontal + totalRowStack.distribution = .fillEqually + + let intervalRowStack = UIStackView(arrangedSubviews: [intervalLabel, intervalValueLabel]) + intervalRowStack.axis = .horizontal + intervalRowStack.distribution = .fillEqually + + let summaryStack = UIStackView(arrangedSubviews: [totalRowStack, intervalRowStack]) + summaryStack.axis = .vertical + summaryStack.spacing = 10 + + summaryCard.addSubview(summaryStack) + summaryStack.snp.makeConstraints { + $0.edges.equalToSuperview().inset(20) + } + + createButton.snp.makeConstraints { + $0.height.equalTo(50) + } + [quickTitleLabel, quickGridStackView, missionNameRowStack, studyStepper, restStepper, cycleStepper, summaryCard, createButton].forEach { contentStackView.addArrangedSubview($0) } + } +} + +extension CreateMissionView { + private func createGrid() { + for row in 0..<2 { + let rowStack = UIStackView() + rowStack.axis = .horizontal + rowStack.spacing = 10 + rowStack.distribution = .fillEqually + rowStack.snp.makeConstraints { + $0.height.equalTo(rowStack.snp.width).dividedBy(2) + } + for column in 0..<2 { + let item = quickItems[row * 2 + column] + let view = BaseCardView() + + let iconImageView = UIImageView(image: UIImage(systemName: item.icon)) + iconImageView.tintColor = .white + iconImageView.contentMode = .scaleAspectFit + iconImageView.snp.makeConstraints { + $0.size.equalTo(50) + } + + let titleLabel = UILabel() + titleLabel.text = item.title + titleLabel.font = .boldSystemFont(ofSize: 16) + titleLabel.textColor = .mainLabel + + let subtitleLabel = UILabel() + subtitleLabel.text = item.subtitle + subtitleLabel.font = .boldSystemFont(ofSize: 12) + subtitleLabel.textColor = .systemBlue + + let stackView = UIStackView(arrangedSubviews: [iconImageView, titleLabel, subtitleLabel]) + stackView.axis = .vertical + stackView.alignment = .leading + stackView.spacing = 8 + + view.addSubview(stackView) + quickCardViews.append(view) + stackView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview().inset(20) + $0.bottom.lessThanOrEqualToSuperview().inset(20) + } + + // ๊ฐ View ์ƒ์„ฑ ์‹œ ์ด๋ฒคํŠธ ์ ์šฉ + let tap = UITapGestureRecognizer() + view.addGestureRecognizer(tap) + view.isUserInteractionEnabled = true + tap.rx.event + .map { _ in row * 2 + column } + .bind(to: quickItemTapped) + .disposed(by: disposeBag) + + rowStack.addArrangedSubview(view) + } + quickGridStackView.addArrangedSubview(rowStack) + } + } +} + +extension CreateMissionView { + // ๊ฐ™์€ ์นด๋“œ ๋ˆ„๋ฅผ ๊ฒฝ์šฐ nil ๋ฐฉ์ถœ -> ์˜ต์…”๋„๋กœ ์ฒ˜๋ฆฌ + func updateQuickItem(selectedIndex: Int?) { + quickCardViews.enumerated().forEach { index, card in + card.isOn = index == selectedIndex + } + } +} diff --git a/RocketCall/View/Mission/Create/CreateMissionViewController.swift b/RocketCall/View/Mission/Create/CreateMissionViewController.swift new file mode 100644 index 0000000..f9eeb9b --- /dev/null +++ b/RocketCall/View/Mission/Create/CreateMissionViewController.swift @@ -0,0 +1,131 @@ +// +// CreateMissionViewController.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/25/26. +// + +import UIKit +import RxSwift +import RxCocoa + +class CreateMissionViewController: UIViewController { + + private let mainView = CreateMissionView() + private let disposeBag = DisposeBag() + private let viewModel: CreateMissionViewModel + // ๋ฏธ์…˜ํŽ˜์ด๋กœ๋“œ ๋„˜๊ธฐ๊ธฐ ์œ„ํ•ด ์ถ”๊ฐ€ํ•จ + private let onMissionCreated: ((MissionPayload) -> Void)? + + init(viewModel: CreateMissionViewModel, onMissionCreated: ((MissionPayload) -> Void)? = nil) { + self.viewModel = viewModel + self.onMissionCreated = onMissionCreated + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func viewDidLoad() { + super.viewDidLoad() + bind() + } +} + +extension CreateMissionViewController { + private func bind() { + let input = CreateMissionViewModel.Input( + missionName: mainView.missionNameField.rx.text.orEmpty.asObservable(), + studyTime: mainView.studyStepper.value.asObservable(), + restTime: mainView.restStepper.value.asObservable(), + cycleCount: mainView.cycleStepper.value.asObservable(), + quickItemSelected: mainView.quickItemTapped.asObservable(), + createButtonTapped: mainView.createButton.rx.tap.asObservable() + ) + + let output = viewModel.transform(input) + + output.totalTime + .bind(to: mainView.totalTimeValueLabel.rx.text) + .disposed(by: disposeBag) + + output.intervalText + .bind(to: mainView.intervalValueLabel.rx.text) + .disposed(by: disposeBag) + + output.selectedQuickItem + .bind(onNext: { [weak self] index in + self?.mainView.updateQuickItem(selectedIndex: index) + }) + .disposed(by: disposeBag) + + let isQuickSelected = output.selectedQuickItem + .map { $0 != nil } + .share() + + isQuickSelected + .bind(to: mainView.studyStepper.isQuickSelected) + .disposed(by: disposeBag) + + isQuickSelected + .bind(to: mainView.restStepper.isQuickSelected) + .disposed(by: disposeBag) + + output.quickStudyTime + .bind(onNext: { [weak self] studyTime in + self?.mainView.studyStepper.value.accept(studyTime) + }) + .disposed(by: disposeBag) + + output.quickRestTime + .bind(onNext: { [weak self] restTime in + self?.mainView.restStepper.value.accept(restTime) + }) + .disposed(by: disposeBag) + + output.isCreateButtonEnabled + .bind(to: mainView.createButton.rx.isEnabled) + .disposed(by: disposeBag) + + //mission ๊ฐ’๊นŒ์ง€ ๊ฐ™์ด ๋ณด๋‚ด์คŒ + output.createdMission + .subscribe(onNext: { [weak self] mission in + guard let self else { return } + self.navigationController?.popViewController(animated: false) + self.onMissionCreated?(mission) + }) + .disposed(by: disposeBag) + + output.error + .subscribe(onNext: { [weak self] error in + guard let self else { return } + self.showErrorAlert(error: error) + }) + .disposed(by: disposeBag) + } +} + +extension CreateMissionViewController { + + private func showErrorAlert(error: CoreDataManager.CoreDataError) { + let message: String + switch error { + case .descriptionLoadFailed: + message = "๋ฐ์ดํ„ฐ ๋ชจ๋ธ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + case .saveFailed: + message = "๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + case .loadFailed: + message = "๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + case .empty: + message = "๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." + } + let alert = UIAlertController(title: "์˜ค๋ฅ˜", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default, handler: nil)) + present(alert, animated: true) + } +} diff --git a/RocketCall/View/Mission/Create/CustomStepper.swift b/RocketCall/View/Mission/Create/CustomStepper.swift new file mode 100644 index 0000000..4cedadd --- /dev/null +++ b/RocketCall/View/Mission/Create/CustomStepper.swift @@ -0,0 +1,118 @@ +// +// CustomStepper.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/25/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +class CustomStepper: UIView { + + private let disposeBag = DisposeBag() + let value: BehaviorRelay + let isQuickSelected = BehaviorRelay(value: false) + + private let labelStackView = UIStackView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + + private let stepperStackView = UIStackView() + private let plusButton = CircleButton(size: 30, backgroundColor: .cardBackground, image: UIImage(systemName: "plus"), tintColor: .mainLabel) + private let minusButton = CircleButton(size: 30, backgroundColor: .cardBackground, image: UIImage(systemName: "minus"), tintColor: .mainLabel) + private let valueLabel = UILabel() + + init(title: String, subtitle: String, initialValue: Int) { + self.value = BehaviorRelay(value: initialValue) + super.init(frame: .zero) + titleLabel.text = title + subtitleLabel.text = subtitle + setAttributes() + setLayout() + bind() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension CustomStepper { + private func bind() { + value + .map { "\($0)" } + .bind(to: valueLabel.rx.text) + .disposed(by: disposeBag) + + Observable.combineLatest(value, isQuickSelected) + .map { value, isQuickSelected in !isQuickSelected && value > 0 } + .bind(to: minusButton.rx.isEnabled) + .disposed(by: disposeBag) + + Observable.combineLatest(value, isQuickSelected) + .map { value, isQuickSelected in !isQuickSelected && value < 180 } + .bind(to: plusButton.rx.isEnabled) + .disposed(by: disposeBag) + + + plusButton.rx.tap + .subscribe(onNext: { [weak self] in + guard let self else { return } + self.value.accept(self.value.value + 1) + }).disposed(by: disposeBag) + + minusButton.rx.tap + .subscribe(onNext: { [weak self] in + guard let self else { return } + self.value.accept(self.value.value - 1) + }).disposed(by: disposeBag) + + } +} + +extension CustomStepper { + private func setAttributes() { + + titleLabel.font = LabelConfiguration.missionLabel.font + titleLabel.textColor = LabelConfiguration.missionLabel.color + + subtitleLabel.font = .systemFont(ofSize: 12) + subtitleLabel.textColor = .secondLabel + + valueLabel.font = LabelConfiguration.main24Bold.font + valueLabel.textColor = LabelConfiguration.main24Bold.color + valueLabel.textAlignment = .center + valueLabel.setContentHuggingPriority(.required, for: .horizontal) + + labelStackView.axis = .vertical + labelStackView.spacing = 4 + labelStackView.alignment = .leading + + stepperStackView.axis = .horizontal + stepperStackView.spacing = 14 + stepperStackView.alignment = .center + } + + private func setLayout() { + [titleLabel, subtitleLabel].forEach { labelStackView.addArrangedSubview($0) } + [minusButton, valueLabel, plusButton].forEach { stepperStackView.addArrangedSubview($0) } + + addSubview(labelStackView) + addSubview(stepperStackView) + + labelStackView.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.top.bottom.equalToSuperview().inset(10) + $0.trailing.lessThanOrEqualTo(stepperStackView.snp.leading).offset(-12) + } + + stepperStackView.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.top.bottom.equalToSuperview().inset(10) + } + } +} + diff --git a/RocketCall/View/Mission/List/ActivatedMissionCell.swift b/RocketCall/View/Mission/List/ActivatedMissionCell.swift new file mode 100644 index 0000000..bfef801 --- /dev/null +++ b/RocketCall/View/Mission/List/ActivatedMissionCell.swift @@ -0,0 +1,151 @@ +// +// ActivatedMissionCell.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/25/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +class ActivatedMissionCell: UICollectionViewCell { + + static let id = "ActivatedMissionCell" + + var disposeBag = DisposeBag() + + private let containerView = BaseCardView() + + private let stateLabel = StateLabel(text: "", config: .success) + private let titleLabel = UILabel() + private let timeLabel = UILabel() + + private let progressView = UIProgressView() + + private let buttonStackView = UIStackView() + private let startButton = RectangleButton(image: UIImage(systemName: ""), backgroundColor: .mainPoint.withAlphaComponent(0.2), tintColor: .mainPoint) + var pauseResumeButtonTapped: Observable { startButton.rx.tap.asObservable() } + private let stopButton = RectangleButton(image: UIImage(systemName: "stop"), backgroundColor: .mainLabel.withAlphaComponent(0.1), tintColor: .mainLabel) + var stopButtonTapped: Observable { stopButton.rx.tap.asObservable() } + + override init(frame: CGRect) { + super.init(frame: frame) + setAttributes() + setLayout() + } + + override func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ActivatedMissionCell { + private func setAttributes() { + let titleConfig = LabelConfiguration.main24Bold + let timeConfig = LabelConfiguration.missionTime + + titleLabel.font = titleConfig.font + titleLabel.textColor = titleConfig.color + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + + timeLabel.textAlignment = .right + timeLabel.font = timeConfig.font + timeLabel.textColor = timeConfig.color + + buttonStackView.axis = .horizontal + buttonStackView.spacing = 10 + + progressView.progressTintColor = .mainPoint + progressView.trackTintColor = .mainPoint.withAlphaComponent(0.2) + + var startConfig = startButton.configuration + startConfig?.background.strokeColor = .mainPoint + startConfig?.background.strokeWidth = 1 + startButton.configuration = startConfig + + var resetConfig = stopButton.configuration + resetConfig?.background.strokeColor = .white + resetConfig?.background.strokeWidth = 1 + stopButton.configuration = resetConfig + + } + private func setLayout() { + contentView.addSubview(containerView) + + [startButton, stopButton].forEach { buttonStackView.addArrangedSubview($0) } + [stateLabel, titleLabel, timeLabel, progressView, buttonStackView].forEach { containerView.addSubview($0) } + + containerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + stateLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().offset(20) + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(stateLabel.snp.bottom).offset(10) + $0.leading.equalToSuperview().offset(20) + $0.trailing.equalTo(timeLabel.snp.leading).offset(-10) + } + + timeLabel.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalToSuperview().offset(-20) + } + + progressView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(10) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(4) + } + + buttonStackView.snp.makeConstraints { + $0.top.equalTo(progressView.snp.bottom).offset(10) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().inset(20) + } + + startButton.snp.makeConstraints { + $0.width.equalTo(stopButton).multipliedBy(5) + $0.height.equalTo(stopButton.snp.height) + } + stopButton.snp.makeConstraints { + $0.height.equalTo(stopButton.snp.width) + } + + } +} + +extension ActivatedMissionCell { + func config(mission: ActivatedMissionPayload) { + let state = mission.isConcentrating ? "์ง‘์ค‘" : "ํœด์‹" + stateLabel.text = "\(mission.currentCycle)/\(mission.mission.cycle) ์‚ฌ์ดํด ยท \(state)" + titleLabel.text = mission.mission.title + + let minutes = mission.remainingTime / 60 + let second = mission.remainingTime % 60 + + timeLabel.text = String(format: "%02d:%02d", minutes, second) + + let image = mission.isPaused ? UIImage(systemName: "play") : UIImage(systemName: "pause") + let title = mission.isPaused ? "์žฌ๊ฐœ" : "์ผ์‹œ์ •์ง€" + var config = startButton.configuration + config?.image = image + config?.title = title + config?.imagePadding = 10 + startButton.configuration = config + + let totalTime = mission.isConcentrating ? mission.mission.concentrateTime * 60 : mission.mission.breakTime * 60 + let progress = totalTime > 0 ? 1.0 - Float(mission.remainingTime) / Float(totalTime) : 0 + progressView.setProgress(progress, animated: false) + + } +} diff --git a/RocketCall/View/Mission/List/CustomMissionCell.swift b/RocketCall/View/Mission/List/CustomMissionCell.swift new file mode 100644 index 0000000..4938437 --- /dev/null +++ b/RocketCall/View/Mission/List/CustomMissionCell.swift @@ -0,0 +1,115 @@ +// +// CustomMissionCell.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/25/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +class CustomMissionCell: UICollectionViewCell { + + static let id = "CustomMissionCell" + + var disposeBag = DisposeBag() + + private let containerView = BaseCardView() + + private let labelStackView = UIStackView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let cycleLabel = UILabel() + + private let rightStackView = UIStackView() + private let timeLabel = UILabel() + private let startButton = CircleButton(size: 50, backgroundColor: .mainPoint.withAlphaComponent(0.2), image: UIImage(systemName: "play"), tintColor: .mainPoint) + var startButtonTapped: Observable { startButton.rx.tap.asObservable() } + + override init(frame: CGRect) { + super.init(frame: frame) + setAttributes() + setLayout() + } + + override func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() // ์ด์ „ ๊ตฌ๋… ํ•ด์ œ ํ›„ ์ƒˆ๋กœ์šด ๊ตฌ๋… ์ถ”๊ฐ€ -> reuse ๋ฐฉ์ง€ + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension CustomMissionCell { + private func setAttributes() { + + let subtitleConfig = LabelConfiguration(font: .systemFont(ofSize: 12), color: .secondLabel, lines: 1) + + labelStackView.axis = .vertical + labelStackView.spacing = 5 + + titleLabel.font = .systemFont(ofSize: 14, weight: .semibold) + titleLabel.textColor = .mainLabel + + subtitleLabel.font = subtitleConfig.font + subtitleLabel.textColor = subtitleConfig.color + + cycleLabel.font = subtitleConfig.font + cycleLabel.textColor = subtitleConfig.color + + timeLabel.font = .boldSystemFont(ofSize: 18) + timeLabel.textColor = .mainLabel + + rightStackView.axis = .vertical + rightStackView.spacing = 5 + rightStackView.alignment = .trailing + + startButton.layer.borderColor = UIColor.mainPoint.cgColor + startButton.layer.borderWidth = 1 + + } + + private func setLayout() { + contentView.addSubview(containerView) + [titleLabel,subtitleLabel,cycleLabel].forEach { labelStackView.addArrangedSubview($0) } + [timeLabel,startButton].forEach { rightStackView.addArrangedSubview($0) } + [labelStackView,rightStackView].forEach { containerView.addSubview($0) } + + containerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + labelStackView.snp.makeConstraints { + $0.top.leading.equalToSuperview().offset(20) + $0.trailing.equalTo(rightStackView.snp.leading) + } + + rightStackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().offset(-20) + $0.top.bottom.equalToSuperview().inset(20) + } + } +} + +extension CustomMissionCell { + func config(mission: MissionPayload) { + titleLabel.text = mission.title + subtitleLabel.text = "\(mission.concentrateTime)๋ถ„ ์ง‘์ค‘ / \(mission.breakTime)๋ถ„ ํœด์‹" + cycleLabel.text = "\(mission.cycle)์‚ฌ์ดํด" + + let hour = (mission.concentrateTime + mission.breakTime) * mission.cycle / 60 + let minute = (mission.concentrateTime + mission.breakTime) * mission.cycle % 60 + + // ํ•„์š”ํ•œ๊ฐ€? + if hour > 0 { + timeLabel.text = "\(hour)h \(minute)m" + } else { + timeLabel.text = "\(minute)m" + } + } +} diff --git a/RocketCall/View/Mission/List/MissionHeaderView.swift b/RocketCall/View/Mission/List/MissionHeaderView.swift new file mode 100644 index 0000000..5fe6fba --- /dev/null +++ b/RocketCall/View/Mission/List/MissionHeaderView.swift @@ -0,0 +1,45 @@ +// +// MissionHeaderView.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/25/26. +// + +import UIKit +import SnapKit + +class MissionHeaderView: UICollectionReusableView { + static let id = "MissionHeaderView" + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setAttributes() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension MissionHeaderView { + private func setAttributes() { + titleLabel.font = .systemFont(ofSize: 14, weight: .semibold) + titleLabel.textColor = .subLabel + } + + private func setLayout() { + addSubview(titleLabel) + titleLabel.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +extension MissionHeaderView { + func config(title: String) { + titleLabel.text = title + } +} diff --git a/RocketCall/View/Mission/List/MissionView.swift b/RocketCall/View/Mission/List/MissionView.swift new file mode 100644 index 0000000..07c8f58 --- /dev/null +++ b/RocketCall/View/Mission/List/MissionView.swift @@ -0,0 +1,107 @@ +// +// MissionView.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/24/26. +// + +import UIKit +import SnapKit + +enum MissionSection: Int, CaseIterable { + case activatedMission + case customMission + + var title: String { + switch self { + case .activatedMission: + return "ํ™œ์„ฑ ์ž„๋ฌด ๋ชฉ๋ก" + case .customMission: + return "์ปค์Šคํ…€ ์ž„๋ฌด ๋ชฉ๋ก" + } + } +} + +class MissionView: UIView { + let titleView = TitleView(title: "๊ณ„ํš๋œ ์ž„๋ฌด", subTitle: "ํฌ๋ชจ๋„๋กœ ํƒ€์ด๋จธ๋ฅผ ์„ค์ •ํ•˜๊ณ  ์‹œ์ž‘ํ•˜์„ธ์š”.", hasButton: true) + lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + + override init(frame: CGRect) { + super.init(frame: frame) + setAttributes() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension MissionView { + private func setAttributes() { + collectionView.backgroundColor = .background + } + private func setLayout() { + [titleView, collectionView].forEach { addSubview($0) } + + titleView.snp.makeConstraints { + $0.top.horizontalEdges.equalTo(self.safeAreaLayoutGuide) + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(titleView.snp.bottom) + $0.horizontalEdges.bottom.equalToSuperview() + } + } +} + +extension MissionView { + private func createLayout() -> UICollectionViewLayout { + return UICollectionViewCompositionalLayout { sectionIndex, environment in + let sectionType = MissionSection.allCases[sectionIndex] + switch sectionType { + case .activatedMission: + return self.makeActivatedSection() + case .customMission: + return self.makeCustomSection() + } + } + } + + private func makeActivatedSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(160)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(160)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20) + section.interGroupSpacing = 10 + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(60)) + let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) + section.boundarySupplementaryItems = [header] + + return section + } + + private func makeCustomSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(150)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(150)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20) + section.interGroupSpacing = 10 + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(60)) + let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) + section.boundarySupplementaryItems = [header] + + return section + } +} + diff --git a/RocketCall/View/Mission/List/MissionViewController.swift b/RocketCall/View/Mission/List/MissionViewController.swift new file mode 100644 index 0000000..3722ca4 --- /dev/null +++ b/RocketCall/View/Mission/List/MissionViewController.swift @@ -0,0 +1,296 @@ +// +// MissionViewController.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/24/26. +// + +import UIKit +import RxSwift +import RxCocoa + +enum MissionItem: Hashable { + case activatedMission(ActivatedMissionPayload) + case customMission(MissionPayload) +} + +class MissionViewController: UIViewController { + + private let mainView = MissionView() + private let disposeBag = DisposeBag() + private let viewModel: MissionViewModel + private let timerViewModel: TimerViewModel + // ํ™”๋ฉด ๋ฐ–์œผ๋กœ ํŽ˜์ด๋กœ๋“œ ๋„˜๊ธฐ๊ธฐ ์œ„ํ•ด์„œ ํ”„๋กœํผํ‹ฐ ์ถ”๊ฐ€ + private let activatedMissionsRelay = BehaviorRelay<[ActivatedMissionPayload]>(value: []) + + private var missions: [MissionPayload] = [] + private let initialLoadSubject = PublishSubject() + + private var activatedMissions: [ActivatedMissionPayload] = [] + private let activatedMissionSubject = PublishSubject() + + private let pauseResumeMissionSubject = PublishSubject() + private let stopMissionSubject = PublishSubject() + + private let deleteMissionSubject = PublishSubject() + + private lazy var dataSource: UICollectionViewDiffableDataSource = { + let dataSource = UICollectionViewDiffableDataSource(collectionView: mainView.collectionView) {[weak self] collectionView, indexPath, itemIdentifier in + guard let self else { fatalError("Error: self is nil") } + switch itemIdentifier { + case .activatedMission(let mission): + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ActivatedMissionCell.id, for: indexPath) as? ActivatedMissionCell else { fatalError("ActivatedMissionCell dequeueReusableCell error") } + cell.config(mission: mission) + cell.pauseResumeButtonTapped + .map { mission.id } + .bind(to: self.pauseResumeMissionSubject) + .disposed(by: cell.disposeBag) + cell.stopButtonTapped + .map { mission.id } + .bind(to: self.stopMissionSubject) + .disposed(by: cell.disposeBag) + return cell + + case .customMission(let mission): + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomMissionCell.id, for: indexPath) as? CustomMissionCell else { fatalError("CustomMissionCell dequeueReusableCell error") } + cell.config(mission: mission) + cell.startButtonTapped + .map { mission } + .bind(to: self.activatedMissionSubject ) + .disposed(by: cell.disposeBag) + return cell + } + } + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard let sectionType = self.dataSource.sectionIdentifier(for: indexPath.section) else { return UICollectionReusableView() } + guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MissionHeaderView.id, for: indexPath) as? MissionHeaderView else { fatalError("MissionHeaderView dequeueReusableSupplementaryView error")} + header.config(title: sectionType.title) + return header + } + return dataSource + }() + + let coreDataManager: CoreDataManager + + init(coreDataManager: CoreDataManager, timerViewModel: TimerViewModel) { + self.coreDataManager = coreDataManager + self.viewModel = MissionViewModel(coreDataManager: coreDataManager) + self.timerViewModel = timerViewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = mainView + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + initialLoadSubject.onNext(()) + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationController?.isNavigationBarHidden = true + setDelegate() + bind() + } +} + +extension MissionViewController { + private func bind() { + mainView.titleView.addButton.rx.tap + .subscribe(onNext: { [weak self] in + guard let self else { return } + let nextVM = CreateMissionViewModel(coreDataManager: self.coreDataManager) + let nextVC = CreateMissionViewController( + viewModel: nextVM, + // ๋ฏธ์…˜ ์ •๋ณด ํด๋กœ์ € ๋„˜๊น€ + onMissionCreated: { [weak self] mission in + // ์‹œ์ž‘ํ•˜๋ฉด ์ •๋ณด ๋„˜๊ธฐ๋„๋ก + self?.activatedMissionSubject.onNext(mission) + } + ) + self.navigationController?.pushViewController(nextVC, animated: true) + }).disposed(by: disposeBag) + + let input = MissionViewModel.Input(initialize: initialLoadSubject.asObservable()) + let output = viewModel.transform(input) + + output.missions + .subscribe(onNext: { [weak self] missions in + self?.missions = missions + self?.setSnapshot(animated: true) + }) + .disposed(by: disposeBag) + + output.error + .subscribe(onNext: { [weak self] error in + self?.showErrorAlert(error: error) + }) + .disposed(by: disposeBag) + + let timerInput = TimerViewModel.Input( + activatedMission: activatedMissionSubject.asObservable(), + pauseResumeButtonTapped: pauseResumeMissionSubject.asObservable(), + stopButtonTapped: stopMissionSubject.asObservable() + ) + + let timerOutput = timerViewModel.transform(timerInput) + + timerOutput.activatedMissions + .subscribe(onNext: { [weak self] (activatedMissions, animated) in + self?.activatedMissions = activatedMissions + // ์‹œ์ž‘๋œ ๋ฏธ์…˜ ๋ฐ›์•„์„œ ์ €์žฅํ•จ(ํƒ€์ด๋จธ ํ™”๋ฉด์—์„œ ์ •๋ณด ๋ฐ›์„ ์ˆ˜ ์žˆ๋„๋ก) + self?.activatedMissionsRelay.accept(activatedMissions) + self?.setSnapshot(animated: animated) + }) + .disposed(by: disposeBag) + + // ์ƒˆ๋ฏธ์…˜ ์‹œ์ž‘์‹œ ํƒ€์ด๋จธ ํ™”๋ฉด์œผ๋กœ ๋ณด๋‚ด๊ธฐ + timerOutput.startedMission + .subscribe(onNext: { [weak self] activatedMission in + self?.showTimerAnimationViewController(for: activatedMission) + }) + .disposed(by: disposeBag) + + timerOutput.error + .subscribe(onNext: { [weak self] error in + self?.showErrorAlert(error: error) + }) + .disposed(by: disposeBag) + + /* + // ํƒ€์ด๋จธ๋กœ ๋ณด๋‚ด๊ธฐ ์œ„ํ•ด์„œ ๊ฒฐ๊ณผ ๋„์šฐ๋Š” ๋กœ์ง์€ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. + timerOutput.missionResult + .subscribe(onNext: { [weak self] resultId in + guard let self else { return } + let resultVC = MissionResultViewController(coreDataManager: self.coreDataManager, resultId: resultId) + self.navigationController?.pushViewController(resultVC, animated: true) + }) + .disposed(by: disposeBag) + */ + + deleteMissionSubject + .subscribe(onNext: { [weak self] mission in + guard let self else { return } + do { + try self.coreDataManager.deleteMissionEntity(of: mission.id) + self.initialLoadSubject.onNext(()) + } catch { + if let coreDataError = error as? CoreDataManager.CoreDataError { + self.showErrorAlert(error: coreDataError) + } else { + self.showErrorAlert(error: .saveFailed) + } + } + }) + .disposed(by: disposeBag) + } +} + +extension MissionViewController { + private func setDelegate() { + mainView.collectionView.delegate = self + mainView.collectionView.register(ActivatedMissionCell.self, forCellWithReuseIdentifier: ActivatedMissionCell.id) + mainView.collectionView.register(CustomMissionCell.self, forCellWithReuseIdentifier: CustomMissionCell.id) + mainView.collectionView.register(MissionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: MissionHeaderView.id) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let item = dataSource.itemIdentifier(for: indexPath), + case .customMission(let mission) = item else { return nil } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + let delete = UIAction(title: "์‚ญ์ œ", image: UIImage(systemName: "trash.fill"), attributes: .destructive) { [weak self] _ in + self?.deleteMissionSubject.onNext(mission) + } + return UIMenu(title: "",children: [delete]) + } + } + +} + +extension MissionViewController: UICollectionViewDelegate { + // ์…€ ๋ˆ„๋ฅด๋ฉด ํƒ€์ด๋จธ ํ™”๋ฉด์œผ๋กœ ๊ฐ€๋„๋ก ํ•จ + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .activatedMission(let mission): + showTimerAnimationViewController(for: mission) + case .customMission: + break + } + } +} + +extension MissionViewController { + // ํƒ€์ด๋จธ ํ™”๋ฉด ๋„์šฐ๋Š” ์ฝ”๋“œ + private func showTimerAnimationViewController(for activatedMission: ActivatedMissionPayload) { + // ํ•ด๋‹น ํ•˜๋Š” id ๊บผ๋‚ด์„œ ์ €์žฅ + let activatedMissionState = activatedMissionsRelay + .asObservable() + .compactMap { missions in + missions.first(where: { $0.id == activatedMission.id }) + } + let planetImageName = planetImageName(for: activatedMission.id) + // ํ•ด๋‹น id๋กœ ๊ฒฐ๊ณผ์ฐฝ ํ™”๋ฉด ๋„์›€ - ์ผ์‹œ์ •์ง€/์ •์ง€ ๊ฐ™์ด ์ฒ˜๋ฆฌํ•ด์คŒ + let timerViewController = TimerAnimationViewController( + activatedMissionState: activatedMissionState, + planetImageName: planetImageName, + onPauseResumeRequested: { [weak self] in + self?.pauseResumeMissionSubject.onNext(activatedMission.id) + }, + onMissionStopRequested: { [weak self] in + self?.stopMissionSubject.onNext(activatedMission.id) + } + ) + navigationController?.pushViewController(timerViewController, animated: true) + } + // ํƒ€์ด๋จธ ๋งˆ๋‹ค ๋žœ๋คํ•˜๊ฒŒ ํ–‰์„ฑ ๋„์›Œ์ฃผ๊ธฐ + private func planetImageName(for missionId: UUID) -> String { + let imageNames = TimerAnimationView.availablePlanetImageNames + // UUID๋ฅผ -> ๋ฌธ์ž -> ์ˆซ์ž๋ณ€ํ™˜ -> ํ•ฉํ•˜๊ธฐ ๊ทธ๋‹ค์Œ ํ–‰์„ฑ ์ด๋ฏธ์ง€ ์ˆ˜๋กœ ๋‚˜๋ˆ„์–ด์„œ ํ–‰์„ฑ ์ง์ง€์–ด์ฃผ๊ธฐ + let scalarSum = missionId.uuidString.unicodeScalars.reduce(0) { partialResult, scalar in + partialResult + Int(scalar.value) + } + return imageNames[scalarSum % imageNames.count] + } + + private func setSnapshot(animated: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(MissionSection.allCases) + snapshot.appendItems(activatedMissions.map { .activatedMission($0)}, toSection: .activatedMission) + snapshot.appendItems(missions.map { .customMission($0)}, toSection: .customMission) + + if activatedMissions.isEmpty { + snapshot.deleteSections([.activatedMission]) + } + + dataSource.apply(snapshot, animatingDifferences: animated) + } +} + +extension MissionViewController { + + private func showErrorAlert(error: CoreDataManager.CoreDataError) { + let message: String + switch error { + case .descriptionLoadFailed: + message = "๋ฐ์ดํ„ฐ ๋ชจ๋ธ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + case .saveFailed: + message = "๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + case .loadFailed: + message = "๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + case .empty: + message = "๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." + } + let alert = UIAlertController(title: "์˜ค๋ฅ˜", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default, handler: nil)) + present(alert, animated: true) + } +} diff --git a/RocketCall/View/MissionResultView/InfoPairView.swift b/RocketCall/View/MissionResultView/InfoPairView.swift new file mode 100644 index 0000000..aed5baa --- /dev/null +++ b/RocketCall/View/MissionResultView/InfoPairView.swift @@ -0,0 +1,62 @@ +// +// InfoPairView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/24/26. +// +import UIKit +import SnapKit +import Then + +// MissionResultView ๊ตฌ์„ฑ์„ ์œ„ํ•œ ๋ทฐ +class InfoPairView: UIView { + + private let titleLabel = UILabel().then { + $0.textColor = UIColor.lightGray + $0.font = .systemFont(ofSize: 15, weight: .medium) + } + + let dataLabel: UILabel + + init(title: String, data: String? = nil, dataLabel: UILabel? = nil) { + self.dataLabel = dataLabel ?? UILabel().then { + $0.textColor = .white + $0.font = .systemFont(ofSize: 15, weight: .bold) + $0.textAlignment = .right + } + + super.init(frame: .zero) + + titleLabel.text = title + if let data { + self.dataLabel.text = data + } + + configureUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateData(_ text: String) { + dataLabel.text = text + } + + private func configureUI() { + addSubview(titleLabel) + addSubview(dataLabel) + } + + private func setLayout() { + titleLabel.snp.makeConstraints { + $0.leading.centerY.equalToSuperview() + } + + dataLabel.snp.makeConstraints { + $0.trailing.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(15) + } + } +} diff --git a/RocketCall/View/MissionResultView/MissionResultView.swift b/RocketCall/View/MissionResultView/MissionResultView.swift new file mode 100644 index 0000000..cfa644f --- /dev/null +++ b/RocketCall/View/MissionResultView/MissionResultView.swift @@ -0,0 +1,317 @@ +// +// MissionResultView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/24/26. +// +import UIKit +import SnapKit +import Then + +final class MissionResultView: UIView { + + private let scrollView = UIScrollView() + private let contentView = UIView() + + private let iconCircleView = CircleContainerView(size: 100) + + private let resultImageView = UIImageView().then { + $0.image = UIImage(systemName: "checkmark.circle") + $0.tintColor = .white + $0.contentMode = .scaleAspectFit + } + + private let coloredLabel = UILabel().then { + $0.textColor = UIColor.systemBlue + $0.font = .systemFont(ofSize: 15, weight: .bold) + $0.textAlignment = .center + } + + private let titleLabel = UILabel().then { + $0.textColor = .white + $0.font = .systemFont(ofSize: 35, weight: .bold) + $0.textAlignment = .center + } + + private let subtitleLabel = UILabel(config: .sub16).then { + $0.textAlignment = .center + } + + private let infoCardView = BaseCardView() + + private let missionNameTitleLabel = UILabel(text: "ํ˜„์žฌ ๋๋‚œ ๋ฏธ์…˜", config: .sub16).then { + $0.textAlignment = .center + } + + private let missionNameValueLabel = UILabel(config: .main24Bold).then { + $0.textAlignment = .center + } + + private let durationRow = InfoPairView(title: "์ด ์†Œ์š” ์‹œ๊ฐ„") + private let focusRow = InfoPairView(title: "์ง‘์ค‘ ์‹œ๊ฐ„") + private let startTimeRow = InfoPairView(title: "์‹œ์ž‘ ์‹œ๊ฐ„") + private let endTimeRow = InfoPairView(title: "๋๋‚œ ์‹œ๊ฐ„") + private let stateRow = StateRowView(title: "์ƒํƒœ") + + private let Separator1 = SeparatorView() + private let Separator2 = SeparatorView() + private let Separator3 = SeparatorView() + private let Separator4 = SeparatorView() + private let Separator5 = SeparatorView() + + private let completedDateTitleLabel = UILabel(config: .sub16).then { + $0.text = "์™„๋ฃŒ ์ผ์‹œ" + $0.textAlignment = .center + } + + private let completedDateValueLabel = UILabel(config: .sub16).then { + $0.textAlignment = .center + } + + let homeButton = RectangleButton(title: "์ด์ „ ํ™”๋ฉด์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", color: .subLabel) .then { + $0.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold) + $0.backgroundColor = UIColor.white.withAlphaComponent(0.1) + } + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with payload: MissionResultPayload) { + applyState(payload.isCompleted) + + missionNameValueLabel.text = payload.title + durationRow.updateData(Self.formattedDuration(from: payload.start, to: payload.end)) + focusRow.updateData(Self.formattedHourMinute(payload.studyTime)) + startTimeRow.updateData(Self.dateFormatter.string(from: payload.start)) + endTimeRow.updateData(Self.dateFormatter.string(from: payload.end)) + completedDateValueLabel.text = Self.completedDateFormatter.string(from: payload.end) + + stateRow.updateState(payload.isCompleted) + } + + private func applyState(_ state: Bool) { + switch state { + case true: + iconCircleView.backgroundColor = .mainPoint + resultImageView.image = UIImage(systemName: "checkmark.circle") + coloredLabel.text = "โœ“ ๋ฏธ์…˜ ์„ฑ๊ณต" + coloredLabel.textColor = .systemGreen + titleLabel.text = "๋ฏธ์…˜ ์™„๋ฃŒ!" + subtitleLabel.text = "์—ฌ์ •์„ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค" + + case false: + iconCircleView.backgroundColor = .systemRed + resultImageView.image = UIImage(systemName: "xmark.circle") + coloredLabel.text = "โœ— ๋ฏธ์…˜ ์‹คํŒจ" + coloredLabel.textColor = .systemRed + titleLabel.text = "๋ฏธ์…˜ ์‹คํŒจ" + subtitleLabel.text = "์—ฌ์ •์„ ์™„๋ฃŒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค" + } + } + + private func configureUI() { + // ์Šคํฌ๋กค๋ทฐ ์ถ”๊ฐ€ + addSubview(scrollView) + scrollView.addSubview(contentView) + + // ํ—ค๋” ์ถ”๊ฐ€ + contentView.addSubview(iconCircleView) + iconCircleView.addSubview(resultImageView) + contentView.addSubview(coloredLabel) + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + + // ์นด๋“œ๋ทฐ ์ถ”๊ฐ€ + contentView.addSubview(infoCardView) + infoCardView.addSubview(missionNameTitleLabel) + infoCardView.addSubview(missionNameValueLabel) + + infoCardView.addSubview(durationRow) + infoCardView.addSubview(Separator1) + infoCardView.addSubview(focusRow) + infoCardView.addSubview(Separator2) + infoCardView.addSubview(startTimeRow) + infoCardView.addSubview(Separator3) + infoCardView.addSubview(endTimeRow) + infoCardView.addSubview(Separator4) + infoCardView.addSubview(stateRow) + infoCardView.addSubview(completedDateTitleLabel) + infoCardView.addSubview(completedDateValueLabel) + + contentView.addSubview(homeButton) + } + + private func setLayout() { + scrollView.snp.makeConstraints { + $0.leading.trailing.top.equalTo(safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + + contentView.snp.makeConstraints { + $0.edges.equalTo(scrollView.contentLayoutGuide) + $0.width.equalTo(scrollView.frameLayoutGuide) + } + + iconCircleView.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.centerX.equalToSuperview() + } + + resultImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(60) + } + + coloredLabel.snp.makeConstraints { + $0.top.equalTo(iconCircleView.snp.bottom).offset(20) + $0.centerX.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(coloredLabel.snp.bottom).offset(10) + $0.leading.trailing.equalToSuperview().inset(25) + } + + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(10) + $0.leading.trailing.equalToSuperview().inset(25) + } + + infoCardView.isOn = true + infoCardView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(30) + $0.leading.trailing.equalToSuperview().inset(20) + } + + missionNameTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(30) + $0.leading.trailing.equalToSuperview().inset(25) + } + + missionNameValueLabel.snp.makeConstraints { + $0.top.equalTo(missionNameTitleLabel.snp.bottom).offset(10) + $0.leading.trailing.equalToSuperview().inset(25) + } + + durationRow.snp.makeConstraints { + $0.top.equalTo(missionNameValueLabel.snp.bottom).offset(40) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(25) + } + + Separator1.snp.makeConstraints { + $0.top.equalTo(durationRow.snp.bottom).offset(15) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(1) + } + + focusRow.snp.makeConstraints { + $0.top.equalTo(Separator1.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(25) + } + + Separator2.snp.makeConstraints { + $0.top.equalTo(focusRow.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(1) + } + + startTimeRow.snp.makeConstraints { + $0.top.equalTo(Separator2.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(25) + } + + Separator3.snp.makeConstraints { + $0.top.equalTo(startTimeRow.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(1) + } + + endTimeRow.snp.makeConstraints { + $0.top.equalTo(Separator3.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(25) + } + + Separator4.snp.makeConstraints { + $0.top.equalTo(endTimeRow.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(1) + } + + stateRow.snp.makeConstraints { + $0.top.equalTo(Separator4.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(30) + $0.height.equalTo(25) + } + + completedDateTitleLabel.snp.makeConstraints { + $0.top.equalTo(stateRow.snp.bottom).offset(30) + $0.leading.trailing.equalToSuperview().inset(25) + } + + completedDateValueLabel.snp.makeConstraints { + $0.top.equalTo(completedDateTitleLabel.snp.bottom).offset(10) + $0.leading.trailing.equalToSuperview().inset(25) + $0.bottom.equalToSuperview().offset(-25) + } + + homeButton.snp.makeConstraints { + $0.top.equalTo(infoCardView.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(18) + $0.height.equalTo(60) + $0.bottom.equalToSuperview().offset(-20) + } + } +} + +// ๋‚ ์งœ ๋ฐ ์‹œ๊ฐ„ ๋ณ€๊ฒฝ +extension MissionResultView { + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "a h:mm" + return formatter + }() + + private static let completedDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy. M. d. a h:mm:ss" + return formatter + }() + + private static func formattedDuration(from start: Date, to end: Date) -> String { + let durationInSeconds = max(0, Int(end.timeIntervalSince(start))) + return formattedHourMinute(durationInSeconds) + } + + private static func formattedHourMinute(_ totalSeconds: Int) -> String { + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 && minutes > 0 { + return "\(hours)์‹œ๊ฐ„ \(minutes)๋ถ„" + } + + if hours > 0 { + return "\(hours)์‹œ๊ฐ„" + } + + if minutes > 0 { + return "\(minutes)๋ถ„ \(seconds)์ดˆ" + } + + return "\(seconds)์ดˆ" + } +} diff --git a/RocketCall/View/MissionResultView/MissionResultViewController.swift b/RocketCall/View/MissionResultView/MissionResultViewController.swift new file mode 100644 index 0000000..e541294 --- /dev/null +++ b/RocketCall/View/MissionResultView/MissionResultViewController.swift @@ -0,0 +1,73 @@ +// +// MissionResultViewController.swift +// RocketCall +// +// Created by Yeseul Jang on 3/25/26. +// +import UIKit +import SnapKit + +//๊ฒฐ๊ณผ ํ™”๋ฉด(MissionResultViewController) ๋„์šธ ๋•Œ pushํ•ด์„œ ๋„์šฐ๊ณ  +//hidesBottomBarWhenPushed = true๋กœ ํƒญ๋ฐ” ์ˆจ๊ฒจ์ฃผ์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค +class MissionResultViewController: UIViewController { + private let coreDataManager: CoreDataManager + private let resultId: UUID? + + let missionResultView = MissionResultView() + + // UI ํ™•์ธ์„ ์œ„ํ•œ mock ๋ฐ์ดํ„ฐ + private let samplePayload = MissionResultPayload( + id: UUID(), + title: "CS ๊ณต๋ถ€", + start: Date(timeIntervalSince1970: 1773995228), + end: Date(timeIntervalSince1970: 1773999428), + studyTime: 45, + isCompleted: false + ) + + // ์ฝ”์–ด๋ฐ์ดํ„ฐ์™€ ์•„์ด๋””๋ฅผ ๋ฐ›์•„์„œ ๋ทฐ๋ฅผ ๊ทธ๋ฆผ + init(coreDataManager: CoreDataManager, resultId: UUID? = nil) { + self.coreDataManager = coreDataManager + self.resultId = resultId + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .background + + view.addSubview(missionResultView) + missionResultView.snp.makeConstraints { + $0.leading.trailing.top.equalTo(view.safeAreaLayoutGuide) + $0.bottom.equalToSuperview() + } + missionResultView.homeButton.addTarget(self, action: #selector(handleHomeButtonTap), for: .touchUpInside) + + // ์—ฌ๊ธฐ์„œ ๋ฐ์ดํ„ฐ ๋„ฃ์–ด์คŒ(์‚ฌ์šฉ์‹œ ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ๋กœ์ง ๋ถ€๋ถ„ ์‚ญ์ œํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค) + if let resultId { + do { + let payload = try coreDataManager.fetchMissionResult(of: resultId) + missionResultView.configure(with: payload) + } catch { + // TODO: ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ ๋กœ์ง ๊ตฌํ˜„ + missionResultView.configure(with: samplePayload) + } + } else { + missionResultView.configure(with: samplePayload) + } + } + + @objc + private func handleHomeButtonTap() { + if let navigationController { + navigationController.popViewController(animated: true) + return + } + + dismiss(animated: true) + } +} diff --git a/RocketCall/View/MissionResultView/StateRowView.swift b/RocketCall/View/MissionResultView/StateRowView.swift new file mode 100644 index 0000000..8baaeb3 --- /dev/null +++ b/RocketCall/View/MissionResultView/StateRowView.swift @@ -0,0 +1,42 @@ +// +// StateRowView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/24/26. +// +import UIKit +import SnapKit +import Then + +// MissionResultView ๊ตฌ์„ฑ์„ ์œ„ํ•œ ๋ทฐ +final class StateRowView: InfoPairView { + + private let stateLabel = StateLabel(text: "โœ“ ์„ฑ๊ณต", config: .success) + + init(title: String) { + super.init(title: title, dataLabel: stateLabel) + updateState(true) // ๊ธฐ๋ณธ๊ฐ’ + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateState(_ isSuccess: Bool) { + let text = isSuccess ? "โœ“ ์„ฑ๊ณต" : "โœ— ์‹คํŒจ" + let config: StateLabelConfiguration = isSuccess ? .success : .failure + + apply(text: text, config: config) + } + + private func apply(text: String, config: StateLabelConfiguration) { + stateLabel.text = text + stateLabel.font = config.font + stateLabel.textColor = config.color + stateLabel.backgroundColor = config.backgroundColor + + stateLabel.invalidateIntrinsicContentSize() + stateLabel.setNeedsLayout() + stateLabel.layoutIfNeeded() + } +} diff --git a/RocketCall/View/Stopwatch/RecordCell.swift b/RocketCall/View/Stopwatch/RecordCell.swift new file mode 100644 index 0000000..2338ee1 --- /dev/null +++ b/RocketCall/View/Stopwatch/RecordCell.swift @@ -0,0 +1,98 @@ +// +// RecordCell.swift +// RocketCall +// +// Created by Hanjuheon on 3/24/26. +// + +import UIKit +import SnapKit +import Then + + +class RecordCell: UICollectionViewCell { + + static let id = "RecordCell" + + private let countLabel = UILabel( + text: "# 0", + config: .sub14 + ) + + private let timerLabel = UILabel( + text: "00:00.00", + config: .missionLabel + ) + + private let locationLabel = UILabel( + text: "๊ณ„์‚ฐ ์ค‘...", + config: .sub12 + ).then { + $0.textColor = .thirdPoint + } + + + + //MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .cardBackground + contentView.backgroundColor = .mainPoint + contentView.layer.cornerRadius = 16 + configureUI() + } + + required init?(coder: NSCoder) { + fatalError() + } +} + +//MARK: - Update UI +extension RecordCell { + /// Update RecordCell Component Text + func updateRecord(count: Int, time: String , location: String, isLive: Bool) { + countLabel.text = "# \(count)" + timerLabel.text = time + locationLabel.text = location + if !isLive { + contentView.backgroundColor = .cardBackground + contentView.layer.borderColor = UIColor.mainPoint.cgColor + contentView.layer.borderWidth = 1 + } + else { + contentView.backgroundColor = .mainPoint + contentView.layer.borderWidth = 0 + } + } +} + +//MARK: - Configure UI +extension RecordCell { + func configureUI() { + contentView.addSubview(countLabel) + contentView.addSubview(timerLabel) + contentView.addSubview(locationLabel) + + + countLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(30) + $0.width.equalTo(50) + } + + timerLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(countLabel.snp.trailing).offset(20) + } + + locationLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().inset(30) + } + } +} + +@available(iOS 17.0, *) +#Preview { + RecordCell() +} diff --git a/RocketCall/View/Stopwatch/StopWatchHeaderView.swift b/RocketCall/View/Stopwatch/StopWatchHeaderView.swift new file mode 100644 index 0000000..eb93170 --- /dev/null +++ b/RocketCall/View/Stopwatch/StopWatchHeaderView.swift @@ -0,0 +1,184 @@ +// +// Untitled.swift +// RocketCall +// +// Created by Hanjuheon on 3/24/26. +// + + +import UIKit +import RxSwift +import RxCocoa +import SnapKit +import Then + +/// ์Šคํƒ‘์›Œ์น˜ ์ƒ๋‹จ ํƒ€์ด๋จธ ๊ด€๋ จ ๋ทฐ +class StopWatchHeaderView: UIView { + + //MARK: - Properties + private let mainCircleSize = 280 + private let pulseCircleSize = 290 + + //MARK: - Components + /// ํƒ€์ด๋จธ ์›ํ˜• ๋ทฐ + private let mainCircleView = UIView().then { + $0.layer.borderWidth = 2 + $0.layer.borderColor = UIColor.mainPoint.cgColor + $0.backgroundColor = .clear + } + /// ํƒ€์ด๋จธ ํŽ„์Šค ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋ทฐ + private let pulseCircleView = UIView().then { + $0.layer.borderWidth = 4 + $0.layer.borderColor = UIColor.darkGray.cgColor + $0.backgroundColor = .clear + } + /// ํƒ€์ด๋จธ ๋ผ๋ฒจ + let timerLabel = UILabel( + text: "00:00.00", + config: .title + ).then { + $0.font = UIFont.systemFont(ofSize: 50, weight: .light) + } + /// ํ˜„์žฌ ์œ„์น˜ ๋ผ๋ฒจ + let currentLocationLabel = UILabel().then { + $0.text = "ํ˜„์žฌ ์œ„์น˜ ํ™•์ธ์ค‘..." + $0.font = .systemFont(ofSize: 18) + $0.textColor = .mainPoint + } + /// ๋ชฉ์ ์ง€ ์•ˆ๋‚ด ๋ฒ„ํŠผ + let locationButton = RectangleButton( + title: "๋ฐœ์‚ฌ ์ง€์ ", + color: .mainPoint + ).then { + $0.backgroundColor = .cardBackground + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.darkGray.cgColor + } + /// ํƒ€์ด๋จธ ์‹œ์ž‘/์ผ์‹œ์ •์ง€ ๋ฒ„ํŠผ + let startButton = CircleButton( + size: 64, + image: UIImage(systemName: "play"), + tintColor: .mainLabel + ).then { + $0.setImage(UIImage(systemName: "pause"), for: .selected) + } + /// ๋ ˆ์ฝ”๋“œ ์ €์žฅ ๋ฒ„ํŠผ + let recordButton = CircleButton( + size: 64, + image: UIImage(systemName: "flag"), + tintColor: .mainLabel + ).then { + $0.backgroundColor = .cardBackground + $0.isEnabled = false + } + /// ์ดˆ๊ธฐํ™” ๋ฒ„ํŠผ + let resetButton = CircleButton( + size: 64, + image: UIImage(systemName: "arrow.counterclockwise"), + tintColor: .mainLabel + ).then { + $0.backgroundColor = .cardBackground + } + + //MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .background + configureUI() + } + + required init?(coder: NSCoder) { + fatalError() + } +} + +//MARK: - Configure UI +extension StopWatchHeaderView { + private func configureUI() { + let mainStack = UIStackView().then { + $0.axis = .vertical + $0.distribution = .fillProportionally + $0.alignment = .center + $0.spacing = 16 + } + + let timerStack = UIStackView().then { + $0.axis = .vertical + $0.distribution = .fill + $0.alignment = .center + $0.spacing = 10 + } + + let buttonStack = UIStackView().then { + $0.axis = .horizontal + $0.distribution = .fillEqually + $0.alignment = .center + $0.spacing = 20 + } + + let timerView = UIView().then { + $0.backgroundColor = .background + } + + mainCircleView.layer.cornerRadius = CGFloat(mainCircleSize / 2) + pulseCircleView.layer.cornerRadius = CGFloat(pulseCircleSize / 2) + + + timerStack.addArrangedSubview(timerLabel) + timerStack.addArrangedSubview(currentLocationLabel) + timerStack.addArrangedSubview(locationButton) + mainCircleView.addSubview(timerStack) + timerView.addSubview(pulseCircleView) + timerView.addSubview(mainCircleView) + + buttonStack.addArrangedSubview(startButton) + buttonStack.addArrangedSubview(recordButton) + buttonStack.addArrangedSubview(resetButton) + + mainStack.addArrangedSubview(timerView) + mainStack.addArrangedSubview(buttonStack) + + addSubview(mainStack) + + + mainStack.snp.makeConstraints { + $0.centerX.equalToSuperview() + } + + timerStack.snp.makeConstraints { + $0.center.equalToSuperview() + $0.leading.greaterThanOrEqualToSuperview().offset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + buttonStack.snp.makeConstraints { + $0.width.equalTo(232) + } + + timerView.snp.makeConstraints { + $0.width.height.equalTo(pulseCircleSize) + } + + pulseCircleView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(pulseCircleSize) + } + + mainCircleView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(mainCircleSize) + } + + locationButton.snp.makeConstraints { + $0.width.equalTo(180) + $0.height.equalTo(38) + } + } +} + + + +@available(iOS 17.0, *) +#Preview { + StopWatchHeaderView() +} diff --git a/RocketCall/View/Stopwatch/StopWatchRecordView.swift b/RocketCall/View/Stopwatch/StopWatchRecordView.swift new file mode 100644 index 0000000..15fa7fa --- /dev/null +++ b/RocketCall/View/Stopwatch/StopWatchRecordView.swift @@ -0,0 +1,142 @@ +// +// StopWatchRecordListView.swift +// RocketCall +// +// Created by Hanjuheon on 3/24/26. +// + +import UIKit +import RxSwift +import SnapKit +import Then + + + +/// ์Šคํƒ‘์›Œ์น˜ ํ•˜๋‹จ ๋ ˆ์ฝ”๋“œ ๋ทฐ +class StopWatchRecordView: UIView { + + //MARK: - Enum + enum RecordSection { + case main + } + + + //MARK: - Components + private let titleLabel = UILabel( + text: "Checkpoint Records", + config: .sub14 + ) + + private lazy var recordCollectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewCompositionalLayout(section: recordSection()) + ).then { + $0.backgroundColor = .background + } + + private var dataSource: UICollectionViewDiffableDataSource! + + + //MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .background + configureUI() + configureDataSource() + } + + required init?(coder: NSCoder) { + fatalError() + } +} + +////MARK: - Binding +//extension StopWatchRecordView { +// /// Binding ๋ฉ”์†Œ๋“œ +// private func binding() { +// //TODO: ํ•œ์ฃผํ—Œ - ๋ชฉ์—…๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ +// applySnapshot(with: mockRecords) +// } +//} + +//MARK: - CollectionView Configure +extension StopWatchRecordView { + /// SnapShot ์ƒ์„ฑ ๋งค์†Œ๋“œ + func applySnapshot(with recordDatas: [RecordData]){ + var snapShot = NSDiffableDataSourceSnapshot() + snapShot.appendSections([.main]) + snapShot.appendItems(recordDatas) + dataSource.apply(snapShot,animatingDifferences: false) + } + + + /// DataSource ์„ค์ • ๋ฉ”์†Œ๋“œ + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource (collectionView: recordCollectionView) { [weak self] + collectionview, indexPath, item in + guard let self else { return UICollectionViewCell() } + + let section = self.dataSource.snapshot().sectionIdentifiers + let sectionType = section[indexPath.section] + + switch sectionType { + case .main: + guard let cell = collectionview.dequeueReusableCell( + withReuseIdentifier: RecordCell.id, + for: indexPath) as? RecordCell + else { return UICollectionViewCell() } + + cell.updateRecord(count: item.count, + time: item.time, + location: item.location, + isLive: item.isLive) + return cell + } + } + } + + /// ๋ ˆ์ฝ”๋“œ ์„น์…˜ ์ƒ์„ฑ ๋ฉ”์†Œ๋“œ + private func recordSection()-> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: .init(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(50))) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: .init(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(50)), + subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 12 + + return section + } +} + + +//MARK: - Configure UI +extension StopWatchRecordView { + // UI ์ดˆ๊ธฐ ์„ค์ • ๋ฉ”์†Œ๋“œ + func configureUI() { + recordCollectionView.register(RecordCell.self, forCellWithReuseIdentifier: RecordCell.id) + + addSubview(titleLabel) + addSubview(recordCollectionView) + + + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview() + } + + recordCollectionView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(10) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} + + +@available(iOS 17.0, *) +#Preview { + StopWatchRecordView() +} diff --git a/RocketCall/View/Stopwatch/StopWatchViewController.swift b/RocketCall/View/Stopwatch/StopWatchViewController.swift new file mode 100644 index 0000000..8d567ed --- /dev/null +++ b/RocketCall/View/Stopwatch/StopWatchViewController.swift @@ -0,0 +1,195 @@ +// +// StopWatchViewController.swift +// RocketCall +// +// Created by Hanjuheon on 3/24/26. +// + +import UIKit +import RxSwift +import RxCocoa +import SnapKit +import Then + +/// StopWatch ViewController +class StopWatchViewController: UIViewController { + + //MARK: - ViewModel + private let vm = StopWatchViewModel() + + //MARK: - Properties + private var disposeBag = DisposeBag() + + //MARK: - Components + private let titleView = TitleView( + title: "์ž์œ  ํ•ญํ–‰ ๋ชจ๋“œ", + subTitle: "์Šคํ†ฑ์›Œ์น˜ ๊ธฐ๋Šฅ์œผ๋กœ ์›ํ•˜๋Š” ์‹œ๊ฐ„๊นŒ์ง€ ์นด์šดํŠธํ•˜์„ธ์š”.", + hasButton: false + ) + + /// StopWatch ์ƒ๋‹จ ํƒ€์ž„ ๋ทฐ + private let stopWatchHeaderView = StopWatchHeaderView() + + /// StopWatch ํ•˜๋‹จ ๋ ˆ์ฝ”๋“œ ๋ทฐ + private let stopWatchRecordView = StopWatchRecordView() + + //MARK: - Init + override func viewDidLoad() { + super.viewDidLoad() + //description = "์Šคํ†ฑ์›Œ์น˜ ๊ธฐ๋Šฅ์œผ๋กœ ์›ํ•˜๋Š” ์‹œ๊ฐ„๊นŒ์ง€ ์นด์šดํŠธํ•˜์„ธ์š”." + view.backgroundColor = .background + navigationController?.navigationBar.isHidden = true + configureUI() + bind() + } +} + +//MARK: - Binding +extension StopWatchViewController { + func bind() { + /// ๋‹ค์Œ์œ„์น˜ ๋ฒ„ํŠผ ํƒญ ์‹œ, ์ž์œ ํ•ญํ–‰ ๋ชฉ์ ์ง€ ์ •๋ณด ํ‘œ๊ธฐ ๋ทฐ ์ถœ๋ ฅ ์•ก์…˜ + stopWatchHeaderView.locationButton.rx.tap + .subscribe(onNext: { [weak self] in + guard let self else { return } + + let vc = UIViewController() + vc.modalPresentationStyle = .overFullScreen + vc.view.backgroundColor = .clear + + let dimmedButton = UIButton(type: .custom) + dimmedButton.backgroundColor = UIColor.black.withAlphaComponent(0.4) + + let containerView = TimeContainerView(items: FreeStage.infoLocation()) + + vc.view.addSubview(dimmedButton) + vc.view.addSubview(containerView) + + dimmedButton.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + containerView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(24) + } + + dimmedButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.dismiss(animated: true) + }, + for: .touchUpInside + ) + + self.present(vc, animated: true) + }) + .disposed(by: disposeBag) + + + // View Action Setting + // ์‹œ์ž‘/์ผ์‹œ์ •์ง€ ๋ฒ„ํŠผ ํƒญ ์ด๋ฒคํŠธ ์„ค์ • + let startPause = stopWatchHeaderView.startButton.rx.tap + .do(onNext: { [weak self] _ in + guard let self = self else { return } + // UI ์ƒํƒœ๋ฅผ ์ง์ ‘ ๋ณ€๊ฒฝ + self.stopWatchHeaderView.startButton.isSelected.toggle() + if self.stopWatchHeaderView.startButton.isSelected { + self.stopWatchHeaderView.recordButton.isEnabled = true + } else { + self.stopWatchHeaderView.recordButton.isEnabled = false + } + }) + .map { [weak self] _ -> StopWatchViewModel.State in + guard let self = self else { return .pause } + return self.stopWatchHeaderView.startButton.isSelected ? .run : .pause + } + .share() + + // ๋ฆฌ์…‹ ๋ฒ„ํŠผ ํƒญ ์ด๋ฒคํŠธ ์„ค์ • + let reset = stopWatchHeaderView.resetButton.rx.tap + .do(onNext: { + self.stopWatchHeaderView.startButton.isSelected = false + self.stopWatchHeaderView.recordButton.isEnabled = false + }) + .map { _ -> StopWatchViewModel.State in + return .reset + } + + // ๋ ˆ์ฝ”๋“œ ๋ฒ„ํŠผ ํƒญ ์ด๋ฒคํŠธ ์„ค์ • + let record = stopWatchHeaderView.recordButton.rx.tap + .map { return } + + // ViewModel ์•ก์…˜ ์ „๋‹ฌ Input ์„ธํŒ… + let input = StopWatchViewModel.Input( + stopwatchAction: Observable.merge(startPause,reset), + record: record + ) + + // View Action -> ViewModel + let output = vm.transform(input) + + + // ViewModel -> View Binding + // ์Šคํƒ‘์›Œ์น˜ ๋ฉ”์ธ ์‹œ๊ฐ„ ํ…์ŠคํŠธ ๋ฐ”์ธ๋”ฉ + output.mainTimer + .bind(to: stopWatchHeaderView.timerLabel.rx.text) + .disposed(by: disposeBag) + + // ๋ ˆ์ฝ”๋“œ ๋ฐ”์ธ๋”ฉ + output.record + .bind(onNext: { [weak self] datas in + guard let self else { return } + self.stopWatchRecordView.applySnapshot(with: datas) + }) + .disposed(by: disposeBag) + + // ํ˜„์žฌ ์œ„์น˜ ํ‘œ๊ธฐ + output.location + .bind(to: stopWatchHeaderView.currentLocationLabel.rx.text) + .disposed(by: disposeBag) + + // ๋‹ค์Œ ์œ„์น˜ ํ‘œ๊ธฐ + output.targetLocation + .bind(to: stopWatchHeaderView.locationButton.rx.title()) + .disposed(by: disposeBag) + } +} + +//MARK: - Configure UI +extension StopWatchViewController { + private func configureUI() { + let mainStack = UIStackView().then { + $0.axis = .vertical + $0.spacing = 0 + $0.distribution = .fill + $0.alignment = .fill + } + + mainStack.addArrangedSubview(stopWatchHeaderView) + mainStack.addArrangedSubview(stopWatchRecordView) + + view.addSubview(titleView) + view.addSubview(mainStack) + + titleView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + } + + mainStack.snp.makeConstraints { ms in + ms.top.equalTo(titleView.snp.bottom) + ms.leading.equalToSuperview().offset(20) + ms.trailing.bottom.equalToSuperview().inset(20) + } + + stopWatchHeaderView.snp.makeConstraints { + $0.height.equalTo(380) + } + } + +} + +@available(iOS 17.0, *) +#Preview { + StopWatchViewController() +} diff --git a/RocketCall/View/TimerAnimationView/TimerAnimationBottomView.swift b/RocketCall/View/TimerAnimationView/TimerAnimationBottomView.swift new file mode 100644 index 0000000..9b6469b --- /dev/null +++ b/RocketCall/View/TimerAnimationView/TimerAnimationBottomView.swift @@ -0,0 +1,110 @@ +// +// TimerAnimationBottomView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/25/26. +// + +import UIKit +import SnapKit +import Then + +final class TimerAnimationBottomView: UIView { + + let missionTitleLabel = UILabel(config:.sub16).then { + $0.text = "ํƒ€์ด๋จธ ์ด๋ฆ„" + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let timerLabel = UILabel().then { + $0.text = "24:57" + $0.numberOfLines = 1 + $0.textColor = .white + $0.font = .systemFont(ofSize: 50, weight: .bold) + } + + let cycleLabel = UILabel().then { + $0.text = "1 / 4 ์‚ฌ์ดํด" + $0.font = .systemFont(ofSize: 16, weight: .medium) + $0.textColor = UIColor.subLabel.withAlphaComponent(0.9) + } + + private let buttonStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 18 + $0.alignment = .center + } + + let backButton = CircleButton( + size: 40, + backgroundColor: UIColor.white.withAlphaComponent(0.3), + image: UIImage(systemName: "chevron.left"), + tintColor: .white + ) + + let stopButton = CircleButton( + size: 60, + backgroundColor: .mainPoint, + image: UIImage(systemName: "pause.fill"), + tintColor: .white + ) + + let missionStopButton = CircleButton( + size: 40, + backgroundColor: UIColor.white.withAlphaComponent(0.3), + image: UIImage(systemName: "stop"), + tintColor: .white + ) + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(missionTitle: String, timerText: String, cycleText: String) { + missionTitleLabel.text = missionTitle + timerLabel.text = timerText + cycleLabel.text = cycleText + } + + private func configureUI() { + backgroundColor = .black + + buttonStackView.addArrangedSubview(backButton) + buttonStackView.addArrangedSubview(stopButton) + buttonStackView.addArrangedSubview(missionStopButton) + + addSubview(missionTitleLabel) + addSubview(timerLabel) + addSubview(cycleLabel) + addSubview(buttonStackView) + } + + private func setupLayout() { + + missionTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.centerX.equalToSuperview() + } + + timerLabel.snp.makeConstraints { + $0.top.equalTo(missionTitleLabel.snp.bottom).offset(5) + $0.centerX.equalToSuperview() + } + + cycleLabel.snp.makeConstraints { + $0.top.equalTo(timerLabel.snp.bottom).offset(5) + $0.centerX.equalToSuperview() + } + + buttonStackView.snp.makeConstraints { + $0.top.equalTo(cycleLabel.snp.bottom).offset(10) + $0.centerX.equalToSuperview() + } + } +} diff --git a/RocketCall/View/TimerAnimationView/TimerAnimationView.swift b/RocketCall/View/TimerAnimationView/TimerAnimationView.swift new file mode 100644 index 0000000..b27b8f4 --- /dev/null +++ b/RocketCall/View/TimerAnimationView/TimerAnimationView.swift @@ -0,0 +1,130 @@ +// +// TimerAnimationView.swift +// RocketCall +// +// Created by Yeseul Jang on 3/25/26. +// +import UIKit +import SnapKit +import Then + +final class TimerAnimationView: UIView { + private enum Layout { + static let minimumPlanetSize: CGFloat = 5 + static let maximumPlanetSize: CGFloat = 400 + } + + static let availablePlanetImageNames = ["star1", "star2", "star3", "star4", "star5", "star6"] + + private let backgroundImageView = UIImageView().then { + $0.image = UIImage(named: "backGroundImage") + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + } + + private let centerStarImageView = UIImageView().then { + $0.image = UIImage(named: "star2") + $0.contentMode = .scaleAspectFit + $0.clipsToBounds = true + } + + private let shipImageView = UIImageView().then { + $0.image = UIImage(named: "spaceShip") + $0.clipsToBounds = true + } + + private var centerStarWidthConstraint: Constraint? + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setPlanetImage(named imageName: String) { + centerStarImageView.image = UIImage(named: imageName) + } + + // ์‹œ๊ฐ„์„ ๊ธฐ์ค€์œผ๋กœ ํ–‰์„ฑํฌ๊ธฐ ์—…๋ฐ์ดํŠธ ๋กœ์ง + func updatePlanetProgress(remainingTime: TimeInterval, totalDuration: TimeInterval) { + // 0์ผ ๊ฒฝ์šฐ๊ฐ€ ์—†๋„๋ก ํ•จ + guard totalDuration > 0 else { + // ์ตœ์†Œํฌ๊ธฐ๋ฅผ ์ •ํ•จ + centerStarWidthConstraint?.update(offset: Layout.minimumPlanetSize) + return + } + // ๋‚จ์€ ์‹œ๊ฐ„ ์ „๋‹ฌ๊ฐ’ ๋ฒ”์œ„๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ - 0๋ณด๋‹ค ํฌ๊ฒŒ, ์ „์ฒด๋ณด๋‹จ ์ž‘๊ฒŒ + let remainingTime = min(max(remainingTime, 0), totalDuration) + // ์ง„ํ–‰๋ฅ  ๊ณ„์‚ฐ (์ง„ํ–‰๋ฅ ์ด 1์ด๋˜๋ฉด ์ตœ๋Œ€ ํฌ๊ธฐ) + let progress = 1 - (remainingTime / totalDuration) + // ํ˜„์žฌ์‚ฌ์ด์ฆˆ = ์ตœ์†Œ์‚ฌ์ด์ฆˆ + (์ปค์ ธ์•ผ๋  ํฌ๊ธฐ) * ์ง„ํ–‰๋ฅ  + let currentSize = Layout.minimumPlanetSize + (Layout.maximumPlanetSize - Layout.minimumPlanetSize) * progress + // ์‹ค์ œ๋กœ ์ปค์ง€๊ฒŒ ํ•˜๊ธฐ - ์˜คํ† ๋ ˆ์ด์•„์›ƒ์€ ์ œ์•ฝ์ด ๋ฐ”๋€Œ๋ฉด ๋งž์ถฐ์„œ ๋ทฐ๋ฅผ ๋‹ค์‹œ ๊ทธ๋ฆผ + centerStarWidthConstraint?.update(offset: currentSize) + } + + private func configureUI() { + backgroundColor = .background + + addSubview(backgroundImageView) + addSubview(centerStarImageView) + addSubview(shipImageView) + } + + private func setupLayout() { + backgroundImageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + centerStarImageView.snp.makeConstraints { + $0.center.equalToSuperview() + // ์ผ๋‹จ ์ดˆ๊ธฐ๊ฐ’์œผ๋กœ ์ดˆ๊ธฐํ–‰์„ฑํฌ๊ธฐ๋ฅผ ์คŒ + centerStarWidthConstraint = $0.width.equalTo(Layout.minimumPlanetSize).constraint + $0.height.equalTo(centerStarImageView.snp.width) + } + + shipImageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +// ๋ฐฑ๊ทธ๋ผ์šด๋“œ๋กœ ๊ฐ”๋‹ค๊ฐ€ ๋‹ค์‹œ ๋Œ์•„์˜ฌ๋•Œ ๋กœ์ง๊ณผ ์šฐ์ฃผ์„  ์›€์ง์ž„ +extension TimerAnimationView { + override func didMoveToWindow() { + super.didMoveToWindow() + + if window != nil { + restartAnimations() + } else { + stopAnimations() + } + } + + private func restartAnimations() { + stopAnimations() + startFlyingAnimation() + } + + private func stopAnimations() { + centerStarImageView.layer.removeAllAnimations() + shipImageView.layer.removeAllAnimations() + shipImageView.transform = .identity + } + + // ์šฐ์ฃผ์„  ์œ„์•„๋ž˜ ํ”๋“ค๋ฆฌ๋Š” ์• ๋‹ˆ๋ฉ”์ด์…˜ + private func startFlyingAnimation() { + UIView.animate( + withDuration: 0.5, + delay: 0, + options: [.autoreverse, .repeat, .allowUserInteraction], + animations: { + self.shipImageView.transform = CGAffineTransform(translationX: 0, y: 3) + } + ) + } +} diff --git a/RocketCall/View/TimerAnimationView/TimerAnimationViewController.swift b/RocketCall/View/TimerAnimationView/TimerAnimationViewController.swift new file mode 100644 index 0000000..4fedac4 --- /dev/null +++ b/RocketCall/View/TimerAnimationView/TimerAnimationViewController.swift @@ -0,0 +1,145 @@ +// +// TimerAnimationViewController.swift +// RocketCall +// +// Created by Yeseul Jang on 3/25/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +final class TimerAnimationViewController: UIViewController { + private let timerAnimationView = TimerAnimationView() + private let timerView = TimerAnimationBottomView() + private let viewModel = TimerAnimationViewModel() + private let disposeBag = DisposeBag() + private let activatedMissionState: Observable // ์ด๊ฑธ ๊ธฐ์ค€์œผ๋กœ ํ™”๋ฉด ํ‘œ์‹œ + private let planetImageName: String + // MissionList๋กœ์ง ๊ฐ™์ด ์“ฐ๋„๋ก ๋ฒ„ํŠผ ๋ˆŒ๋ฆด์‹œ ๋ฐ–์œผ๋กœ ๋„˜๊น€ + private let onPauseResumeRequested: (() -> Void)? + private let onMissionStopRequested: (() -> Void)? + + init( + activatedMissionState: Observable, + planetImageName: String, + onPauseResumeRequested: (() -> Void)? = nil, + onMissionStopRequested: (() -> Void)? = nil + ) { + self.activatedMissionState = activatedMissionState + self.planetImageName = planetImageName + self.onPauseResumeRequested = onPauseResumeRequested + self.onMissionStopRequested = onMissionStopRequested + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + setupLayout() + bind() + } + + private func configureUI() { + view.backgroundColor = .background + view.addSubview(timerAnimationView) + view.addSubview(timerView) + timerAnimationView.setPlanetImage(named: planetImageName) + } + + // ์ƒ๋‹จ์€ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ•˜๋‹จ์€ ํƒ€์ด๋จธ + private func setupLayout() { + timerAnimationView.snp.makeConstraints { + $0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + $0.height.equalTo(view.safeAreaLayoutGuide.snp.height).multipliedBy(2.0 / 3.0) + } + + timerView.snp.makeConstraints { + $0.top.equalTo(timerAnimationView.snp.bottom) + $0.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) + } + } + + private func bind() { + let input = TimerAnimationViewModel.Input( + activatedMissionState: activatedMissionState, + stopButtonTapped: timerView.stopButton.rx.tap.asObservable(), + missionStopButtonTapped: timerView.missionStopButton.rx.tap.asObservable(), + backButtonTapped: timerView.backButton.rx.tap.asObservable() + ) + + let output = viewModel.transform(input) + + // ํ•˜๋‹จ ๋ทฐ ๊ฐฑ์‹  + Observable + .combineLatest(output.missionTitleText, output.timerText, output.cycleText) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] values in + let (missionTitle, timerText, cycleText) = values + self?.timerView.configure( + missionTitle: missionTitle, + timerText: timerText, + cycleText: cycleText + ) + }) + .disposed(by: disposeBag) + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ๊ฐฑ์‹  + Observable + .combineLatest(output.remainingTime, output.totalDuration) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] values in + let (remainingTime, totalDuration) = values + self?.timerAnimationView.updatePlanetProgress( + remainingTime: remainingTime, + totalDuration: totalDuration + ) + }) + .disposed(by: disposeBag) + + output.stopButtonImage + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] image in + self?.timerView.stopButton.setImage(image, for: .normal) + }) + .disposed(by: disposeBag) + + //๋ˆŒ๋ฆฐ ์ด๋ฒคํŠธ๋งŒ ์ „๋‹ฌ(ActivatedMissionCell ํ•ด๋‹น๋กœ์ง์ด๋ž‘ ๋ฐ–์—์„œ ๊ฐ™์ด ์ฒ˜๋ฆฌํ•จ) + output.pauseResumeRequested + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] in + self?.onPauseResumeRequested?() + }) + .disposed(by: disposeBag) + + output.routeStopMission + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] in + self?.onMissionStopRequested?() + }) + .disposed(by: disposeBag) + + // ๋’ค๋กœ๊ฐ€๊ธฐ ๋ฒ„ํŠผ ๋ˆŒ๋ ธ์„ ์‹œ missionViewController ๋„์›€ + output.routeBack + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] in + self?.popToMissionViewController() + }) + .disposed(by: disposeBag) + } + + private func popToMissionViewController() { + guard let navigationController else { return } + + if let missionViewController = navigationController.viewControllers.last(where: { $0 is MissionViewController }) { + navigationController.popToViewController(missionViewController, animated: true) + return + } + navigationController.popViewController(animated: true) + } +} diff --git a/RocketCall/View/ViewController.swift b/RocketCall/View/ViewController.swift index f7cbe61..21e79be 100644 --- a/RocketCall/View/ViewController.swift +++ b/RocketCall/View/ViewController.swift @@ -6,14 +6,31 @@ // import UIKit +import SnapKit +// StateLabel ์˜ˆ์‹œ ๋ฐ NavigationController ํƒ€์ดํ‹€ ์„ค์ • ์˜ˆ์‹œ class ViewController: UIViewController { + private let titleView = TitleView(title: "Test", subTitle: "Test", hasButton: true) + private let coreDataManager: CoreDataManager + + init(coreDataManager: CoreDataManager) { + self.coreDataManager = coreDataManager + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view. + view.backgroundColor = .background + navigationController?.isNavigationBarHidden = true + + view.addSubview(titleView) + titleView.snp.makeConstraints { + $0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + } } - - } - diff --git a/RocketCall/ViewModel/AlarmListViewModel.swift b/RocketCall/ViewModel/AlarmListViewModel.swift new file mode 100644 index 0000000..d2a72a2 --- /dev/null +++ b/RocketCall/ViewModel/AlarmListViewModel.swift @@ -0,0 +1,158 @@ +// +// AlarmListViewModel.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/25/26. +// + +import RxSwift +import RxCocoa +import Foundation + +final class AlarmListViewModel: ViewModelProtocol { + + private let coreDataManager: CoreDataManager + private let disposeBag = DisposeBag() + + init(coreDataManager: CoreDataManager) { + self.coreDataManager = coreDataManager + } + + + // MARK: - Input + struct Input { + let viewWillAppear: Observable + let refreshTrigger: Observable + let deleteAlarm: Observable + let addTapped: Observable + let itemSelected: Observable + let toggleAlarm: Observable<(UUID, Bool)> + } + + + // MARK: - Output + struct Output { + let alarms: Driver<[Alarm]> + let showSettingModal: Driver + } + + + // MARK: - transform + func transform(_ input: Input) -> Output { + // ์•Œ๋žŒ ๋ฐ์ดํ„ฐ ๋‹ด์„ ์ฃผ๋จธ๋‹ˆ + let alarmsRelay = BehaviorRelay<[Alarm]>(value: []) + + // ์ƒˆ๋กœ๊ณ ์นจ + let fetchTrigger = PublishRelay() + fetchTrigger + .bind(with: self) { owner, _ in + owner.fetchAlarms(into: alarmsRelay) + } + .disposed(by: disposeBag) + + + // ๋ฐ์ดํ„ฐ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ (์ฒ˜์Œ ์ผœ์งˆ ๋•Œ + ์ƒˆ๋กœ๊ณ ์นจํ•  ๋•Œ) + Observable.merge(input.viewWillAppear, input.refreshTrigger) + .bind(to: fetchTrigger) + .disposed(by: disposeBag) + + + // ์Šค์™€์ดํ”„ ์‚ญ์ œ ๋กœ์ง + input.deleteAlarm + .bind(with: self) { owner, alarm in + do { + try owner.coreDataManager.deleteAlarmEntity(of: alarm.id) // ์ฝ”์–ด๋ฐ์ดํ„ฐ delete + + NotificationManager.shared.cancelAlarm(String(alarm.id.uuidString.prefix(36))) // ์•Œ๋žŒ ์˜ˆ์•ฝ๋„ ์‚ญ์ œ + fetchTrigger.accept(()) // ์‚ญ์ œ ํ›„ ๋ฆฌ์ŠคํŠธ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + } catch { + print("์‚ญ์ œ ์‹คํŒจ: \(error)") + } + } + .disposed(by: disposeBag) + + + // ๋ชจ๋‹ฌ ๋„์šฐ๊ธฐ + let showAdd = input.addTapped.map { _ -> AlarmPayload? in nil } // ์•Œ๋žŒ ์ƒˆ๋กœ ์ถ”๊ฐ€ + let showEdit = input.itemSelected.map { alarm -> AlarmPayload? in // ๊ธฐ์กด ์•Œ๋žŒ ์ˆ˜์ • + return AlarmPayload( + id: alarm.id, + title: alarm.title, + hour: alarm.hour, + minute: alarm.minute, + isRepeat: !alarm.repeatDays.isEmpty, + repeatDays: alarm.repeatDays.map { $0.rawValue }, + isOn: alarm.isOn + ) + } + let showModal = Observable.merge(showAdd, showEdit).asDriver(onErrorDriveWith: .empty()) + + + // ํ† ๊ธ€ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + input.toggleAlarm + .bind(with:self) { owner, data in + let (id, isOn) = data + do { + // ์ฝ”์–ด๋ฐ์ดํ„ฐ ์›๋ณธ ๊ฐ€์ ธ์˜ค๊ธฐ + let originalPayload = try owner.coreDataManager.fetchAlarm(of: id) + + // isOn๋งŒ ๊ฐˆ์•„๋ผ์šด ์ƒˆ๋กœ์šด Payload ์ƒ์„ฑํ•˜๊ธฐ + var updatedPayload = originalPayload + updatedPayload.isOn = isOn + + // ์ฝ”์–ด๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ + try owner.coreDataManager.updateAlarmEntity(of: updatedPayload) + + // ์•Œ๋žŒ ์˜ˆ์•ฝ์šฉ ๋ฐ์ดํ„ฐ + let toggleAlarm = Alarm( + id: updatedPayload.id, + hour: updatedPayload.hour, + minute: updatedPayload.minute, + title: updatedPayload.title, + repeatDays: updatedPayload.repeatDays.compactMap { WeekDay(rawValue: $0) }, + isOn: updatedPayload.isOn) + + // ์ผœ๋ฉด ์˜ˆ์•ฝํ•˜๊ธฐ + if isOn { + NotificationManager.shared.addAlarm(toggleAlarm) + } else { + // ๋„๋ฉด ์ทจ์†Œํ•˜๊ธฐ + NotificationManager.shared.cancelAlarm(String(toggleAlarm.id.uuidString.prefix(36))) + } + + // ๋ฆฌ์ŠคํŠธ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + fetchTrigger.accept(()) + + } catch { + print("ํ† ๊ธ€ DB ์ €์žฅ ์‹คํŒจ: \(error)") + } + } + .disposed(by: disposeBag) + + return Output( + alarms: alarmsRelay.asDriver(), // ์ตœ์‹  ์•Œ๋žŒ ๋ฆฌ์ŠคํŠธ + showSettingModal: showModal // ๋ชจ๋‹ฌ ๋„์šฐ๊ธฐ + ) + } + + + // ์ฝ”์–ด๋ฐ์ดํ„ฐ์—์„œ ๋ถˆ๋Ÿฌ์™€์„œ ๋ณ€ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜ (Payload -> Alarm) + private func fetchAlarms(into relay: BehaviorRelay<[Alarm]>) { + do { + let payloads = try coreDataManager.fetchAllAlarm() + let alarms = payloads.map { payload in + Alarm(id: payload.id, + hour: payload.hour, + minute: payload.minute, + title: payload.title, + repeatDays: payload.repeatDays.compactMap { WeekDay(rawValue: $0) }, + isOn: payload.isOn + ) + } + relay.accept(alarms) + } catch { + print("๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์‹คํŒจ: \(error)") + } + } +} + diff --git a/RocketCall/ViewModel/AlarmSettingViewModel.swift b/RocketCall/ViewModel/AlarmSettingViewModel.swift new file mode 100644 index 0000000..3bd5cee --- /dev/null +++ b/RocketCall/ViewModel/AlarmSettingViewModel.swift @@ -0,0 +1,150 @@ +// +// AlarmSettingViewModel.swift +// RocketCall +// +// Created by ๊น€์ฃผํฌ on 3/25/26. +// + +import RxSwift +import RxCocoa +import Foundation + +final class AlarmSettingViewModel: ViewModelProtocol { + + private let coreDataManager = CoreDataManager() + private let disposeBag = DisposeBag() + private let existingAlarm: AlarmPayload? + + init(existingAlarm: AlarmPayload?) { + self.existingAlarm = existingAlarm + } + + + // MARK: - Input + struct Input { + let timeSelected: Observable + let titleText: Observable + let dayToggled: Observable + let cancelButtonTapped: Observable + let saveButtonTapped: Observable + } + + + // MARK: - Output + struct Output { + let initialSetup: Driver // ํ™”๋ฉด ์ผค ๋•Œ ๊ธฐ์กด ๋ฐ์ดํ„ฐ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + let dismissView: Driver // ํ™”๋ฉด ๋‹ซ๊ธฐ + let saveCompleted: Driver + } + + + // MARK: - transform + func transform(_ input: Input) -> Output { + + // MARK: ์ŠคํŠธ๋ฆผ + // ์‹œ๊ฐ„ ์ŠคํŠธ๋ฆผ (Date -> ์‹œ, ๋ถ„) + + // ๊ธฐ์กด ์•Œ๋žŒ ์žˆ์œผ๋ฉด ๊ทธ ์‹œ๊ฐ„์œผ๋กœ, ์—†์œผ๋ฉด ํ˜„ ์‹œ๊ฐ„ + let initialHour = existingAlarm?.hour ?? Calendar.current.component(.hour, from: Date()) + let initialMinute = existingAlarm?.minute ?? Calendar.current.component(.minute, from: Date()) + + let timeStream = input.timeSelected + .skip(1) // ํ˜„์žฌ์‹œ๊ฐ„ ๋ฌด์‹œ + .map { date -> (Int, Int) in + let calendar = Calendar.current + return (calendar.component(.hour, from: date), calendar.component(.minute, from: date)) + } + .startWith((initialHour, initialMinute)) + + // ํƒ€์ดํ‹€ ์ŠคํŠธ๋ฆผ + let initialTitle = existingAlarm?.title ?? "" + + let titleStream = input.titleText + .skip(1) + .map { $0 ?? "" } + .startWith(initialTitle) + + // ์š”์ผ ์ŠคํŠธ๋ฆผ + let initialDays = Set(existingAlarm?.repeatDays ?? []) + let daysStream = input.dayToggled + .scan(initialDays) { currentDays, toggledDay in + var newDays = currentDays + if newDays.contains(toggledDay) { + newDays.remove(toggledDay) + } else { + newDays.insert(toggledDay) + } + return newDays + } + .startWith(initialDays) + + + // MARK: ์ €์žฅ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ -> ์œ„์˜ 3๊ฐœ ์ŠคํŠธ๋ฆผ์˜ ์ตœ์‹ ๊ฐ’ ๋ชจ์•„์„œ ์ „์†กํ•˜๊ธฐ + let saveSuccess = PublishRelay() + + input.saveButtonTapped + .withLatestFrom(Observable.combineLatest(timeStream, titleStream, daysStream)) // ๊ฐ€์žฅ ์ตœ๊ทผ ๊ฐ’๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ + .bind(with: self) { owner, data in + let (time, title, days) = data // ๋ฌถ์—ฌ์˜จ ๋ฐ์ดํ„ฐ ํ’€๊ธฐ + let (hour, minute) = time + let isRepeat = !days.isEmpty + let finalTitle = title.isEmpty ? "์•Œ๋žŒ" : title + + // payload๋กœ ํฌ์žฅ + let payload = AlarmPayload( + id: owner.existingAlarm?.id ?? UUID(), + title: finalTitle, + hour: hour, + minute: minute, + isRepeat: isRepeat, + repeatDays: Array(days).sorted(), + isOn: true // ์ €์žฅํ•˜๋ฉด ๋ฌด์กฐ๊ฑด on + ) + + // ์•Œ๋žŒ ์—์•ฝ์— ์“ธ ๋ชจ๋ธ + let alarmForNoti = Alarm( + id: payload.id, + hour: payload.hour, + minute: payload.minute, + title: payload.title, + repeatDays: payload.repeatDays.compactMap { WeekDay(rawValue: $0) }, + isOn: payload.isOn + ) + + // ์ฝ”์–ด๋ฐ์ดํ„ฐ ์ €์žฅ ๋ฐ ์•Œ๋žŒ ์˜ˆ์•ฝ + do { + if let oldPayload = owner.existingAlarm { + try owner.coreDataManager.updateAlarmEntity(of: payload) + + // ๊ธฐ์กด ์•Œ๋žŒ ์˜ˆ์•ฝ ์ทจ์†Œ + let oldAlarm = Alarm(id: oldPayload.id, hour: oldPayload.hour, minute: oldPayload.minute, title: oldPayload.title, repeatDays: oldPayload.repeatDays.compactMap { WeekDay(rawValue: $0) }, isOn: oldPayload.isOn + ) + NotificationManager.shared.cancelAlarm(String(oldAlarm.id.uuidString.prefix(36))) + } else { + try owner.coreDataManager.createAlarmEntity(alarm: payload) + } + + // ์•Œ๋žŒ ์˜ˆ์•ฝํ•˜๊ธฐ + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + NotificationManager.shared.addAlarm(alarmForNoti) + saveSuccess.accept(()) // ์„ฑ๊ณต ํŠธ๋ฆฌ๊ฑฐ ๋ฐœ์‚ฌ + } + + } catch { + print("์ €์žฅ ์‹คํŒจ: \(error)") + } + } + .disposed(by: disposeBag) + + + // ํ™”๋ฉด ๋‹ซ๊ธฐ ํŠธ๋ฆฌ๊ธฐ + let dismissView = Observable.merge(saveSuccess.asObservable(), input.cancelButtonTapped) + .asDriver(onErrorDriveWith: .empty()) + + return Output( + initialSetup: Driver.just(existingAlarm), + dismissView: dismissView, + saveCompleted: saveSuccess.asDriver(onErrorDriveWith: .empty()) + ) + } +} diff --git a/RocketCall/ViewModel/CreateMissionViewModel.swift b/RocketCall/ViewModel/CreateMissionViewModel.swift new file mode 100644 index 0000000..d51cd98 --- /dev/null +++ b/RocketCall/ViewModel/CreateMissionViewModel.swift @@ -0,0 +1,143 @@ +// +// CreateMissionViewModel.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/25/26. +// + +import Foundation +import RxSwift +import RxCocoa + +class CreateMissionViewModel: ViewModelProtocol { + + private let disposeBag = DisposeBag() + private let coreDataManager: CoreDataManager + private let errorSubject = PublishSubject() + + init(coreDataManager: CoreDataManager) { + self.coreDataManager = coreDataManager + } + + struct Input { + let missionName: Observable + let studyTime: Observable + let restTime: Observable + let cycleCount: Observable + let quickItemSelected: Observable + let createButtonTapped: Observable + } + + struct Output { + let totalTime: Observable + let intervalText: Observable + let selectedQuickItem: Observable + let quickStudyTime: Observable + let quickRestTime: Observable + let isCreateButtonEnabled: Observable + // ์„ฑ๊ณต ์—ฌ๋ถ€ + ๋ฏธ์…˜ ํŽ˜์ด๋กœ๋“œ ์ „๋‹ฌ (success๋ฅผ ๋Œ€์ฒดํ•จ) + let createdMission: Observable + let error: Observable + } + + func transform(_ input: Input) -> Output { + + let time = Observable + .combineLatest(input.studyTime, input.restTime, input.cycleCount) + // ์ด ์†Œ์š” ์‹œ๊ฐ„ + let totalTime = time + .map { studyTime, restTime, cycleCount in + let total = (studyTime + restTime) * cycleCount + let hour = total / 60 + let minute = total % 60 + if hour > 0 { + return "\(hour)์‹œ๊ฐ„ \(minute)๋ถ„" + } else { + return "\(minute)๋ถ„" + } + } + + // ๋ฐ˜๋ณต ์ฃผ๊ธฐ + let intervalText = time + .map { studyTime, restTime, cycleCount in + return "\(studyTime + restTime) x \(cycleCount)ํšŒ ๋ฐ˜๋ณต" // ์ •ํ•˜๊ณ  ์ˆ˜์ • ํ•„์š” + } + + let quickItem: [(studyTime: Int, restTime: Int)] = [ + (25, 5), (50, 10), (90, 20), (120, 20) + ] + // ๋น ๋ฅธ ์„ ํƒ ์•„์ดํ…œ์˜ Index ๊ฐ€์ ธ์˜ค๊ธฐ, ๋‹ค์‹œ ์„ ํƒ ์‹œ ์ทจ์†Œ + let selectedQuickItem: Observable = input.quickItemSelected + .scan(nil as Int?) { current, selected in + current == selected ? nil : selected + } + .share() // quickStudyTime, quickRestTime์—์„œ ์‚ฌ์šฉ -> ๊ณต์œ  + + let quickStudyTime = selectedQuickItem + .map { index -> Int in + guard let index else { return 0 } + return quickItem[index].studyTime + } + + let quickRestTime = selectedQuickItem + .map { index -> Int in + guard let index else { return 0 } + return quickItem[index].restTime + } + + // ๋ฒ„ํŠผ ํ™œ์„ฑํ™” ๋น„ํ™œ์„ฑํ™” + let isCreateButtonEnabled = Observable + .combineLatest(input.missionName, input.studyTime, input.cycleCount) + .map { missionName, studyTime, cycleCount in + !missionName.isEmpty && studyTime >= 1 && cycleCount >= 1 + } + + // ์ €์žฅ ์„ฑ๊ณต, ์‹คํŒจ + // ์ƒ์„ฑ๋ฒ„ํŠผ ํƒญํ•˜๋ฉด createdMission๋งŒ๋“ฆ(success ์—ญํ•  ํฌํ•จ) + let createdMission = input.createButtonTapped + .flatMap { + Observable.combineLatest( + input.missionName, + input.studyTime, + input.restTime, + input.cycleCount + ).take(1) + } + // ํŽ˜์ด๋กœ๋“œ ๊ฐ’๊นŒ์ง€ ์ „๋‹ฌํ•˜๋„๋ก ์ˆ˜์ •ํ•จ + .flatMap { [weak self] (missionName, studyTime, restTime, cycleCount) -> Observable in + guard let self else { return .empty() } + let mission = MissionPayload( + id: UUID(), + title: missionName, + concentrateTime: studyTime, + breakTime: restTime, + cycle: cycleCount + ) + do { + try self.coreDataManager.createMissionEntity(mission: mission) + print("์ €์žฅ ์„ฑ๊ณต.\n title: \(missionName), concentrateTime: \(studyTime), breakTime: \(restTime), cycle: \(cycleCount)") + // ๋ฏธ์…˜์ •๋ณด๊นŒ์ง€ ์ „๋‹ฌ + return .just(mission) + } catch { + if let coreDataError = error as? CoreDataManager.CoreDataError { + self.errorSubject.onNext(coreDataError) + } else { + self.errorSubject.onNext(.saveFailed) + } + return .empty() + } + } + + return Output( + totalTime: totalTime, + intervalText: intervalText, + selectedQuickItem: selectedQuickItem, + quickStudyTime: quickStudyTime, + quickRestTime: quickRestTime, + isCreateButtonEnabled: isCreateButtonEnabled, + // success: success์—์„œ ๋ฏธ์…˜์— ๋Œ€ํ•œ ์ •๋ณดํฌํ•จํ•˜๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ณ€๊ฒฝ + createdMission: createdMission, + error: errorSubject.asObservable() + ) + } +} diff --git a/RocketCall/ViewModel/HomeViewModel.swift b/RocketCall/ViewModel/HomeViewModel.swift new file mode 100644 index 0000000..ff32664 --- /dev/null +++ b/RocketCall/ViewModel/HomeViewModel.swift @@ -0,0 +1,486 @@ +// +// HomeViewModel.swift +// RocketCall +// +// Created by t2025-m0143 on 3/26/26. +// + +import RxSwift +import RxCocoa +import Foundation +import UserNotifications +import Then + +final class HomeViewModel: ViewModelProtocol { + struct Input { + let fetchData: Observable + } + + struct Output { + let alarm: Observable> // (alarm: ์•Œ๋žŒ, isExist: ์กด์žฌ ์—ฌ๋ถ€) + let missionResultList: Observable> // ๋ฏธ์…˜ ๊ฒฐ๊ณผ ๋ชฉ๋ก + let sum: Observable> // ๋ฏธ์…˜ ๊ฒฐ๊ณผ ํ†ต๊ณ„ + let chartRawData: Observable> // ์ฐจํŠธ๋ทฐ ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ dataSource + let progressStatus: Observable> // ProgressView ๋ฐ์ดํ„ฐ + } + + + //MARK: ์†์„ฑ ์„ ์–ธ + let coreDataManager: CoreDataManager + let notificationManager: NotificationManager + let disposeBag = DisposeBag() + + let center = UNUserNotificationCenter.current() + let dateFormatter = DateFormatter().then { + $0.locale = Locale(identifier: "ko_KR") + $0.dateFormat = "M์›” dd์ผ (E)" + } + private(set) var weeklyData: WeeklyData // ChartView ๋ฐ”์ธ๋”ฉ์šฉ + + struct TotalResultValue { + let value: String + let detail: String + } + + //MARK: init + init(coreDataManager: CoreDataManager, notificationManager: NotificationManager) { + self.coreDataManager = coreDataManager + self.notificationManager = notificationManager + self.weeklyData = WeeklyData() + } + + func transform(_ input: Input) -> Output { + let fetch = input.fetchData + .share() + + // ๊ฐ€๊นŒ์šด ์•Œ๋žŒ + let alarm: Observable> = fetch + .withUnretained(self) + .flatMap { `self`, _ in + self.nearestAlarm() + .map { + .success($0) + } + .catch { + .just(.failure($0)) + } + } + + // ์ „์ฒด ๋ฏธ์…˜ ๊ฒฐ๊ณผ ๊ฐ€์ ธ์˜ค๊ธฐ + let missionResults: Observable> = fetch + .withUnretained(self) + .flatMap { `self`, _ in + self.fetchAllMissionResults() + .map { + .success($0) + } + .catch { + .just(.failure($0)) + } + } + .share() + + let missionResultList: Observable> = missionResults + .withUnretained(self) + .flatMap { `self`, results in + self.mapToMissionResultList(results) + } + + // ๊ฒฐ๊ณผ ํ†ต๊ณ„ + let sum: Observable> = missionResults + .withUnretained(self) + .flatMap { `self`, results in + self.sumResults(of: results) + } + .share() + + // ์ƒ์„ธํ™”๋ฉด ์ฐจํŠธ ๋ทฐ์— ๋ฐ”์ธ๋”ฉ์šฉ ์ฃผ๊ฐ„ ๋ˆ„์  ๊ธฐ๋ก ๋ฐ์ดํ„ฐ + let chartRawData = missionResults + .withUnretained(self) + .flatMap { `self`, results in + self.chartRawData(from: results) + } + + let progress: Observable> = sum + .withUnretained(self) + .flatMap { `self`, results in + self.progressStatus(from: results) + } + + return Output( + alarm: alarm, + missionResultList: missionResultList, + sum: sum, + chartRawData: chartRawData, + progressStatus: progress + ) + } +} + +extension HomeViewModel { + struct SumResult: Hashable { + var cardType: TotalCardView.CardCategory + var value: Int + var detail: Int + } +} + +//MARK: ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์•Œ๋žŒ ๊ฐ€์ ธ์˜ค๊ธฐ +extension HomeViewModel { + func nearestAlarm() -> Observable { + Observable.create { [weak self] observer in + let task = Task { + do { + let payload = try await self?.fetchNearestAlarmPayload() + + if let payload { + let result = Alarm( + id: payload.id, + hour: payload.hour, + minute: payload.minute, + title: payload.title, + repeatDays: payload.repeatDays.compactMap { WeekDay(rawValue: $0) }, + isOn: payload.isOn + ) + + observer.on(.next(result)) + observer.onCompleted() + } else { + observer.onNext(nil) + observer.onCompleted() + } + } catch { + observer.onError(error) + } + } + return Disposables.create { + task.cancel() + } + } + } + + private func fetchNearestAlarmPayload() async throws -> AlarmPayload? { + guard let id = await notificationManager.fetchNearestAlarm() else { + return nil + } + + do { + let result = try coreDataManager.fetchAlarm(of: id) + return result + } catch { + throw error + } + } +} + +//MARK: Total ๊ธฐ๋ก +extension HomeViewModel { + // ๋ฏธ์…˜ ๊ฒฐ๊ณผ ํ†ต๊ณ„ ๊ณ„์‚ฐ ๋ฉ”์„œ๋“œ + private func sumResults(of result: Result<[MissionResultPayload], Error>) -> Observable> { + Observable.create { [weak self] observer in + guard let self else { return Disposables.create() } + + switch result { + case .success(let results): + let totalTime = self.calculateTotalTime(of: results) // ์ด ์ง‘์ค‘ ์‹œ๊ฐ„ + let leftTime = self.calculateLeftTime(from: totalTime.detail) // ๋‹ค์Œ ๋ชฉํ‘œ๊นŒ์ง€ ๋‚จ์€ ์‹œ๊ฐ„ + let totalCount = self.calculateCompleteCount(of: results) // ์ด ๋ฏธ์…˜ ์„ฑ๊ณต ํšŸ์ˆ˜ + let streak = self.calculateStreak(of: results) + + observer.onNext(.success([totalTime, leftTime, totalCount, streak])) + observer.onCompleted() + + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + + return Disposables.create() + } + } + + // ์ด ์ง‘์ค‘ ์‹œ๊ฐ„ ๊ณ„์‚ฐ ๋ฉ”์„œ๋“œ + private func calculateTotalTime(of results: [MissionResultPayload]) -> SumResult { + guard !results.isEmpty else { + return SumResult( + cardType: .totalTime, + value: 0, + detail: 0 + ) + } + + let completed = results.filter { $0.isCompleted } + let seconds = completed.reduce(0) { $0 + $1.studyTime } // ์ดˆ ๋‹จ์œ„ - $1.studyTime์€ ์ดˆ ๋‹จ์œ„๋กœ ์ž‘์„ฑ + + let minutes = seconds / 60 // ๋ถ„ ๋‹จ์œ„ ๋ณ€ํ™˜ + let hours = minutes / 60 // ์‹œ ๋‹จ์œ„ ๋ณ€ํ™˜ + + return SumResult( + cardType: .totalTime, + value: hours, + detail: minutes + ) + } + + // ๋‹ค์Œ ๋ชฉํ‘œ๊นŒ์ง€ ์ž”์—ฌ ์‹œ๊ฐ„ ๊ณ„์‚ฐ ๋ฉ”์„œ๋“œ + private func calculateLeftTime(from totalMinute: Int) -> SumResult { + guard let target = findTargetPlanet(from: totalMinute) else { + return SumResult( + cardType: .leftTime, + value: 0, + detail: 0 + ) + } + + let leftMinute = target.targetTime * 60 - totalMinute // ๋ถ„ ๋‹จ์œ„ - target.targetTime์€ ์‹œ ๋‹จ์œ„๋กœ ์ž‘์„ฑ + let leftHour = leftMinute / 60 // ์‹œ ๋‹จ์œ„ ๋ณ€ํ™˜ + + return SumResult( + cardType: .leftTime, + value: leftHour, + detail: leftMinute + ) + } + + // ์ด ์„ฑ๊ณต ๋ฏธ์…˜ ํšŸ์ˆ˜ ๊ณ„์‚ฐ ๋ฉ”์„œ๋“œ + private func calculateCompleteCount(of results: [MissionResultPayload]) -> SumResult { + let count = results.filter { $0.isCompleted }.count + + return SumResult( + cardType: .totalCount, + value: count, + detail: -1 + ) + } + + // ์—ฐ์† ๊ธฐ๋ก ์ผ์ˆ˜ ๊ณ„์‚ฐ ๋ฉ”์„œ๋“œ + private func calculateStreak(of results: [MissionResultPayload]) -> SumResult { + // ๋ฏธ์…˜ ๊ฒฐ๊ณผ๊ฐ€ ์—†์„ ๊ฒฝ์šฐ + guard !results.isEmpty else { + return SumResult( + cardType: .streak, + value: 0, + detail: -1 + ) + } + + // ๋ฏธ์…˜ ๊ฒฐ๊ณผ๊ฐ€ 1๊ฐœ์ผ ๊ฒฝ์šฐ + if results.count == 1 { + return SumResult( + cardType: .streak, + value: 1, + detail: -1 + ) + } + + let calendar = Calendar.current + + // ์„ฑ๊ณต ๋ฏธ์…˜ ์‹œ์ž‘์ผ์ž ๋ฐฐ์—ด + let completed = results + .filter { $0.isCompleted } + .map { + calendar.startOfDay(for: $0.start) + } + + // ๋ฏธ์…˜ ์„ฑ๊ณต ๋‚ ์งœ Set - ์ตœ๊ทผ์ˆœ ์ •๋ ฌ + let completeDates = Set(completed) + .sorted(by: { $0 > $1 }) + + // ์–ด์ œ ๋‚ ์งœ + let yesterday = calendar.date( + byAdding: .day, + value: -1, + to: calendar.startOfDay(for: Date.now) + )! + + + var streak = 0 + + /* + ๋ฏธ์…˜ ์„ฑ๊ณต ๋‚ ์งœ์— ์–ด์ œ ๋‚ ์งœ๊ฐ€ ํฌํ•จ๋˜์–ด์žˆ๋Š”์ง€ ํ™•์ธ + - ์—ฐ์† ์ง‘์ค‘ ํ›„ ์ ‘์† ์ผ์ž์™€ ๋งˆ์ง€๋ง‰ ์ง‘์ค‘ ์ผ์ž ๊ฐ„๊ฒฉ์ด 1์ผ๋ณด๋‹ค ๋งŽ์€ ๊ฒฝ์šฐ ์—ฐ์† ๊ธฐ๋ก์„ ์ดˆ๊ธฐํ™”ํ•˜๊ธฐ ์œ„ํ•จ + - ์˜ˆ์‹œ) ์›”, ํ™”, ์ˆ˜ ์—ฐ์†์œผ๋กœ ์ง‘์ค‘ํ•˜๊ณ  ๋ชฉ, ๊ธˆ์€ ์ง‘์ค‘ํ•˜์ง€ ์•Š์€ ์ƒํƒœ๋กœ ํ† ์š”์ผ์— ์ ‘์†ํ–ˆ์„ ๊ฒฝ์šฐ, ๊ธฐ์กด ์—ฐ์† ๊ธฐ๋ก 3์ผ์ด ๋ณด์ด์ง€ ์•Š๋„๋ก ํ•˜๊ธฐ ์œ„ํ•จ + */ + if completeDates.contains(yesterday) { + for i in 1..) -> Observable> { + Observable.create { [weak self] observer in + guard let self else { return Disposables.create() } + + switch result { + case .success(let results): + let data = calculateWeeklyTotal(of: results) + self.weeklyData.newValue(data) // ์ฐจํŠธ๋ทฐ(Main ํ™”๋ฉด) dataSource ์—…๋ฐ์ดํŠธ + + observer.onNext(.success(data)) + observer.onCompleted() + + case .failure(let error): + observer.onNext(.failure(error)) + } + return Disposables.create() + } + } + + // ์ฃผ๊ฐ„ ๋ˆ„์  ๊ธฐ๋ก์„ ๊ณ„์‚ฐํ•˜๋Š” ๋ฉ”์„œ๋“œ + private func calculateWeeklyTotal(of results: [MissionResultPayload]) -> [Int: Int] { + let calendar = Calendar.current + let date = Date.now + let todayWeekday = calendar.dateComponents(in: .current, from: date).weekday ?? -1 + + let distanceToMonday = (todayWeekday - 2 + 7) % 7 // ์˜ค๋Š˜ ์š”์ผ์—์„œ ์›”์š”์ผ๊นŒ์ง€์˜ ์ฐจ์ด, ์›”์š”์ผ์˜ weekday๋Š” 1 + + let start = calendar.date(byAdding: .day, value: -distanceToMonday, to: calendar.startOfDay(for: date))! // ์›”์š”์ผ 00:00 + let end = calendar.date(byAdding: .day, value: 7, to: start.addingTimeInterval(-1))! // ์ผ์š”์ผ 23:59 + + let filtered = results.filter { + $0.isCompleted == true + && $0.start >= start + && $0.end <= end + } + + var weeklyRecord: [Int: Int] = [:] + + for result in filtered { + let rawWeekday = calendar.dateComponents(in: .current, from: result.start).weekday ?? -1 + let weekday = rawWeekday == 1 ? 6 : rawWeekday - 2 + + weeklyRecord[weekday, default: 0] += (result.studyTime / 60) + } + + return weeklyRecord + } +} + +//MARK: Progress View +extension HomeViewModel { + struct ProgressStatus: Hashable { + let current: Planet + let target: Planet? + let progress: Float + } + + private func progressStatus(from result: Result<[SumResult], Error>) -> Observable> { + Observable.create { [weak self] observer in + guard let self else { return Disposables.create() } + + switch result { + case .success(let result): + let cum = result[TotalCardView.CardCategory.totalTime.rawValue].detail // ๋ˆ„์  ์ง‘์ค‘ ์‹œ๊ฐ„(๋ถ„) + + let progressStatus = self.calculateProgress(cum: cum) + print(progressStatus) + observer.onNext(.success(progressStatus)) + observer.onCompleted() + + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + + return Disposables.create() + } + } + + private func calculateProgress(cum: Int) -> ProgressStatus { + let target = findTargetPlanet(from: cum) // ๋ชฉ์ ์ง€ + + guard let target else { // ๋งˆ์ง€๋ง‰ ๋ชฉ์ ์ง€๊นŒ์ง€ ์ด๋ฏธ ๋„๋‹ฌํ•œ ๊ฒฝ์šฐ + return ProgressStatus( + current: Planet.allCases.last!, + target: nil, + progress: 1 + ) + } + + let current = Planet.allCases[target.rawValue - 1] + let progress = Float(cum) / Float(target.targetTime * 60) + + return ProgressStatus( + current: current, + target: target, + progress: progress + ) + } +} + +extension HomeViewModel { + private func fetchAllMissionResults() -> Observable<[MissionResultPayload]> { + Observable.create { [weak self] observer in + guard let self else { return Disposables.create() } + do { + let results = try self.coreDataManager.fetchAllMissionResult() + observer.onNext(results) + observer.onCompleted() + } catch { + observer.onError(error) + } + return Disposables.create() + } + } + + private func findTargetPlanet(from totalMinute: Int) -> Planet? { + Planet.allCases.filter { + totalMinute < ($0.targetTime * 60) + }.first + } +} + +extension HomeViewModel { + struct MissionResultList: Hashable { + let id: UUID + let title: String + let date: String + let studyTime: Int // ๊ณต๋ถ€ ์‹œ๊ฐ„ - ๋ถ„๋‹จ์œ„ + let isCompleted: Bool // ๋‹ฌ์„ฑ ์—ฌ๋ถ€ + } + + private func mapToMissionResultList(_ results: Result<[MissionResultPayload], Error>) -> Observable> { + Observable.create { observer in + switch results { + case .success(let results): + let list = results.map { [weak self] in + let time = $0.studyTime / 60 + let date = self?.dateFormatter.string(from: $0.start) ?? "" + + return MissionResultList( + id: $0.id, + title: $0.title, + date: date, + studyTime: time, + isCompleted: $0.isCompleted + ) + } + + observer.onNext(.success(list)) + observer.onCompleted() + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + return Disposables.create() + } + + } +} diff --git a/RocketCall/ViewModel/MissionViewModel.swift b/RocketCall/ViewModel/MissionViewModel.swift new file mode 100644 index 0000000..d2fd2ba --- /dev/null +++ b/RocketCall/ViewModel/MissionViewModel.swift @@ -0,0 +1,54 @@ +// +// MissionViewModel.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/26/26. +// + +import Foundation +import RxSwift +import RxCocoa + +class MissionViewModel: ViewModelProtocol { + + private let disposeBag = DisposeBag() + private let coreDataManager: CoreDataManager + + private let errorSubject = PublishSubject() + + init(coreDataManager: CoreDataManager) { + self.coreDataManager = coreDataManager + } + + struct Input { + let initialize: Observable + } + + struct Output { + let missions: Observable<[MissionPayload]> + let error: Observable + } + + func transform(_ input: Input) -> Output { + + let missions = input.initialize + .map { [weak self] _ -> [MissionPayload] in + guard let self else { return [] } + do { + return try self.coreDataManager.fetchAllMission() + } catch { + if let coreDataError = error as? CoreDataManager.CoreDataError { + self.errorSubject.onNext(coreDataError) + } else { + self.errorSubject.onNext(.loadFailed) + } + return [] + } + } + + return Output( + missions: missions, + error: errorSubject.asObservable() + ) + } +} diff --git a/RocketCall/ViewModel/StopWatchViewModel.swift b/RocketCall/ViewModel/StopWatchViewModel.swift new file mode 100644 index 0000000..c15d769 --- /dev/null +++ b/RocketCall/ViewModel/StopWatchViewModel.swift @@ -0,0 +1,338 @@ +// +// StopWatchViewModel.swift +// RocketCall +// +// Created by Hanjuheon on 3/25/26. +// + +import Foundation +import UIKit +import RxSwift +import RxRelay +import RxCocoa + +/// DiffableDataSource์šฉ ๋ ˆ์ฝ”๋“œ ๊ตฌ์กฐ์ฒด +struct RecordData: Hashable { + var count : Int + var time : String + var location : String + var isLive : Bool +} + +/// StopWatch ViewModel +class StopWatchViewModel { + /// ์Šคํƒ‘์›Œ์น˜ ์ƒํƒœ + enum State { + /// ์Šคํƒ‘์›Œ์น˜ ๋™์ž‘ + case run + /// ์Šคํƒ‘์›Œ์น˜ ์ผ์‹œ์ •์ง€ + case pause + /// ์Šคํƒ‘์›Œ์น˜ ์ดˆ๊ธฐํ™” + case reset + } + + /// ์Šคํƒ‘์›Œ์น˜ ๋™์ž‘์•ก์…˜ + enum TimerAction { + /// ์‹œ๊ฐ„ ๋™์ž‘ ์•ก์…˜ + case tick + /// ์‹œ๊ฐ„ ์ดˆ๊ธฐํ™” ์•ก์…˜ + case reset + /// ๋ ˆ์ฝ”๋“œ ์ƒ์„ฑ ์•ก์…˜ + case record + /// ๋™์ž‘ ์œ ๋ฌด ์•ก์…˜ + case isRunning(Bool) + /// ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์•ก์…˜ + case background(Date) + /// ํฌ์–ด๊ทธ๋ผ์šด๋“œ ์•ก์…˜ + case foreground(Date) + } + + /// ์Šคํƒ‘์›Œ์น˜ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ์ฒด + struct StopWatchData { + /// ์Šคํƒ‘์›Œ์น˜ ๋ฉ”์ธ ํƒ€์ด๋จธ + var mainTimer = 0 + /// ์Šคํƒ‘์›Œ์น˜ ๋ ˆ์ฝ”๋“œ ํƒ€์ด๋จธ + var recordTimer = 0 + /// ์Šคํƒ‘์›Œ์น˜ ๋ ˆ์ฝ”๋“œ ์ •๋ณด ๋ฐฐ์—ด + var records: [RecordData] = [] + /// ์Šคํƒ‘์›Œ์น˜ ๋™์ž‘ ์œ ๋ฌด ์ฒดํฌ + var isRun = false + /// ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์‹œ๊ฐ„ + var backgroundEnterTime: Date? + } + + /// View -> ViewModel Action + struct Input { + /// ์Šคํƒ‘์›Œ์น˜ ๋™์ž‘ In + let stopwatchAction: Observable + /// ๋ ˆ์ฝ”๋“œ In + let record: Observable + } + + /// ViewModel -> View Action + struct Output { + /// ๋ฉ”์ธํƒ€์ด๋จธ Out + let mainTimer: Observable + /// ๋ ˆ์ฝ”๋“œ ์ •๋ณด Out + let record: Observable<[RecordData]> + /// ํ˜„์žฌ ์œ„์น˜๊ฐ’ Out + let location: Observable + /// ๋‹ค์Œ ์œ„์น˜๊ฐ’ Out + let targetLocation: Observable + } + + /// ๋ณ€ํ™˜ ๋ฉ”์†Œ๋“œ + func transform(_ input: Input) -> Output { + + // ์‹คํ–‰ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์•ก์…˜ + let runningAction = input.stopwatchAction + .map { state -> TimerAction in + switch state { + case .run: + return .isRunning(true) + case .pause, .reset: + return .isRunning(false) + } + } + + // ์Šคํ†ฑ์›Œ์น˜ ๋™์ž‘ ์•ก์…˜(timerAction)์œผ๋กœ ๋ณ€ํ™˜ + let timeAction = input.stopwatchAction + .flatMapLatest{ state -> Observable in + switch state { + case .run: + return Observable.interval(.milliseconds(10), scheduler: MainScheduler.asyncInstance) + .map{ _ in .tick } + case .pause: + return .empty() + case .reset: + return .just(.reset) + } + } + + // ๋ ˆ์ฝ”๋“œ ์•ก์…˜ ๋ฐ˜ํ™˜ + let recordAction = input.record + .map { TimerAction.record } + + // NotificationCenter์„ ์ด์šฉํ•˜์—ฌ ์•ฑ๋ผ์ดํ”„ ์‚ฌ์ดํด์„ ํ†ตํ•ด ํ˜„์žฌ ์‹œ๊ฐ„ ๊ฐ’์„ ์ถ”์ถœ + // ์•ฑ์ด ํ™œ์„ฑ์ƒํƒœ๋ฅผ ์žƒ์„๋•Œ(didEnterBackgroundNotification) ๋ฐœ์ƒ : ํ™ˆํ™”๋ฉด, ์ „ํ™” ๋ฐ ๋‹ค๋ฅธ ์ œ์–ด์ƒŒํ„ฐ ๋“ฑ์œผ๋กœ ์ด๋™ ์‹œ + let backgroundAction = NotificationCenter.default.rx + .notification(UIApplication.didEnterBackgroundNotification) + .map { _ in + return TimerAction.background(Date()) + } + + // ์•ฑ์ด ๋‹ค์‹œ ํ™œ์„ฑ์ƒํƒœ๊ฐ€ ๋˜์—ˆ์„๋•Œ(willEnterForegroundNotification) ๋ฐœ์ƒ + let foregroundAction = NotificationCenter.default.rx + .notification(UIApplication.willEnterForegroundNotification) + .map { _ in + return TimerAction.foreground(Date()) + } + + + // ์•ก์…˜์— ๋”ฐ๋ฅธ ์Šคํƒ‘์›Œ์น˜ ๋™์ž‘์•ก์…˜ ์ฒ˜๋ฆฌ + let state = Observable.merge(timeAction, recordAction, runningAction, backgroundAction, foregroundAction) + .scan(StopWatchData()) { [weak self] data, action in + guard let self else { return StopWatchData() } + var newData = data + + switch action { + // ์ „์ฒด ํƒ€์ด๋จธ์™€ ๋ ˆ์ฝ”๋“œ ํƒ€์ด๋จธ๋ฅผ ๋ชจ๋‘ 1 ์ฆ๊ฐ€ + case .tick: + newData.mainTimer = newData.mainTimer + 1 + newData.recordTimer = newData.recordTimer + 1 + + // ํ˜„์žฌ recordTimer ๊ฐ’์„ ํ™•์ • ๋ ˆ์ฝ”๋“œ๋กœ ์ €์žฅ + case .record: + let recordData = RecordData(count: newData.records.count + 1, + time: formatTime(newData.recordTimer), + location: FreeStage.currentLocationTitle(newData.mainTimer), + isLive: false) + newData.recordTimer = 0 + newData.records.insert(recordData, at: 0) + + // ์Šคํƒ‘์›Œ์น˜ ์ „์ฒด ์ •๋ณด ์ดˆ๊ธฐํ™” + case .reset: + newData.mainTimer = 0 + newData.recordTimer = 0 + newData.records.removeAll() + + // ์Šคํƒ‘์›Œ์น˜ ๋™์ž‘ ์—ฌ๋ถ€ ์„ค์ • + case .isRunning(let isRunning): + newData.isRun = isRunning + if !newData.isRun { + newData.backgroundEnterTime = nil + } + + // ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์•ก์…˜ ์‹œ, ํ•ด๋‹น ์‹œ์  ์ €์žฅ + case .background(let BackDate): + if newData.isRun{ + newData.backgroundEnterTime = BackDate + } + + // ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜ ์žฌ ์ง„์ž… ์‹œ, ๋กœ์ง + case .foreground(let foreDate): + // ์Šคํƒ‘์›Œ์น˜ ๋™์ž‘์—ฌ๋ถ€ ์ฒดํฌ ๋ฐ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์‹œ๊ฐ„ ์กด์žฌ ์—ฌ๋ถ€ ์ฒดํฌ + guard newData.isRun, + let backgroundDate = newData.backgroundEnterTime else { + return newData + } + + // ํ˜„์žฌ ๊ฐ’ ๋ฐ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ €์žฅ ๊ฐ’ ์ฐจ์ด ๊ณ„์‹ผ ๋ฐ ๋ฐ€๋ฆฌ์ดˆ๋กœ ๋ณ€ํ™˜ + let backgroundTime = foreDate.timeIntervalSince(backgroundDate) + let centisecond = Int((backgroundTime * 100).rounded()) + newData.backgroundEnterTime = nil + + // ๋ฐ€๋ฆฌ ์ดˆ ์‚ฝ์ž… + guard centisecond > 0 else { return newData } + newData.mainTimer += centisecond + newData.recordTimer += centisecond + } + + return newData + } + // ์ดˆ๊ธฐ๊ฐ’ ์„ค์ • + .startWith(StopWatchData()) + // ๊ณต์œ  ์„ค์ • + .share(replay: 1) + + // ๋ฉ”์ธํƒ€์ด๋จธ์— ๋Œ€ํ•œ ๋ฌธ์ž์—ด ๋ณ€ํ™˜ ์ฒ˜๋ฆฌ ๋ฐ ์ถœ๋ ฅ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + let mainTimer = state + .map { [weak self] data in + guard let self else { return "" } + return formatTime(data.mainTimer) + } + + // ์‹ค์‹œ๊ฐ„ ๋ ˆ์ฝ”๋“œ ์ •๋ณด ๋ฐ ๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ํ†ตํ•ฉ ์ถœ๋ ฅ์šฉ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + let record = state + .map { [weak self] data -> [RecordData] in + guard let self else { return [] } + let currentRecord = RecordData(count: data.records.count + 1, + time: formatTime(data.recordTimer), + location: FreeStage.currentLocationTitle(data.mainTimer), + isLive: true) + + return [currentRecord] + data.records + } + + // ํ˜„์žฌ ์œ„์น˜ ์ถœ๋ ฅ์šฉ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + let location = state + .map { data -> String in + return FreeStage.currentLocationTitle(data.mainTimer) + (data.mainTimer != 0 ? " ํ•ญํ–‰ ์ค‘" : " ๋Œ€๊ธฐ ์ค‘") + } + + // ๋‹ค์Œ ํƒ€๊ฒŸ ์œ„์น˜ ์ถœ๋ ฅ์šฉ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + let targetLocation = state + .map {data -> String in + return "๋‹ค์Œ ์œ„์น˜: \(FreeStage.targetLocationTitle(data.mainTimer))" + } + + return Output( + mainTimer: mainTimer, + record: record, + location: location, + targetLocation: targetLocation + ) + } + + + /// ๋ฐ€๋ฆฌ์ดˆ ํฌ๋ฉงํŒ… ๋ฉ”์†Œ๋“œ + private func formatTime(_ centiseconds: Int) -> String { + let minutes = centiseconds / 6000 + let seconds = (centiseconds % 6000) / 100 + let cs = centiseconds % 100 + + return String(format: "%02d:%02d.%02d", minutes, seconds, cs) + } +} + + +/// ์ž์œ ํ•ญํ–‰ ๋ชฉํ‘œ ๊ฐ’ +enum FreeStage: Int, CaseIterable { + case launchPad = 0 + case surface = 5 + case troposphere = 13 + case stratosphere = 20 + case mesosphere = 35 + case thermosphere = 55 + case exosphere = 80 + case deepSpace = 100 + case moon = 120 + + var title: String { + switch self { + case .launchPad: "๋ฐœ์‚ฌ๋Œ€" + case .surface: "์ง€ํ‘œ๋ฉด" + case .troposphere: "๋Œ€๋ฅ˜๊ถŒ" + case .stratosphere: "์„ฑ์ธต๊ถŒ" + case .mesosphere: "์ค‘๊ฐ„๊ถŒ" + case .thermosphere: "์—ด๊ถŒ" + case .exosphere: "์™ธ๊ธฐ๊ถŒ" + case .deepSpace: "์‹ฌ์šฐ์ฃผ" + case .moon: "๋‹ฌ" + } + } + + /// ์Šคํƒ‘์›Œ์น˜ ์‹œ๊ฐ„ ๊ธฐ์ค€ ํ˜„์žฌ ์œ„์น˜ ๋ฉ”์†Œ๋“œ + static func currentLocationTitle(_ centiseconds: Int) -> String { + let elapsedMinutes = Double(centiseconds) / 6000.0 + + switch elapsedMinutes { + case 0: + return FreeStage.launchPad.title + case 0.0..<5.0: + return FreeStage.surface.title + case 5.0..<13.0: + return FreeStage.troposphere.title + case 13.0..<20.0: + return FreeStage.stratosphere.title + case 20.0..<35.0: + return FreeStage.mesosphere.title + case 35.0..<55.0: + return FreeStage.thermosphere.title + case 55.0..<80.0: + return FreeStage.exosphere.title + case 80.0..<100.0: + return FreeStage.deepSpace.title + case 100.0..<120.0: + return FreeStage.moon.title + default: + return FreeStage.deepSpace.title + } + } + + /// ์Šคํƒ‘์›Œ์น˜ ์‹œ๊ฐ„ ๊ธฐ์ค€ ๋‹ค์Œ ์œ„์น˜ ๋ฉ”์†Œ๋“œ + static func targetLocationTitle(_ centiseconds: Int) -> String { + let elapsedMinutes = Double(centiseconds) / 6000.0 + + switch elapsedMinutes { + case 0: + return FreeStage.surface.title + case 0.0..<5.0: + return FreeStage.troposphere.title + case 5.0..<13.0: + return FreeStage.stratosphere.title + case 13.0..<20.0: + return FreeStage.mesosphere.title + case 20.0..<35.0: + return FreeStage.thermosphere.title + case 35.0..<55.0: + return FreeStage.exosphere.title + case 55.0..<80.0: + return FreeStage.deepSpace.title + case 80.0..<100.0: + return FreeStage.moon.title + default: + return FreeStage.deepSpace.title + } + } + + /// ์ž์œ ํ•ญํ–‰ ๋ชฉ์ ์ง€ ์ •๋ณด ์ถœ๋ ฅ ๋ฉ”์†Œ๋“œ + static func infoLocation() -> [ContainerInfoItem] { + return FreeStage.allCases.map { + ContainerInfoItem( + title: $0.title, + value: "\($0.rawValue)๋ถ„" + ) + } + } +} diff --git a/RocketCall/ViewModel/TimerAnimationViewModel.swift b/RocketCall/ViewModel/TimerAnimationViewModel.swift new file mode 100644 index 0000000..f4c57af --- /dev/null +++ b/RocketCall/ViewModel/TimerAnimationViewModel.swift @@ -0,0 +1,81 @@ +// +// TimerAnimationViewModel.swift +// RocketCall +// +// Created by Yeseul Jang on 3/27/26. +// +import Foundation +import RxSwift +import RxCocoa +import UIKit + +class TimerAnimationViewModel: ViewModelProtocol { + struct Input { + let activatedMissionState: Observable // ์‹ค์ œ ํƒ€์ด๋จธ ์ •๋ณด + let stopButtonTapped: Observable + let missionStopButtonTapped: Observable + let backButtonTapped: Observable + } + + struct Output { + let missionTitleText: Observable + let timerText: Observable + let cycleText: Observable + let remainingTime: Observable + let totalDuration: Observable + let stopButtonImage: Observable + let pauseResumeRequested: Observable + let routeStopMission: Observable + let routeBack: Observable + } + + func transform(_ input: Input) -> Output { + let missionState = input.activatedMissionState + .share(replay: 1, scope: .whileConnected) + + let missionTitle = missionState + .map { $0.mission.title } + + let totalDuration = missionState + .map { mission -> TimeInterval in + let minutes = mission.isConcentrating ? mission.mission.concentrateTime : mission.mission.breakTime + return TimeInterval(minutes * 60) + } + + let remainingTime = missionState + .map { TimeInterval($0.remainingTime) } + + let timerText = remainingTime + .map(Self.formatTime) + + let cycleText = missionState + .map { "\($0.currentCycle) / \($0.mission.cycle) ์‚ฌ์ดํด" } + + let stopButtonImage = missionState + .map { mission -> UIImage? in + UIImage(systemName: mission.isPaused ? "play.fill" : "pause.fill") + } + + return Output( + missionTitleText: missionTitle, + timerText: timerText, + cycleText: cycleText, + remainingTime: remainingTime, + totalDuration: totalDuration, + stopButtonImage: stopButtonImage, + pauseResumeRequested: input.stopButtonTapped, + routeStopMission: input.missionStopButtonTapped, + routeBack: input.backButtonTapped + ) + } +} + +private extension TimerAnimationViewModel { + static func formatTime(_ timeInterval: TimeInterval) -> String { + let totalSeconds = max(0, Int(timeInterval)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + + return String(format: "%02d:%02d", minutes, seconds) + } +} diff --git a/RocketCall/ViewModel/TimerViewModel.swift b/RocketCall/ViewModel/TimerViewModel.swift new file mode 100644 index 0000000..5b955ba --- /dev/null +++ b/RocketCall/ViewModel/TimerViewModel.swift @@ -0,0 +1,282 @@ +// +// TimerViewModel.swift +// RocketCall +// +// Created by ์†์˜๋นˆ on 3/26/26. +// + +import Foundation +import RxSwift +import RxCocoa +import UserNotifications + +class TimerViewModel: ViewModelProtocol { + + private let disposeBag = DisposeBag() + private let coreDataManager: CoreDataManager + private let activatedMissionRelay = BehaviorRelay<[ActivatedMissionPayload]>(value: []) // ์ง„ํ–‰์ค‘ ํƒ€์ด๋จธ + private let errorSubject = PublishSubject() + private let missionResultSubject = PublishSubject() + private let startedMissionSubject = PublishSubject() + + var backgroundEnterTime: Date? + + // ๋ฐ–(mainController)์—์„œ ๊ฒฐ๊ณผ UUID๋ฅผ ์–ป์„ ์ˆ˜ ์ž‡๋„๋ก ๋งŒ๋“ฆ + var missionResult: Observable { + missionResultSubject.asObservable() + } + + init(coreDataManager: CoreDataManager) { + self.coreDataManager = coreDataManager + } + + struct Input { + let activatedMission: Observable + let pauseResumeButtonTapped: Observable + let stopButtonTapped: Observable + } + + struct Output { + let activatedMissions: Observable<([ActivatedMissionPayload], Bool)> + // ์‹œ์ž‘๋œ ๋ฏธ์…˜ ์ถ”๊ฐ€ + let startedMission: Observable + let error: Observable + let missionResult: Observable + } + + func transform(_ input: Input) -> Output { + // ๋ฏธ์…˜ ํ™œ์„ฑํ™” + input.activatedMission + .subscribe(onNext: { [weak self] mission in + guard let self else { return } + let activatedMission = ActivatedMissionPayload( // MissionPayload -> ActivatedMissionPayload ๋ณ€ํ™˜ + id: UUID(), + mission: mission, + currentCycle: 1, + remainingTime: mission.concentrateTime * 60, + isConcentrating: true, + startDate: Date(), + isPaused: false, + studyTime: 0, + pausedTime: 0 + ) + var current = self.activatedMissionRelay.value + current.append(activatedMission) + self.activatedMissionRelay.accept(current) // Relay ๋ฐฐ์—ด์— ์ถ”๊ฐ€ + // ์—ฌ๊ธฐ์„œ ๋ณด๋‚ด๋ฉด MissionViewController์ชฝ์—์„œ ๋ฐ›์•„์„œ ํƒ€์ด๋จธ ํ™”๋ฉด ์—ด์–ด์คŒ + startedMissionSubject.onNext(activatedMission) + }) + .disposed(by: disposeBag) + + // 1์ดˆ๋งˆ๋‹ค ์‹คํ–‰๋˜๋Š” ํƒ€์ด๋จธ (ํ•˜๋‚˜๋กœ ๋ชจ๋‘ ๊ด€๋ฆฌ) + Observable.interval(.seconds(1), scheduler: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + guard let self else { return } + let updated = self.activatedMissionRelay.value.compactMap { mission -> ActivatedMissionPayload? in + if mission.isPaused { + var updated = mission + updated.pausedTime += 1 + return updated + } + var updated = self.updateMission(mission) + if mission.isConcentrating { + updated?.studyTime += 1 + } + return updated + } + self.activatedMissionRelay.accept(updated) // ์ˆ˜์ •๋œ ๋ฐฐ์—ด ๋‹ค์‹œ ๋„ฃ๊ธฐ + }) + .disposed(by: disposeBag) + + // ๋ชฉ๋ก ์ถ”๊ฐ€/์ œ๊ฑฐ -> ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ ์šฉ + let activatedMissions = activatedMissionRelay + .distinctUntilChanged { $0.count == $1.count } + .map{ ($0, true) } + + // ํƒ€์ด๋จธ ์—…๋ฐ์ดํŠธ -> ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ œ๊ฑฐ + let timerUpdate = activatedMissionRelay + .map { ($0, false) } + + input.pauseResumeButtonTapped + .subscribe(onNext: { [weak self] uuid in + guard let self else { return } + let updated = self.activatedMissionRelay.value.map { mission -> ActivatedMissionPayload in + guard mission.id == uuid else { return mission } + var updated = mission + updated.isPaused.toggle() + return updated + } + self.activatedMissionRelay.accept(updated) + }) + .disposed(by: disposeBag) + + input.stopButtonTapped + .subscribe(onNext: { [weak self] uuid in + guard let self else { return } + guard let mission = self.activatedMissionRelay.value.first(where: { $0.id == uuid }) else { return } + self.saveMission(mission: mission, isCompleted: false) + let updated = self.activatedMissionRelay.value.filter { $0.id != uuid } + self.activatedMissionRelay.accept(updated) + }) + .disposed(by: disposeBag) + + return Output( + activatedMissions: Observable.merge(activatedMissions, timerUpdate), + // ์ƒˆ๋กœ์‹œ์ž‘๋œ ํ™œ์„ฑ๋ฏธ์…˜ ๋‚ด๋ณด๋ƒ„ + startedMission: startedMissionSubject.asObservable(), + error: errorSubject.asObservable(), + missionResult: missionResultSubject.asObservable() + ) + } + + // ๋ฏธ์…˜ ์ƒํƒœ ๋ณ€๊ฒฝ ๋กœ์ง + private func updateMission(_ mission: ActivatedMissionPayload) -> ActivatedMissionPayload? { + + var updated = mission + updated.remainingTime -= 1 // 1์ดˆ ๊ฐ์†Œ + + guard updated.remainingTime <= 0 else { return updated } // ๋‚จ์€ ์‹œ๊ฐ„์ด ์žˆ์œผ๋ฉด ๋ฐ˜ํ™˜ + + if updated.isConcentrating { // ์ง‘์ค‘ ์‹œ๊ฐ„ ์ข…๋ฃŒ -> ํœด์‹ ์‹œ๊ฐ„์œผ๋กœ ๋ณ€๊ฒฝ + if updated.mission.breakTime == 0 { + if updated.currentCycle < updated.mission.cycle { // ์ง‘์ค‘ ์‹œ๊ฐ„ -> ์ง‘์ค‘ ์‹œ๊ฐ„ + updated.currentCycle += 1 + updated.remainingTime = updated.mission.concentrateTime * 60 + updated.isConcentrating = true + return updated + } + saveMission(mission: mission, isCompleted: true) // ์ง‘์ค‘ ์‹œ๊ฐ„ -> ์‚ฌ์ดํด ์ข…๋ฃŒ + return nil + } + + updated.remainingTime = updated.mission.breakTime * 60 + updated.isConcentrating = false + return updated + } + + if updated.currentCycle < updated.mission.cycle { // ํœด์‹ ์‹œ๊ฐ„ ์ข…๋ฃŒ -> ๋‹ค์Œ ์‚ฌ์ดํด๋กœ ๋ณ€๊ฒฝ + updated.currentCycle += 1 + updated.remainingTime = updated.mission.concentrateTime * 60 + updated.isConcentrating = true + return updated + } + // ๋ชจ๋“  ์‚ฌ์ดํด ์ข…๋ฃŒ -> CoreData์ €์žฅ -> nil ๋ฐ˜ํ™˜ -> ๋ฐฐ์—ด์—์„œ ์ œ๊ฑฐ + saveMission(mission: mission, isCompleted: true) + return nil + } + + private func saveMission(mission: ActivatedMissionPayload, isCompleted: Bool) { + let result = MissionResultPayload( + id: UUID(), + title: mission.mission.title, + start: mission.startDate, + end: Date(), + studyTime: mission.studyTime, + isCompleted: isCompleted + ) + do { + try coreDataManager.createMissionResultEntity(result: result) + missionResultSubject.onNext(result.id) + print("์ €์žฅ ์™„๋ฃŒ, ๊ณต๋ถ€ ์‹œ๊ฐ„ :\(result.studyTime)") + } catch { + if let coreDataError = error as? CoreDataManager.CoreDataError { + errorSubject.onNext(coreDataError) + } else { + errorSubject.onNext(.saveFailed) + } + } + } + + func enterForeGround() { + guard let backgroundEnterTime else { return } + let elapsed = Int(Date().timeIntervalSince(backgroundEnterTime)) + self.backgroundEnterTime = nil + + let updated = activatedMissionRelay.value.compactMap { mission -> ActivatedMissionPayload? in + guard !mission.isPaused else { + var updated = mission + updated.pausedTime += elapsed + return updated + } + return (0.. Output +}