Files
2025-11-29 18:28:37 +08:00

13 KiB

Shoebox/Library Apps

Apps with internal database and sidebar navigation (like Notes, Photos, Music).

<when_to_use> Use shoebox pattern when:

  • Single library of items (not separate files)
  • No explicit save (auto-save everything)
  • Import/export rather than open/save
  • Sidebar navigation (folders, tags, smart folders)
  • iCloud sync across devices

Do NOT use when:

  • Users need to manage individual files
  • Files shared with other apps directly </when_to_use>

<basic_structure>

@main
struct LibraryApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Note.self, Folder.self, Tag.self])
        .commands {
            LibraryCommands()
        }
    }
}

struct ContentView: View {
    @State private var selectedFolder: Folder?
    @State private var selectedNote: Note?
    @State private var searchText = ""

    var body: some View {
        NavigationSplitView {
            SidebarView(selection: $selectedFolder)
        } content: {
            NoteListView(folder: selectedFolder, selection: $selectedNote)
        } detail: {
            if let note = selectedNote {
                NoteEditorView(note: note)
            } else {
                ContentUnavailableView("Select a Note", systemImage: "note.text")
            }
        }
        .searchable(text: $searchText)
    }
}

</basic_structure>

<data_model>

import SwiftData

@Model
class Note {
    var title: String
    var content: String
    var createdAt: Date
    var modifiedAt: Date
    var isPinned: Bool

    @Relationship(inverse: \Folder.notes)
    var folder: Folder?

    @Relationship
    var tags: [Tag]

    init(title: String = "New Note") {
        self.title = title
        self.content = ""
        self.createdAt = Date()
        self.modifiedAt = Date()
        self.isPinned = false
        self.tags = []
    }
}

@Model
class Folder {
    var name: String
    var icon: String
    var sortOrder: Int

    @Relationship(deleteRule: .cascade)
    var notes: [Note]

    var isSmartFolder: Bool
    var predicate: String?  // For smart folders

    init(name: String, icon: String = "folder") {
        self.name = name
        self.icon = icon
        self.sortOrder = 0
        self.notes = []
        self.isSmartFolder = false
    }
}

@Model
class Tag {
    var name: String
    var color: String

    @Relationship(inverse: \Note.tags)
    var notes: [Note]

    init(name: String, color: String = "blue") {
        self.name = name
        self.color = color
        self.notes = []
    }
}

</data_model>

```swift struct SidebarView: View { @Environment(\.modelContext) private var context @Query(sort: \Folder.sortOrder) private var folders: [Folder] @Binding var selection: Folder?
var body: some View {
    List(selection: $selection) {
        Section("Library") {
            Label("All Notes", systemImage: "note.text")
                .tag(nil as Folder?)

            Label("Recently Deleted", systemImage: "trash")
        }

        Section("Folders") {
            ForEach(folders.filter { !$0.isSmartFolder }) { folder in
                Label(folder.name, systemImage: folder.icon)
                    .tag(folder as Folder?)
                    .contextMenu {
                        Button("Rename") { renameFolder(folder) }
                        Button("Delete", role: .destructive) { deleteFolder(folder) }
                    }
            }
            .onMove(perform: moveFolders)
        }

        Section("Smart Folders") {
            ForEach(folders.filter { $0.isSmartFolder }) { folder in
                Label(folder.name, systemImage: "folder.badge.gearshape")
                    .tag(folder as Folder?)
            }
        }

        Section("Tags") {
            TagsSection()
        }
    }
    .listStyle(.sidebar)
    .toolbar {
        ToolbarItem {
            Button(action: addFolder) {
                Label("New Folder", systemImage: "folder.badge.plus")
            }
        }
    }
}

private func addFolder() {
    let folder = Folder(name: "New Folder")
    folder.sortOrder = folders.count
    context.insert(folder)
}

private func deleteFolder(_ folder: Folder) {
    context.delete(folder)
}

private func moveFolders(from source: IndexSet, to destination: Int) {
    var reordered = folders.filter { !$0.isSmartFolder }
    reordered.move(fromOffsets: source, toOffset: destination)
    for (index, folder) in reordered.enumerated() {
        folder.sortOrder = index
    }
}

}

</sidebar>

<note_list>
```swift
struct NoteListView: View {
    let folder: Folder?
    @Binding var selection: Note?

    @Environment(\.modelContext) private var context
    @Query private var allNotes: [Note]

    var filteredNotes: [Note] {
        let sorted = allNotes.sorted {
            if $0.isPinned != $1.isPinned {
                return $0.isPinned
            }
            return $0.modifiedAt > $1.modifiedAt
        }

        if let folder = folder {
            return sorted.filter { $0.folder == folder }
        }
        return sorted
    }

    var body: some View {
        List(filteredNotes, selection: $selection) { note in
            NoteRow(note: note)
                .tag(note)
                .contextMenu {
                    Button(note.isPinned ? "Unpin" : "Pin") {
                        note.isPinned.toggle()
                    }
                    Divider()
                    Button("Delete", role: .destructive) {
                        context.delete(note)
                    }
                }
        }
        .toolbar {
            ToolbarItem {
                Button(action: addNote) {
                    Label("New Note", systemImage: "square.and.pencil")
                }
            }
        }
    }

    private func addNote() {
        let note = Note()
        note.folder = folder
        context.insert(note)
        selection = note
    }
}

struct NoteRow: View {
    let note: Note

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack {
                if note.isPinned {
                    Image(systemName: "pin.fill")
                        .foregroundStyle(.orange)
                        .font(.caption)
                }
                Text(note.title.isEmpty ? "New Note" : note.title)
                    .fontWeight(.medium)
            }

            Text(note.modifiedAt.formatted(date: .abbreviated, time: .shortened))
                .font(.caption)
                .foregroundStyle(.secondary)

            Text(note.content.prefix(100))
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(2)
        }
        .padding(.vertical, 4)
    }
}

</note_list>

```swift struct NoteEditorView: View { @Bindable var note: Note @FocusState private var isFocused: Bool
var body: some View {
    VStack(spacing: 0) {
        // Title
        TextField("Title", text: $note.title)
            .textFieldStyle(.plain)
            .font(.title)
            .padding()

        Divider()

        // Content
        TextEditor(text: $note.content)
            .font(.body)
            .focused($isFocused)
            .padding()
    }
    .onChange(of: note.title) { _, _ in
        note.modifiedAt = Date()
    }
    .onChange(of: note.content) { _, _ in
        note.modifiedAt = Date()
    }
    .toolbar {
        ToolbarItem {
            Menu {
                TagPickerMenu(note: note)
            } label: {
                Label("Tags", systemImage: "tag")
            }
        }

        ToolbarItem {
            ShareLink(item: note.content)
        }
    }
}

}

</editor>

<smart_folders>
```swift
struct SmartFolderSetup {
    static func createDefaultSmartFolders(context: ModelContext) {
        // Today
        let today = Folder(name: "Today", icon: "calendar")
        today.isSmartFolder = true
        today.predicate = "modifiedAt >= startOfToday"
        context.insert(today)

        // This Week
        let week = Folder(name: "This Week", icon: "calendar.badge.clock")
        week.isSmartFolder = true
        week.predicate = "modifiedAt >= startOfWeek"
        context.insert(week)

        // Pinned
        let pinned = Folder(name: "Pinned", icon: "pin")
        pinned.isSmartFolder = true
        pinned.predicate = "isPinned == true"
        context.insert(pinned)
    }
}

// Query based on smart folder predicate
func notesForSmartFolder(_ folder: Folder) -> [Note] {
    switch folder.predicate {
    case "isPinned == true":
        return allNotes.filter { $0.isPinned }
    case "modifiedAt >= startOfToday":
        let start = Calendar.current.startOfDay(for: Date())
        return allNotes.filter { $0.modifiedAt >= start }
    default:
        return []
    }
}

</smart_folders>

<import_export>

struct LibraryCommands: Commands {
    @Environment(\.modelContext) private var context

    var body: some Commands {
        CommandGroup(after: .importExport) {
            Button("Import Notes...") {
                importNotes()
            }
            .keyboardShortcut("i", modifiers: [.command, .shift])

            Button("Export All Notes...") {
                exportNotes()
            }
            .keyboardShortcut("e", modifiers: [.command, .shift])
        }
    }

    private func importNotes() {
        let panel = NSOpenPanel()
        panel.allowedContentTypes = [.json, .plainText]
        panel.allowsMultipleSelection = true

        if panel.runModal() == .OK {
            for url in panel.urls {
                importFile(url)
            }
        }
    }

    private func exportNotes() {
        let panel = NSSavePanel()
        panel.allowedContentTypes = [.json]
        panel.nameFieldStringValue = "Notes Export.json"

        if panel.runModal() == .OK, let url = panel.url {
            let descriptor = FetchDescriptor<Note>()
            if let notes = try? context.fetch(descriptor) {
                let exportData = notes.map { NoteExport(note: $0) }
                if let data = try? JSONEncoder().encode(exportData) {
                    try? data.write(to: url)
                }
            }
        }
    }
}

struct NoteExport: Codable {
    let title: String
    let content: String
    let createdAt: Date
    let modifiedAt: Date

    init(note: Note) {
        self.title = note.title
        self.content = note.content
        self.createdAt = note.createdAt
        self.modifiedAt = note.modifiedAt
    }
}

</import_export>

```swift struct ContentView: View { @State private var searchText = "" @Query private var allNotes: [Note]
var searchResults: [Note] {
    if searchText.isEmpty {
        return []
    }
    return allNotes.filter { note in
        note.title.localizedCaseInsensitiveContains(searchText) ||
        note.content.localizedCaseInsensitiveContains(searchText)
    }
}

var body: some View {
    NavigationSplitView {
        // ...
    }
    .searchable(text: $searchText, placement: .toolbar)
    .searchSuggestions {
        if !searchText.isEmpty {
            ForEach(searchResults.prefix(5)) { note in
                Button {
                    selectedNote = note
                } label: {
                    VStack(alignment: .leading) {
                        Text(note.title)
                        Text(note.modifiedAt.formatted())
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }
            }
        }
    }
}

}

</search>

<icloud_sync>
```swift
// Configure container for iCloud
@main
struct LibraryApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([Note.self, Folder.self, Tag.self])
        let config = ModelConfiguration(
            "Library",
            schema: schema,
            cloudKitDatabase: .automatic
        )

        do {
            container = try ModelContainer(for: schema, configurations: config)
        } catch {
            fatalError("Failed to create container: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

// Handle sync status
struct SyncStatusIndicator: View {
    @State private var isSyncing = false

    var body: some View {
        if isSyncing {
            ProgressView()
                .scaleEffect(0.5)
        } else {
            Image(systemName: "checkmark.icloud")
                .foregroundStyle(.green)
        }
    }
}

</icloud_sync>

<best_practices>

  • Auto-save on every change (no explicit save)
  • Provide import/export for data portability
  • Use sidebar for navigation (folders, tags, smart folders)
  • Support search across all content
  • Show modification dates, not explicit "save"
  • Use SwiftData with iCloud for seamless sync
  • Provide trash/restore instead of permanent delete </best_practices>