728x90
0. 이전
- 일정에 관련된 CRUD 완성
- 모든 일정이 똑같이 보임 -> 카테고리별로 색상 구분
- 일정만 저장 -> 알림 시간도 저장
1. 카테고리에 사용될 enum
- 정해진 것만 선택 가능
- 카테고리, 우선순위, 상태 등에 사용
- 현재 앱에는 업무/개인/약속/기타
enum EventCategory: String
CaseIterable {
case work = "업무"
case personal = "개인"
...
var color: Color {
switch self {
case .work: return .blue
case .personal: return .green
...
}
}
}
- rawValue: enum의 실제 값 (work -> "업무")
- CaseIterable: 모든 케이스 목록을 자동 생성(.allCases)
- computed property: var color로 각 케이스에 색상 매핑
2. 카테고리 선택 Picker
- 정해진 목록에서 하나를 선택하는 UI
- enum의 allCases와 짝꿍
- 현재 앱에 카테코리/알림 선택에 사용
@State var category:
EventCategory = .other
Picker("카테고리",
selection: $category) {
ForEach(
EventCategory.allCases,
id: \.self) { cat in
Text(cat.rawValue)
.tag(cat)
}
}
3. 색상으로 카테고리 구분하기
// 색상 표시 코드
// 목록에서 카테고리 색상 표시
Hstack {
Circle()
.fill(event.category.color)
.frame(width: 10, height: 10)
Text(event.title)
}
// 상세 화면
Label(
event.category.rawValue,
systemImage: "tag.fill"
)
.foregroundColor(
event.category.color
)
4. 알림 옵션 추가
- 일정 전에 미리 알려주는 시간 설정
- 없음(알림 안함)
- 정시(일정 시간에)
- 10분전
- 30분전
- 1시간전
- 1일전
// Event 모델에 추가
var notificationMinutes: Int? // Optional인 이유는 없을 수도 있으니깐
// nil = 없음
// 0 = 정시
// 10 = 10분 전
// 60 = 1시간 전
// 1440 = 1일 전(60*24)
Picker("알림", selection:
$notificationMinutes) {
Text("없음").tag(nil as Int?)
Text("정시").tag(0 as Int?)
Text("10분 전").tag(10 as Int?)
...
}
5. Event 모델 확장
기존에 없던 category, notificationMinutes 추가
6. 프롬프트 작성
일정관리 앱을 이어서 개발할게.
그리고 변수이름 같은 경우에 한글 말고 영어로 작성해줘
추가할 기능:
[카테고리]
1. EventCategory enum을 만든다: work(업무), personal(개인), appointment(약속), other(기타)
2. 각 카테고리는 고유한 색상을 갖는다 (업무-파랑, 개인-초록, 약속-빨강, 기타-회색)
3. Event 모델에 category 필드를 추가한다
4. 추가/수정 화면에 카테고리 Picker를 넣는다
5. 목록에서 카테고리 색상이 표시된다 (아이콘이나 왼쪽 바)
6. 상세 화면에도 카테고리가 표시된다
[알림]
7. Event 모델에 notificationMinutes 필드를 추가한다 (Int?, nil이면 알림 없음)
8. 추가/수정 화면에 알림 Picker를 넣는다
9. 선택지: 없음, 정시, 10분 전, 30분 전, 1시간 전, 1일 전
10. 목록과 상세 화면에 알림 설정이 표시된다
전체 코드를 하나의 파일로 다시 작성해줘.
import SwiftUI
// MARK: - 카테고리 열거형
enum EventCategory: String, CaseIterable, Identifiable {
case work = "업무"
case personal = "개인"
case appointment = "약속"
case other = "기타"
var id: String { rawValue }
/// 카테고리별 고유 색상
var color: Color {
switch self {
case .work: return .blue
case .personal: return .green
case .appointment: return .red
case .other: return .gray
}
}
/// SF Symbol 아이콘
var icon: String {
switch self {
case .work: return "briefcase.fill"
case .personal: return "person.fill"
case .appointment: return "calendar.badge.clock"
case .other: return "tag.fill"
}
}
}
// MARK: - 알림 옵션
enum NotificationOption: Hashable, Identifiable {
case none
case atTime
case before(minutes: Int)
var id: String { label }
/// 화면에 표시할 텍스트
var label: String {
switch self {
case .none: return "없음"
case .atTime: return "정시"
case .before(let m) where m < 60: return "\(m)분 전"
case .before(let m) where m == 60: return "1시간 전"
case .before(let m): return "\(m / 60)시간 전"
}
}
/// Event에 저장할 분 값 (nil이면 알림 없음)
var minutes: Int? {
switch self {
case .none: return nil
case .atTime: return 0
case .before(let m): return m
}
}
/// 분 값으로 옵션 복원
static func from(minutes: Int?) -> NotificationOption {
guard let m = minutes else { return .none }
if m == 0 { return .atTime }
return .before(minutes: m)
}
/// 선택 가능한 모든 옵션
static let allOptions: [NotificationOption] = [
.none,
.atTime,
.before(minutes: 10),
.before(minutes: 30),
.before(minutes: 60),
.before(minutes: 1440) // 1일 = 1440분
]
}
// MARK: - 모델
struct Event: Identifiable {
let id = UUID()
var title: String
var date: Date
var memo: String
var isAllDay: Bool
var category: EventCategory
var notificationMinutes: Int? // nil이면 알림 없음
}
// MARK: - 샘플 데이터
extension Event {
static func makeSamples() -> [Event] {
let dayFmt = DateFormatter()
dayFmt.dateFormat = "yyyy-MM-dd"
let timeFmt = DateFormatter()
timeFmt.dateFormat = "yyyy-MM-dd HH:mm"
return [
Event(title: "팀 회의",
date: timeFmt.date(from: "2026-03-18 14:00")!,
memo: "분기 목표 점검",
isAllDay: false,
category: .work,
notificationMinutes: 10),
Event(title: "치과 예약",
date: timeFmt.date(from: "2026-03-20 10:30")!,
memo: "정기 검진",
isAllDay: false,
category: .appointment,
notificationMinutes: 30),
Event(title: "친구 생일 파티",
date: dayFmt.date(from: "2026-03-22")!,
memo: "선물 준비하기",
isAllDay: true,
category: .personal,
notificationMinutes: 1440),
Event(title: "프로젝트 마감",
date: timeFmt.date(from: "2026-03-25 18:00")!,
memo: "최종 보고서 제출",
isAllDay: false,
category: .work,
notificationMinutes: 60),
Event(title: "가족 저녁 식사",
date: dayFmt.date(from: "2026-03-30")!,
memo: "레스토랑 예약 완료",
isAllDay: true,
category: .personal,
notificationMinutes: nil),
]
}
}
// MARK: - 폼 모드 (추가 / 수정 구분)
enum FormMode {
case add
case edit(Event)
var navigationTitle: String {
switch self {
case .add: return "새 일정"
case .edit: return "일정 수정"
}
}
}
// MARK: - 날짜 표시 헬퍼
struct DateLabel: View {
let date: Date
let isAllDay: Bool
/// 한글 날짜 포맷터: x월 x일 (요일)
private var formatted: String {
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "ko_KR")
if isAllDay {
fmt.dateFormat = "M월 d일 (E) · 하루 종일"
} else {
fmt.dateFormat = "M월 d일 (E) HH:mm"
}
return fmt.string(from: date)
}
var body: some View {
Text(formatted)
}
}
// MARK: - 카테고리 뱃지
struct CategoryBadge: View {
let category: EventCategory
var body: some View {
Image(systemName: category.icon)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.frame(width: 30, height: 30)
.background(category.color, in: RoundedRectangle(cornerRadius: 7))
}
}
// MARK: - 알림 라벨
struct NotificationLabel: View {
let minutes: Int?
var body: some View {
let option = NotificationOption.from(minutes: minutes)
Label(option.label, systemImage: option.minutes != nil ? "bell.fill" : "bell.slash")
.font(.caption)
.foregroundColor(option.minutes != nil ? .orange : .secondary)
}
}
// MARK: - 목록 행
struct EventRow: View {
let event: Event
var body: some View {
HStack(spacing: 12) {
// 왼쪽 카테고리 색상 바
RoundedRectangle(cornerRadius: 3)
.fill(event.category.color)
.frame(width: 4, height: 44)
CategoryBadge(category: event.category)
VStack(alignment: .leading, spacing: 4) {
// 제목 + 카테고리 태그
HStack(spacing: 6) {
Text(event.title)
.font(.headline)
// 카테고리 색상으로 채워진 네모 태그
Text(event.category.rawValue)
.font(.caption2.bold())
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(event.category.color, in: RoundedRectangle(cornerRadius: 4))
}
HStack(spacing: 8) {
DateLabel(date: event.date, isAllDay: event.isAllDay)
.font(.subheadline)
.foregroundColor(.secondary)
NotificationLabel(minutes: event.notificationMinutes)
}
}
}
.padding(.vertical, 4)
}
}
// MARK: - 상세 화면
struct EventDetailView: View {
/// 부모의 일정 배열을 바인딩으로 받아 수정 결과를 반영
@Binding var events: [Event]
let eventID: UUID
@State private var showEditSheet = false
/// id로 현재 일정을 찾는 계산 속성
private var event: Event? {
events.first { $0.id == eventID }
}
var body: some View {
Group {
if let event {
List {
// 카테고리
Section("카테고리") {
HStack(spacing: 10) {
CategoryBadge(category: event.category)
Text(event.category.rawValue)
.font(.body)
}
}
// 제목
Section("제목") {
Text(event.title)
.font(.title3.bold())
}
// 날짜
Section("날짜") {
DateLabel(date: event.date, isAllDay: event.isAllDay)
}
// 알림
Section("알림") {
NotificationLabel(minutes: event.notificationMinutes)
}
// 메모
Section("메모") {
Text(event.memo.isEmpty ? "없음" : event.memo)
.foregroundColor(event.memo.isEmpty ? .secondary : .primary)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("수정") { showEditSheet = true }
}
}
.sheet(isPresented: $showEditSheet) {
// 수정 모드로 폼 열기
EventFormView(events: $events, mode: .edit(event))
}
} else {
Text("일정을 찾을 수 없습니다.")
.foregroundColor(.secondary)
}
}
.navigationTitle("일정 상세")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - 일정 폼 (추가 / 수정 공용)
struct EventFormView: View {
@Environment(\.dismiss) private var dismiss
/// 부모의 일정 배열 바인딩
@Binding var events: [Event]
/// 추가인지 수정인지 구분
let mode: FormMode
// 입력 상태
@State private var title = ""
@State private var date = Date()
@State private var memo = ""
@State private var isAllDay = false
@State private var category: EventCategory = .other
@State private var notificationOption: NotificationOption = .none
/// 제목이 비어있는지 확인
private var isTitleEmpty: Bool {
title.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationView {
Form {
// 제목
Section("제목") {
TextField("일정 제목을 입력하세요", text: $title)
}
// 카테고리
Section("카테고리") {
Picker("카테고리", selection: $category) {
ForEach(EventCategory.allCases) { cat in
Label(cat.rawValue, systemImage: cat.icon)
.tag(cat)
}
}
.pickerStyle(.menu)
}
// 날짜
Section("날짜") {
Toggle("하루 종일", isOn: $isAllDay)
DatePicker(
"날짜",
selection: $date,
displayedComponents: isAllDay ? [.date] : [.date, .hourAndMinute]
)
}
// 알림
Section("알림") {
Picker("알림", selection: $notificationOption) {
ForEach(NotificationOption.allOptions) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.menu)
}
// 메모
Section("메모") {
TextField("메모를 입력하세요", text: $memo, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle(mode.navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") {
save()
dismiss()
}
.disabled(isTitleEmpty)
}
}
.onAppear {
// 수정 모드일 때 기존 데이터를 폼에 채워넣기
if case .edit(let existing) = mode {
title = existing.title
date = existing.date
memo = existing.memo
isAllDay = existing.isAllDay
category = existing.category
notificationOption = NotificationOption.from(minutes: existing.notificationMinutes)
}
}
}
}
/// 모드에 따라 추가 또는 수정 처리
private func save() {
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
let trimmedMemo = memo.trimmingCharacters(in: .whitespaces)
switch mode {
case .add:
// 새 일정을 배열에 추가
let newEvent = Event(
title: trimmedTitle,
date: date,
memo: trimmedMemo,
isAllDay: isAllDay,
category: category,
notificationMinutes: notificationOption.minutes
)
events.append(newEvent)
case .edit(let existing):
// 기존 일정을 찾아서 값 덮어쓰기
if let index = events.firstIndex(where: { $0.id == existing.id }) {
events[index].title = trimmedTitle
events[index].date = date
events[index].memo = trimmedMemo
events[index].isAllDay = isAllDay
events[index].category = category
events[index].notificationMinutes = notificationOption.minutes
}
}
}
}
// MARK: - 목록 화면
struct ContentView: View {
@State private var events = Event.makeSamples()
@State private var showAddSheet = false
var body: some View {
NavigationView {
List {
ForEach(events) { event in
NavigationLink(
destination: EventDetailView(events: $events, eventID: event.id)
) {
EventRow(event: event)
}
}
// 왼쪽 스와이프 삭제
.onDelete(perform: deleteEvents)
}
.navigationTitle("내 일정")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showAddSheet = true }) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddSheet) {
// 추가 모드로 폼 열기
EventFormView(events: $events, mode: .add)
}
}
}
/// 스와이프 삭제 처리
private func deleteEvents(at offsets: IndexSet) {
events.remove(atOffsets: offsets)
}
}
// MARK: - 앱 진입점
@main
struct MyScheduleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

7. 실행 체크 (다 했나요?)
- [ㅇ] 카테고리를 선택해서 일정을 추가했다
- [ㅇ] 각 카테고리별로 일정을 1개씩 만들어봤다
- [ㅇ] 목록에서 카테고리 색상을 확인했다
- [ㅇ] 알림 시간을 설정해서 일정을 추가했다
- [ㅇ] 상세 화면에서 카테고리와 알림을 확인했다
반응형
'AI 해보기 > 딸깍! 일정관리 앱 만들어보기' 카테고리의 다른 글
| 7. 기간 필터와 정렬 (0) | 2026.03.20 |
|---|---|
| 6. 필터 추가해보기 (카테고리 필터링) (1) | 2026.03.20 |
| 4. 삭제와 수정 추가 (CRUD 완성해보기) (0) | 2026.03.20 |
| 3. 직접 새 일정 추가하기 (0) | 2026.03.20 |
| 2. 상세화면 만들기 (0) | 2026.03.20 |
댓글