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
+}