希紋的部落格

APP範例:優雅筆記,原始碼

關連項目:

雖然直接給AI生成也能夠依照檔案名稱生出一個差不多的內容,不過沒有可以參考的指標就無法確定自己撰寫的內容是否正確,所以想想還是貼出來。

不上GitHub只是覺得這個專案的規模沒有大到需要這樣做。

已經包含了Checklist的擴充,可自行斟酌參考是否要拿掉

ElegantNotes

ElegantNotesApp.swift

import SwiftUI
import SwiftData

@main
struct ElegantNotesApp: App {
    //MARK: - values
    private let container: ModelContainer = {
        let schema = Schema([
            Note.self,
            Tag.self,
            ChecklistItem.self//[Checklist]新增項目
        ])// ← 告訴 SwiftData 有哪些 Model
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("無法建立 SwiftData 容器: \(error)")
        }
    }()
    //MARK: - scene
    var body: some Scene {
        WindowGroup {
            NotesGridView()
                .onAppear { preloadDefaultTagsIfNeeded() }
        }
        .modelContainer(container)// ← 注入到整個 App,任何子 View 都可取用
    }
    //MARK: - functions
    //預載預設資料
    //App 第一次啟動時,自動插入預設標籤和歡迎筆記
    //依照APP需求是否要在第一次啟動載入預設資料,以及載入的方式是否要用方便管理的方式呈現
    @MainActor
    private func preloadDefaultTagsIfNeeded() {
        let context = container.mainContext
        let descriptor = FetchDescriptor<Tag>()
        // 如果已有標籤,就不需要預載
        guard let count = try? context.fetchCount(descriptor), count == 0 else { return }
        
        let defaultTags = [
            Tag(name: "工作", colorHex: "#4A90E2"),
            Tag(name: "個人", colorHex: "#2ECC71"),
            // ...
        ]
        defaultTags.forEach { context.insert($0) }
        try? context.save()
    }
}

Models

ChecklistItem.swift

import SwiftData
//💡 @Model 讓 SwiftData 自動追蹤屬性變更。var note: Note? 是與 Note 的反向關聯(inverse),方便在刪除 Note 時一併清除項目。
@Model
final class ChecklistItem {
    var text: String
    var isChecked: Bool
    var order: Int

    // 反向關聯到所屬 Note
    var note: Note?

    init(text: String, isChecked: Bool = false, order: Int = 0) {
        self.text = text
        self.isChecked = isChecked
        self.order = order
    }
}

Note.swift

import Foundation
import SwiftData

@Model                              // ← 告訴 SwiftData 這是一個資料模型
final class Note {
    //MARK: - values
    @Attribute(.unique) var id: UUID  // ← .unique 確保 id 不重複
    var title: String
    var content: String
    var createdAt: Date
    var updatedAt: Date
    var colorHex: String              // ← 存莫蘭迪色 (#F2F2F7 格式)
    
    // 多對多關係:inverse 告訴 SwiftData Tag.notes 是反向關聯
    @Relationship(inverse: \Tag.notes)
    var tags: [Tag] = []
    
    // 計算屬性(不儲存到資料庫)
    var displayTitle: String {
        let t = title.trimmingCharacters(in: .whitespacesAndNewlines)
        return t.isEmpty ? "未命名筆記" : t
    }
    
    //預覽文字
    var previewText: String {
        let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmedContent.isEmpty ? "開始寫下你的想法。" : trimmedContent
    }
    
    // [Checklist]是否為清單模式
    var isChecklist: Bool = false
    // [Checklist]清單項目(刪除筆記時一併刪除)
    @Relationship(deleteRule: .cascade, inverse: \ChecklistItem.note)
    var checklistItems: [ChecklistItem] = []
    // [Checklist]已完成項目數(Computed Property)
    var completedCount: Int {
        checklistItems.filter { $0.isChecked }.count
    }
    //MARK: - init
    init(title: String = "", content: String = "", colorHex: String = "#F2F2F7") {
        self.id = UUID()
        self.title = title
        self.content = content
        self.createdAt = Date()
        self.updatedAt = Date()
        self.colorHex = colorHex
    }
    
}

Tag.swift

import Foundation
import SwiftData
// ⚠️ @Relationship(inverse:) 只需在其中一個 Model 寫,另一個留空陣列即可。兩邊都寫會報錯。
@Model
final class Tag {
    @Attribute(.unique) var id: UUID
    var name: String
    var colorHex: String
    
    // 反向關聯,SwiftData 自動維護
    var notes: [Note] = []
    
    init(name: String, colorHex: String = "#007AFF") {
        self.id = UUID()
        self.name = name
        self.colorHex = colorHex
    }
}

Utilities

Color+Hex.swift

因為原生的Color沒有用hex字串轉成顏色的功能,所以要自己寫一個

import SwiftUI

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 3: // RGB (12-bit)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32-bit)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (255, 1, 1, 1)
        }
        
        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue: Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
}

PreviewContainer.swift

import SwiftUI
import SwiftData

@MainActor
struct PreviewContainer {
    static let shared: ModelContainer = {
        let schema = Schema([Note.self,
                             Tag.self,
                             ChecklistItem.self//[Checklist]新增項目
                            ])
        let config = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: true   // ← 記憶體中,不寫入磁碟
        )
        do {// 插入假資料...
            let container = try ModelContainer(for: schema, configurations: [config])
            let context = container.mainContext
            // 預設標籤與莫蘭迪顏色
            let workTag = Tag(name: "工作", colorHex: "#4A90E2") // 經典藍
            let personalTag = Tag(name: "個人", colorHex: "#2ECC71") // 翠綠
            let ideaTag = Tag(name: "靈感", colorHex: "#E67E22") // 亮橘
            let urgentTag = Tag(name: "緊急", colorHex: "#E74C3C") // 珊瑚紅
            let thoughtTag = Tag(name: "想法", colorHex: "#9B59B6") // 經典紫
            
            context.insert(workTag)
            context.insert(personalTag)
            context.insert(ideaTag)
            context.insert(urgentTag)
            context.insert(thoughtTag)
            
            
            // 預設筆記與淡雅配色(莫蘭迪粉彩)
            let note1 = Note(
                title: "週一團隊週會",
                content: "1. 檢視上週 OKR 達成狀況。\n2. 討論新版 UI 設計稿。\n3. 分配本週開發任務,預計週五完成首個 Beta 版。",
                colorHex: "#E3F2FD" // 淡藍
            )
            note1.tags.append(workTag)
            note1.tags.append(urgentTag)
            //[Checklist]Checklist增加範例
            let note2 = Note(
                title: "週末超市採買清單",
                content: "",
                colorHex: "#E8F5E9" // 淡綠
            )
            note2.isChecklist = true
            note2.tags.append(personalTag)

            // 採買清單項目(有機雞蛋已標記為購買完成)
            let shoppingItems: [(String, Bool)] = [
                ("鮮奶 2 瓶", false),
                ("有機雞蛋 1 盒", true),
                ("燕麥片", false),
                ("雞胸肉與酪梨", false),
                ("氣泡水一箱", false)
            ]
            for (index, (text, checked)) in shoppingItems.enumerated() {
                let item = ChecklistItem(text: text, isChecked: checked, order: index)
                item.note = note2
                context.insert(item)
                note2.checklistItems.append(item)
            }
            // 💡 isStoredInMemoryOnly: true 的 PreviewContainer 每次 Preview 都是全新的資料,因此可以放心在 init 時直接插入假資料,不會影響正式資料庫。
            
            let note3 = Note(
                title: "ElegantNotes 開發構想",
                content: "1. 增加筆記 Widget,支援在桌面展示最新筆記。\n2. 支援 Markdown 渲染以提供更豐富的文字格式。\n3. iCloud 雲端同步功能。",
                colorHex: "#FFF3E0" // 淡橘
            )
            note3.tags.append(ideaTag)
            note3.tags.append(workTag)
            
            let note4 = Note(
                title: "讀書筆記:《原子習慣》",
                content: "「所有巨大的改變都源自微小的開始。」\n- 每天進步 1%,一年後會進步 37 倍。\n- 打造系統,而非單純設定目標。\n- 讓好習慣的提示顯而易見。",
                colorHex: "#F3E5F5" // 淡紫
            )
            note4.tags.append(personalTag)
            note4.tags.append(ideaTag)
            
            let note5 = Note(
                title: "極簡生活雜記",
                content: "生活越簡單,心靈越自由。減少不必要的物品,專注在真正重要的事情上。",
                colorHex: "#ECEFF1" // 淡灰
            )
            
            context.insert(note1)
            context.insert(note2)
            context.insert(note3)
            context.insert(note4)
            context.insert(note5)
            
            try? context.save()
            return container
        }
        catch {
            fatalError("無法建立 PreviewContainer: \(error)")
        }
    }()
}

Views

ChecklistEditorView.swift

import SwiftUI
import SwiftData

struct ChecklistEditorView: View {
    @Bindable var note: Note
    @Environment(\.modelContext) private var modelContext

    var sortedItems: [ChecklistItem] {
        note.checklistItems.sorted { $0.order < $1.order }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ForEach(sortedItems) { item in
                ChecklistItemRow(item: item) { deleteItem(item) }
                Divider().padding(.leading, 44)
            }
            // 新增項目按鈕
            Button(action: addItem) {
                HStack(spacing: 12) {
                    Image(systemName: "plus.circle.fill")
                        .font(.title3).foregroundColor(.secondary)
                    Text("新增項目").foregroundColor(.secondary)
                }
                .padding(.vertical, 8)
            }
            .buttonStyle(.plain)
        }
    }

    private func addItem() {
        let nextOrder = (note.checklistItems.map { $0.order }.max() ?? -1) + 1
        let item = ChecklistItem(text: "", order: nextOrder)
        item.note = note
        modelContext.insert(item)
        note.checklistItems.append(item)
    }

    private func deleteItem(_ item: ChecklistItem) {
        note.checklistItems.removeAll {
            $0.persistentModelID == item.persistentModelID
        }
        modelContext.delete(item)
    }
}

#Preview {
    let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
    ChecklistEditorView(note: note)
}

ChecklistItemRow.swift

/**
 💡 @Bindable 是 SwiftData 版本的 @Binding,讓 View 能雙向綁定 @Model 物件的屬性,任何修改都會自動寫入資料庫。
 💡 .contentTransition(.symbolEffect(.replace)) 是 iOS 17 新增的 SF Symbols 動畫,勾選時圖示會有流暢的切換效果。
 */
import SwiftUI

struct ChecklistItemRow: View {
    @Bindable var item: ChecklistItem
    let onDelete: () -> Void

    var body: some View {
        HStack(spacing: 12) {
            // 勾選按鈕:切換時播放 SF Symbol 動畫
            Button {
                withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
                    item.isChecked.toggle()
                }
            } label: {
                Image(systemName: item.isChecked ? "checkmark.circle.fill" : "circle")
                    .font(.title3)
                    .foregroundColor(item.isChecked ? .green : .secondary)
                    .contentTransition(.symbolEffect(.replace))
            }
            .buttonStyle(.plain)

            // 文字輸入:已完成時加刪除線
            TextField("輸入項目...", text: $item.text)
                .strikethrough(item.isChecked, color: .secondary)
                .foregroundColor(item.isChecked ? .secondary : .primary)
                .animation(.easeInOut(duration: 0.2), value: item.isChecked)

            // 刪除按鈕
            Button(action: onDelete) {
                Image(systemName: "xmark.circle.fill")
                    .foregroundColor(.secondary.opacity(0.4))
                    .font(.subheadline)
            }
            .buttonStyle(.plain)
        }
        .padding(.vertical, 6)
    }
}

#Preview {
    let tempItem = ChecklistItem(text: "test", isChecked: false)
    ChecklistItemRow(item: tempItem, onDelete: {})
}

NoteCardView.swift

import SwiftUI
import SwiftData

struct NoteCardView: View {// ← 遵守 View 協定
    //MARK: - values
    let note: Note// ← 外部傳入(不可變)
    //MARK: - view
    var body: some View {// ← 必須實作 body
        VStack(alignment: .leading, spacing: 12) {
            Text(note.displayTitle)   // 標題
            //[Checklist]💡 .prefix(4) 限制卡片最多顯示四個項目,避免卡片過長破壞網格排版。completedCount 是 Note 上的 computed property,即時反映勾選狀態。
            if note.isChecklist {
                let sortedItems = note.checklistItems.sorted { $0.order < $1.order }
                VStack(alignment: .leading, spacing: 5) {
                    ForEach(sortedItems.prefix(4)) { item in
                        HStack(spacing: 6) {
                            Image(systemName: item.isChecked
                                ? "checkmark.circle.fill" : "circle")
                                .font(.caption)
                                .foregroundColor(item.isChecked ? .green : .secondary)
                            Text(item.text.isEmpty ? "(空白項目)" : item.text)
                                .font(.subheadline)
                                .strikethrough(item.isChecked, color: .secondary)
                                .lineLimit(1)
                        }
                    }
                    if !sortedItems.isEmpty {
                        Text("\(note.completedCount)/\(sortedItems.count) 已完成")
                            .font(.caption2).foregroundColor(.secondary).padding(.top, 2)
                    }
                }
            } else {
                Text(note.previewText)    // 摘要
                    .font(.subheadline).foregroundColor(.secondary)
                    .lineLimit(4).multilineTextAlignment(.leading)
            }
            
            Spacer(minLength: 0)      // 彈性空間(推開下方元素)
            HStack { /* 標籤 */ }
            Text(formatDate(note.updatedAt))     // 日期
            
            // 顯示標籤列表
            ForEach(note.tags.sorted(by: { $0.name < $1.name })) { tag in
              Text(tag.name)
                .foregroundColor(Color(hex: tag.colorHex))
                .padding(.horizontal, 8)
                .background(Color(hex: tag.colorHex).opacity(0.12))
                .cornerRadius(8)
            }
            
        }
        .padding(16)                                    // 內邊距
        .frame(maxWidth: .infinity, minHeight: 140,
               alignment: .leading)                     // 填滿寬度、最小高度
        .background(Color(hex: note.colorHex))          // 背景色
        .cornerRadius(16)                               // 圓角
        .shadow(color: .black.opacity(0.06),
                radius: 5, x: 0, y: 3)                 // 陰影
        .overlay(                                       // 疊加一層微妙邊框
            RoundedRectangle(cornerRadius: 16)
                .stroke(Color.primary.opacity(0.05), lineWidth: 1)
        )
    }
    
    
    //MARK: - functions
    // 格式化日期
    //💡 private func 在 SwiftUI struct 中與 UIKit 的 private method 語義相同,只有本 struct 可呼叫。DateFormatter 不建議每次都重新建立,在小型 App 中影響不大,進階可考慮設為 static。
    private func formatDate(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm"
        return formatter.string(from: date)
    }
    
}

//MARK: - preview
//💡 #Preview 使用記憶體 container(PreviewContainer)而非實際資料庫,所以預覽的資料不會真的存檔。
#Preview {
    let container = PreviewContainer.shared   // 假資料容器
    let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
    return NoteCardView(note: note)
        .padding()
        .modelContainer(container)
}

NoteEditorView.swift

import SwiftUI
import SwiftData

struct NoteEditorView: View {
    //MARK: - values
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss  // ← 取得關閉此 View 的函式
    
    @Bindable var note: Note  // ← 讓 note 的屬性可以用 $ 綁定
    @State private var showTagSheet = false
    
    // 莫蘭迪粉彩配色清單
    // 備註:這個清單可以由其他方式統一掌控
    let noteColors = [
        "#F2F2F7", // 經典淺灰
        "#FADBD8", // 莫蘭迪淡粉
        "#D4E6F1", // 莫蘭迪淡藍
        "#D5F5E3", // 莫蘭迪淡綠
        "#FCF3CF", // 莫蘭迪淡黃
        "#EBDEF0", // 莫蘭迪淡紫
        "#F5CBA7"  // 莫蘭迪淡橘
    ]
    
    //MARK: - view
    var body: some View {
        ZStack {
            // 背景色 (配合選定的 Hex 色彩)
            Color(hex: note.colorHex)
                .ignoresSafeArea()
                .animation(.easeInOut(duration: 0.3), value: note.colorHex)
            
            VStack(spacing: 0) {
                // 1. 編輯區域
                mainZone
                // 2. 底部工具列 (配色選擇與標籤管理)
                bottomTools
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Button("完成") {
                    saveAndDismiss()
                }
                .fontWeight(.semibold)
                .accessibilityIdentifier("done-button")
            }
        }
        .sheet(isPresented: $showTagSheet) {
            TagSelectionView(note: note)
        }
        .onDisappear {
            saveNote()
        }
        
    }
    //主要編輯區域
    var mainZone: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                // 標題輸入框
                TextField("輸入標題...", text: $note.title)// ← $note.title 雙向綁定
                    .font(.title)
                    .fontWeight(.bold)
                    .foregroundColor(.primary)
                    .submitLabel(.next)
                    .padding(.top, 16)
                    .accessibilityIdentifier("note-title-field")
                
                // 關聯標籤展示列(如果有)
                if !note.tags.isEmpty {
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 8) {
                            ForEach(note.tags.sorted(by: { $0.name < $1.name })) { tag in
                                Text(tag.name)
                                    .font(.caption)
                                    .fontWeight(.medium)
                                    .foregroundColor(Color(hex: tag.colorHex))
                                    .padding(.horizontal, 10)
                                    .padding(.vertical, 5)
                                    .background(
                                        Color(hex: tag.colorHex).opacity(0.12)
                                    )
                                    .cornerRadius(8)
                            }
                        }
                    }
                }
                
                // [Checklist]內文區域:清單模式 vs 一般模式
                if note.isChecklist {
                    ChecklistEditorView(note: note)
                        .padding(.top, 4)
                } else {
                    // 內文區域
                    TextEditor(text: $note.content)
                        .font(.body)
                        .scrollContentBackground(.hidden)
                        .frame(minHeight: 400)
                        .foregroundColor(.primary)
                        .accessibilityIdentifier("note-body-editor")
                }
            }
            .padding(.horizontal, 20)
        }
    }
    
    var bottomTools: some View {
        VStack(spacing: 12) {
            Divider()
            
            HStack {
                // 色票選擇
                clolorSelecter
                
                Spacer()
                
                // [Checklist]清單模式切換按鈕
                Button(action: {
                    withAnimation(.easeInOut(duration: 0.2)) {
                        note.isChecklist.toggle()
                    }
                }) {
                    HStack(spacing: 6) {
                        Image(systemName: note.isChecklist
                            ? "checkmark.circle.fill" : "list.bullet")
                        Text(note.isChecklist ? "清單" : "文字")
                    }
                    .font(.subheadline).fontWeight(.medium)
                    .padding(.horizontal, 12).padding(.vertical, 8)
                    .background(note.isChecklist
                        ? Color.green.opacity(0.15) : Color.primary.opacity(0.06))
                    .foregroundColor(note.isChecklist ? .green : .primary)
                    .cornerRadius(10)
                }
                // 標籤設定按鈕
                Button(action: { showTagSheet = true }) {
                    HStack(spacing: 6) {
                        Image(systemName: "tag")
                        Text("標籤")
                    }
                    .font(.subheadline)
                    .fontWeight(.medium)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 8)
                    .background(Color.primary.opacity(0.06))
                    .cornerRadius(10)
                }
            }
            .padding(.horizontal, 20)
            .padding(.bottom, 8)
        }
        .background(Color(hex: note.colorHex).opacity(0.95))
    }
    //色票選擇器
    var clolorSelecter: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 12) {
                ForEach(noteColors, id: \.self) { hex in
                    Circle()
                        .fill(Color(hex: hex))
                        .frame(width: 28, height: 28)
                        .overlay(
                            Circle()
                                .stroke(Color.primary.opacity(0.6), lineWidth: note.colorHex == hex ? 2.5 : 0)
                        )
                        .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
                        .onTapGesture {
                            note.colorHex = hex
                            note.updatedAt = Date()
                        }
                }
            }
            .padding(.vertical, 4)
        }
    }
    //MARK: - functions
    // 儲存並返回
    private func saveAndDismiss() {
        saveNote()
        dismiss()
    }
    
    // 儲存筆記
    private func saveNote() {
        note.updatedAt = Date()
        try? modelContext.save()
    }
}

#Preview {
    let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
    NoteEditorView(note: note)
}

NotesGridView.swift

import SwiftUI
import SwiftData

struct NotesGridView: View {
    //MARK: - values
    // 取得 SwiftData 操作上下文(類比 CoreData 的 viewContext)
    @Environment(\.modelContext) private var modelContext
    
    // 自動從 SwiftData 查詢並訂閱更新
    //💡 @Query 是 SwiftData 的殺手功能:當資料庫有任何變動,@Query 自動更新,View 自動重繪。不需要像 CoreData 那樣手動監聽 NSFetchedResultsController。
    @Query(sort: \Note.updatedAt, order: .reverse) private var notes: [Note]
    @Query(sort: \Tag.name) private var tags: [Tag]
    
    // 本地 UI 狀態
    @State private var searchText = ""
    @State private var selectedTag: Tag? = nil
    @State private var path = NavigationPath()// 導航路徑(堆疊)
    
    // 雙欄網格設定
    let columns = [
        GridItem(.flexible(), spacing: 12),
        GridItem(.flexible(), spacing: 12)
    ]
    // 過濾後的筆記清單
    var filteredNotes: [Note] {
        notes.filter { note in
            let matchesSearch = searchText.isEmpty ||
            note.title.localizedCaseInsensitiveContains(searchText) ||
            note.content.localizedCaseInsensitiveContains(searchText)
            
            let matchesTag = selectedTag == nil || note.tags.contains(selectedTag!)
            
            return matchesSearch && matchesTag
        }
    }
    //MARK: - view
    var body: some View {
        NavigationStack(path: $path) {
            ZStack {
                Color(.systemGroupedBackground)
                    .ignoresSafeArea()
                // 主內容(標籤列 + 網格)
                VStack(spacing: 0) {
                    // 1. 標籤篩選列 (橫向滾動)
                    tagDisplay
                    //2.筆記網格或空白提示頁面
                    if filteredNotes.isEmpty {
                        //💡 用 if/else 在「有資料」與「空白提示」之間切換是 SwiftUI 的常見模式。搜尋時根據 searchText.isEmpty 顯示不同的提示文字,讓使用者知道是「沒資料」還是「搜尋無結果」。
                        noteEmpty
                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                    }
                    else {
                        noteGrid
                    }
                }
                // 3. 右下角浮動新增按鈕 (FAB)
                fabButton
            }
            .navigationTitle("優雅筆記")
            .searchable(text: $searchText, prompt: "搜尋標題或內容...")
            .navigationDestination(for: Note.self) { note in
                NoteEditorView(note: note)// 當 path 加入 Note 物件,導航到此
            }
        }
    }
    
    //筆記空畫面
    var noteEmpty: some View {
        VStack(spacing: 16) {
            Spacer()
            Image(systemName: "note.text.fuzzyfinder")
                .font(.system(size: 60))
                .foregroundColor(.secondary.opacity(0.6))
            Text(searchText.isEmpty
                 ? "還沒有任何筆記\n點擊右下角建立一篇吧!"
                 : "找不到符合關鍵字的筆記")
            .font(.body)
            .foregroundColor(.secondary)
            .multilineTextAlignment(.center)
            Spacer()
        }
    }
    //筆記網格
    var noteGrid: some View {
        ScrollView {
            //💡 Lazy 代表「延遲載入」,只有出現在螢幕上的 cell 才會被渲染,節省記憶體,類似 UICollectionView 的 cell 重用機制。
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(filteredNotes) { note in
                    NavigationLink(value: note) {   // 點擊 → 推入 note 到 path
                        NoteCardView(note: note)
                    }
                    .buttonStyle(PlainButtonStyle()) // 移除預設藍色樣式
                    .contextMenu {
                        Button(role: .destructive) {
                            deleteNote(note)
                        } label: {
                            Label("刪除筆記", systemImage: "trash")
                        }
                    }
                }//end foreach
            }
            .padding(16)
        }
    }
    //標籤展示
    var tagDisplay: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 8) {
                // "全部" 按鈕
                Button(action: {
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
                        selectedTag = nil
                    }
                }) {
                    Text("全部")
                        .font(.subheadline)
                        .fontWeight(.medium)
                        .padding(.horizontal, 16)
                        .padding(.vertical, 8)
                        .background(selectedTag == nil ? Color.primary : Color(.systemBackground))
                        .foregroundColor(selectedTag == nil ? Color(.systemBackground) : .primary)
                        .cornerRadius(20)
                        .shadow(color: Color.black.opacity(0.04), radius: 3, x: 0, y: 1)
                }
                
                // 各個自訂標籤
                ForEach(tags) { tag in
                    Button(action: {
                        withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
                            selectedTag = selectedTag == tag ? nil : tag
                        }
                    }) {
                        HStack(spacing: 6) {
                            Circle()
                                .fill(Color(hex: tag.colorHex))
                                .frame(width: 8, height: 8)
                            Text(tag.name)
                        }
                        .font(.subheadline)
                        .fontWeight(.medium)
                        .padding(.horizontal, 16)
                        .padding(.vertical, 8)
                        .background(selectedTag == tag ? Color(hex: tag.colorHex) : Color(.systemBackground))
                        .foregroundColor(selectedTag == tag ? .white : .primary)
                        .cornerRadius(20)
                        .shadow(color: Color.black.opacity(0.04), radius: 3, x: 0, y: 1)
                    }
                }
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
        }
        .background(Color(.systemBackground))
        .shadow(color: Color.black.opacity(0.02), radius: 5, x: 0, y: 3)
    }
    //FAB 按鈕 - 用 VStack/HStack Spacer 推到右下角
    var fabButton: some View {
        VStack {
            Spacer()
            HStack {
                Spacer()
                Button(action: createNewNote) {
                    Image(systemName: "plus")
                        .font(.title.bold())
                        .foregroundColor(.white)
                        .frame(width: 56, height: 56)
                        .background(
                            LinearGradient(
                                colors: [Color(hex: "#4A90E2"), Color(hex: "#50E3C2")],
                                startPoint: .topLeading,
                                endPoint: .bottomTrailing
                            )
                        )
                        .cornerRadius(28)
                        .shadow(color: Color(hex: "#4A90E2").opacity(0.4), radius: 8, x: 0, y: 4)
                }
                .padding(.trailing, 20)
                .padding(.bottom, 20)
                .accessibilityIdentifier("empty-add-note-button")
            }
        }
    }
    
    //MARK: - functions
    // 導航(類比 UINavigationController.pushViewController)
    // 新增筆記
    private func createNewNote() {
        let newNote = Note()
        modelContext.insert(newNote)
        path.append(newNote)  // 推入路徑 → 觸發 navigationDestination
    }
    
    // 刪除筆記
    private func deleteNote(_ note: Note) {
        withAnimation {
            modelContext.delete(note)
            try? modelContext.save()
        }
    }
}

#Preview {
    NotesGridView()
}

TagSelectionView.swift

/**
 TagSelectionView 是一個 Sheet,包含兩個區塊:
     • 新增標籤區:TextField + 顏色選擇器 + 確認按鈕
     • 標籤清單:List 顯示所有標籤,點擊切換與目前筆記的關聯
 */

import SwiftUI
import SwiftData

struct TagSelectionView: View {
    //MARK: - values
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    
    // 綁定的筆記,點選時會與此筆記關聯/取消關聯
    @Bindable var note: Note
    
    @Query(sort: \Tag.name) private var allTags: [Tag]
    
    @State private var newTagName = ""          // 新標籤名稱輸入
    @State private var selectedColorHex = "#4A90E2"  // 目前選中的顏色
    // 六個預設標籤顏色
    let tagColors = [
        "#4A90E2",  // 經典藍
        "#2ECC71",  // 翠綠
        "#E67E22",  // 亮橘
        "#E74C3C",  // 珊瑚紅
        "#9B59B6",  // 經典紫
        "#1ABC9C"   // 青綠
    ]
    
    
    //MARK: - view
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                // 1. 新增標籤區塊
                addNewTagView
                
                Divider()
                
                // 2. 標籤清單與點選關聯區
                tagListEdit
            }
            .navigationTitle("管理標籤")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("完成") {
                        dismiss()
                    }
                }
            }
        }
    }
    //新增標籤區塊
    var addNewTagView: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("新建標籤")
                .font(.subheadline)
                .fontWeight(.semibold)
                .foregroundColor(.secondary)
            
            HStack(spacing: 12) {
                TextField("標籤名稱...", text: $newTagName)
                    .padding(10)
                    .background(Color(.systemGray6))
                    .cornerRadius(8)
                
                Button(action: createTag) {
                    Image(systemName: "plus.circle.fill")
                        .font(.title2)
                        .foregroundColor(Color(hex: selectedColorHex))
                }
                .disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
            }
            
            // 標籤顏色選擇器
            HStack(spacing: 12) {
                ForEach(tagColors, id: \.self) { hex in
                    Circle()
                        .fill(Color(hex: hex))
                        .frame(width: 24, height: 24)
                        .overlay(
                            Circle()
                                .stroke(Color.primary, lineWidth: selectedColorHex == hex ? 2 : 0)
                        )
                        .onTapGesture {
                            withAnimation(.spring()) {
                                selectedColorHex = hex
                            }
                        }
                }
            }
            .padding(.top, 4)
        }
        .padding(.horizontal)
        .padding(.top)
    }
    //標籤清單與點選關聯區
    var tagListEdit: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("所有標籤 (點擊可加入/移出筆記)")
                .font(.subheadline)
                .fontWeight(.semibold)
                .foregroundColor(.secondary)
                .padding(.horizontal)
            
            if allTags.isEmpty {
                VStack(spacing: 12) {
                    Spacer()
                    Image(systemName: "tag.slash")
                        .font(.system(size: 40))
                        .foregroundColor(.secondary)
                    Text("目前還沒有標籤,在上方建立一個吧!")
                        .font(.footnote)
                        .foregroundColor(.secondary)
                    Spacer()
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else {
                List {
                    ForEach(allTags) { tag in
                        HStack {
                            // 標籤樣式
                            HStack {
                                Circle()
                                    .fill(Color(hex: tag.colorHex))
                                    .frame(width: 12, height: 12)
                                Text(tag.name)
                                    .foregroundColor(.primary)
                            }
                            
                            Spacer()
                            
                            // 關聯狀態勾選框
                            if note.tags.contains(tag) {
                                Image(systemName: "checkmark.circle.fill")
                                    .foregroundColor(Color(hex: tag.colorHex))
                            } else {
                                Image(systemName: "circle")
                                    .foregroundColor(.secondary)
                            }
                        }
                        .contentShape(Rectangle())
                        .onTapGesture {
                            toggleTag(tag)
                        }
                        .accessibilityIdentifier("tag-\(tag.name)")
                        .swipeActions(edge: .trailing) {
                            Button(role: .destructive) {
                                deleteTag(tag)
                            } label: {
                                Label("刪除", systemImage: "trash")
                            }
                        }
                    }
                }
                .listStyle(.plain)
            }
        }
    }
    
    //MARK: - functions
    // 新增標籤
    private func createTag() {
        let trimmedName = newTagName.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedName.isEmpty else { return }
        
        // 檢查是否已存在同名標籤
        if let existingTag = allTags.first(where: { $0.name.lowercased() == trimmedName.lowercased() }) {
            // 直接與目前筆記綁定
            if !note.tags.contains(existingTag) {
                note.tags.append(existingTag)
            }
        } else {
            let newTag = Tag(name: trimmedName, colorHex: selectedColorHex)
            modelContext.insert(newTag)
            note.tags.append(newTag)
        }
        
        newTagName = ""
        try? modelContext.save()
    }
    
    // 切換標籤關聯
    private func toggleTag(_ tag: Tag) {
        if let index = note.tags.firstIndex(of: tag) {
            note.tags.remove(at: index)
        } else {
            note.tags.append(tag)
        }
        try? modelContext.save()
    }
    
    // 徹底從資料庫刪除標籤
    private func deleteTag(_ tag: Tag) {
        modelContext.delete(tag)
        try? modelContext.save()
    }
}

#Preview {
    let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
    TagSelectionView(note: note)
}

總結

這些程式碼都可以在完全新建立ElegantNotes的SwiftUI專案中順利執行,這個做完之後也就幾乎不會看,但總有那麼一天突然想到要複習的時候就會拿出來練習。

#swift