본문 바로가기
AI 해보기/딸깍! 일정관리 앱 만들어보기

5. 카테고리와 알림

by yoondoo 2026. 3. 20.
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개씩 만들어봤다
  • [ㅇ] 목록에서 카테고리 색상을 확인했다
  • [ㅇ] 알림 시간을 설정해서 일정을 추가했다
  • [ㅇ] 상세 화면에서 카테고리와 알림을 확인했다

 

반응형

댓글