Section 17/181 menit

17. Real Use Cases

17. Real Use Cases

Use Case 1: Aplikasi Jurnal Harian dengan Mood Tracking

swift
// MARK: - Models

enum Mood: Int, Codable, CaseIterable {
    case awful = 1, bad, neutral, good, great

    var emoji: String {
        switch self {
        case .awful: "😢"; case .bad: "😞"; case .neutral: "😐"
        case .good: "😊"; case .great: "😁"
        }
    }

    var label: String {
        switch self {
        case .awful: "Buruk Sekali"; case .bad: "Buruk"; case .neutral: "Biasa"
        case .good: "Baik"; case .great: "Luar Biasa"
        }
    }
}

@Model
final class JournalEntry {
    @Attribute(.spotlight)
    var title: String

    var content: String
    var mood: Mood
    var date: Date
    var wordCount: Int

    @Relationship(deleteRule: .cascade, inverse: \JournalPhoto.entry)
    var photos: [JournalPhoto] = []

    @Relationship(inverse: \JournalTag.entries)
    var tags: [JournalTag] = []

    init(title: String, content: String, mood: Mood) {
        self.title = title
        self.content = content
        self.mood = mood
        self.date = Date()
        self.wordCount = content.split(separator: " ").count
    }
}

@Model
final class JournalPhoto {
    @Attribute(.externalStorage)
    var imageData: Data

    var caption: String?
    var takenAt: Date
    var entry: JournalEntry?

    init(imageData: Data, caption: String? = nil) {
        self.imageData = imageData
        self.caption = caption
        self.takenAt = Date()
    }
}

@Model
final class JournalTag {
    @Attribute(.unique)
    var name: String

    var colorHex: String

    @Relationship(inverse: \JournalEntry.tags)
    var entries: [JournalEntry] = []

    init(name: String, colorHex: String = "#007AFF") {
        self.name = name
        self.colorHex = colorHex
    }
}

// MARK: - Views

struct JournalHomeView: View {
    @Query(sort: \JournalEntry.date, order: .reverse)
    private var entries: [JournalEntry]

    @Query private var tags: [JournalTag]
    @Environment(\.modelContext) private var context
    @State private var showingAddEntry = false
    @State private var selectedMoodFilter: Mood?

    var body: some View {
        NavigationStack {
            Group {
                if entries.isEmpty {
                    ContentUnavailableView(
                        "Belum Ada Entri",
                        systemImage: "book.closed",
                        description: Text("Mulai tulis jurnal hari ini")
                    )
                } else {
                    FilteredJournalList(moodFilter: selectedMoodFilter)
                }
            }
            .navigationTitle("Jurnal Harian")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Tulis", systemImage: "square.and.pencil") {
                        showingAddEntry = true
                    }
                }
                ToolbarItem(placement: .secondaryAction) {
                    MoodFilterMenu(selected: $selectedMoodFilter)
                }
            }
            .sheet(isPresented: $showingAddEntry) {
                AddEntryView()
            }
        }
    }
}

struct FilteredJournalList: View {
    let moodFilter: Mood?
    @Query private var entries: [JournalEntry]

    init(moodFilter: Mood?) {
        self.moodFilter = moodFilter
        let predicate: Predicate<JournalEntry>?
        if let mood = moodFilter {
            predicate = #Predicate { $0.mood == mood }
        } else {
            predicate = nil
        }
        _entries = Query(
            filter: predicate,
            sort: [SortDescriptor(\JournalEntry.date, order: .reverse)]
        )
    }

    var body: some View {
        List(entries) { entry in
            NavigationLink(destination: EntryDetailView(entry: entry)) {
                JournalEntryRow(entry: entry)
            }
        }
    }
}

struct JournalEntryRow: View {
    let entry: JournalEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack {
                Text(entry.mood.emoji)
                Text(entry.title)
                    .font(.headline)
                Spacer()
                Text(entry.date, style: .date)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            if !entry.content.isEmpty {
                Text(entry.content)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
            }
        }
        .padding(.vertical, 4)
    }
}

// MARK: - Analytics Actor

@ModelActor
actor JournalAnalytics {
    func moodDistribution(for month: Date) async throws -> [Mood: Int] {
        let calendar = Calendar.current
        let start = calendar.startOfMonth(for: month)
        let end = calendar.endOfMonth(for: month)

        let descriptor = FetchDescriptor<JournalEntry>(
            predicate: #Predicate { $0.date >= start && $0.date <= end }
        )
        let entries = try modelContext.fetch(descriptor)

        return Dictionary(grouping: entries, by: \.mood)
            .mapValues(\.count)
    }

    func averageWordsPerEntry() async throws -> Double {
        let total = try modelContext.fetchCount(FetchDescriptor<JournalEntry>())
        guard total > 0 else { return 0 }

        let entries = try modelContext.fetch(FetchDescriptor<JournalEntry>())
        let totalWords = entries.reduce(0) { $0 + $1.wordCount }
        return Double(totalWords) / Double(total)
    }
}

Use Case 2: Offline-First Task Manager dengan Sync Status

swift
// MARK: - Models

enum SyncStatus: String, Codable {
    case pendingUpload = "pending_upload"
    case synced = "synced"
    case pendingDelete = "pending_delete"
    case conflict = "conflict"
}

@Model
final class Task {
    @Attribute(.unique)
    var localID: UUID

    var serverID: String?
    var title: String
    var description: String
    var isCompleted: Bool
    var priority: Priority
    var dueDate: Date?
    var createdAt: Date
    var modifiedAt: Date
    var syncStatus: SyncStatus

    @Relationship(deleteRule: .nullify, inverse: \TaskList.tasks)
    var taskList: TaskList?

    init(title: String, description: String = "", priority: Priority = .medium,
         dueDate: Date? = nil) {
        self.localID = UUID()
        self.title = title
        self.description = description
        self.isCompleted = false
        self.priority = priority
        self.dueDate = dueDate
        self.createdAt = Date()
        self.modifiedAt = Date()
        self.syncStatus = .pendingUpload
    }

    func markModified() {
        modifiedAt = Date()
        if syncStatus == .synced {
            syncStatus = .pendingUpload
        }
    }
}

@Model
final class TaskList {
    @Attribute(.unique)
    var id: UUID

    var name: String
    var iconName: String
    var colorHex: String

    @Relationship(deleteRule: .cascade, inverse: \Task.taskList)
    var tasks: [Task] = []

    var pendingTaskCount: Int {
        tasks.filter { !$0.isCompleted }.count
    }

    init(name: String, iconName: String = "list.bullet", colorHex: String = "#007AFF") {
        self.id = UUID()
        self.name = name
        self.iconName = iconName
        self.colorHex = colorHex
    }
}

// MARK: - Sync Background Actor

@ModelActor
actor TaskSyncEngine {
    private var isSyncing = false

    func syncPendingTasks(apiClient: APIClient) async throws -> SyncResult {
        guard !isSyncing else { return .alreadySyncing }
        isSyncing = true
        defer { isSyncing = false }

        // 1. Upload tasks baru/dimodifikasi
        let uploadDescriptor = FetchDescriptor<Task>(
            predicate: #Predicate { $0.syncStatus == SyncStatus.pendingUpload }
        )
        let toUpload = try modelContext.fetch(uploadDescriptor)

        for task in toUpload {
            do {
                let serverID = try await apiClient.uploadTask(
                    localID: task.localID,
                    title: task.title,
                    isCompleted: task.isCompleted,
                    priority: task.priority.rawValue
                )
                task.serverID = serverID
                task.syncStatus = .synced
            } catch APIError.conflict {
                task.syncStatus = .conflict
            }
        }

        // 2. Hapus tasks yang ditandai untuk dihapus
        let deleteDescriptor = FetchDescriptor<Task>(
            predicate: #Predicate { $0.syncStatus == SyncStatus.pendingDelete }
        )
        let toDelete = try modelContext.fetch(deleteDescriptor)

        for task in toDelete {
            if let serverID = task.serverID {
                try? await apiClient.deleteTask(serverID: serverID)
            }
            modelContext.delete(task)
        }

        try modelContext.save()

        let conflicts = try modelContext.fetchCount(
            FetchDescriptor<Task>(predicate: #Predicate { $0.syncStatus == SyncStatus.conflict })
        )

        return .success(
            uploaded: toUpload.count,
            deleted: toDelete.count,
            conflicts: conflicts
        )
    }

    func resolveConflict(taskID: PersistentIdentifier, keepLocal: Bool) async throws {
        guard let task = self[taskID, as: Task.self] else { return }
        task.syncStatus = keepLocal ? .pendingUpload : .synced
        try modelContext.save()
    }
}

enum SyncResult {
    case alreadySyncing
    case success(uploaded: Int, deleted: Int, conflicts: Int)
}

// MARK: - SwiftUI Integration

struct TaskManagerApp: App {
    var body: some Scene {
        WindowGroup {
            TaskListsView()
        }
        .modelContainer(for: [Task.self, TaskList.self])
    }
}

struct TaskListsView: View {
    @Query(sort: \TaskList.name) private var lists: [TaskList]
    @Environment(\.modelContext) private var context
    @State private var showingSyncStatus = false

    var body: some View {
        NavigationStack {
            List(lists) { list in
                NavigationLink(destination: TasksInListView(list: list)) {
                    TaskListRow(list: list)
                }
            }
            .navigationTitle("Daftar Tugas")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Sinkronisasi", systemImage: "arrow.triangle.2.circlepath") {
                        showingSyncStatus = true
                    }
                }
            }
            .overlay(alignment: .bottom) {
                PendingSyncBanner()
            }
        }
    }
}

struct PendingSyncBanner: View {
    @Query(filter: #Predicate<Task> { $0.syncStatus != SyncStatus.synced })
    private var pendingTasks: [Task]

    var body: some View {
        if !pendingTasks.isEmpty {
            Label("\(pendingTasks.count) tugas menunggu sinkronisasi", systemImage: "arrow.up.circle")
                .padding()
                .background(.regularMaterial, in: .capsule)
                .padding()
        }
    }
}

// MARK: - Placeholder Types (untuk kompilasi)
class APIClient {
    func uploadTask(localID: UUID, title: String, isCompleted: Bool, priority: Int) async throws -> String { "" }
    func deleteTask(serverID: String) async throws {}
}

enum APIError: Error { case conflict }
enum Priority: Int, Codable { case low = 0, medium = 1, high = 2 }

struct TaskListRow: View {
    let list: TaskList
    var body: some View { Text(list.name) }
}

struct TasksInListView: View {
    let list: TaskList
    var body: some View { Text(list.name) }
}

Use Case 3: Reading Progress Tracker dengan Statistics

swift
// MARK: - Models

@Model
final class ReadingSession {
    var startTime: Date
    var endTime: Date?
    var pagesRead: Int
    var startPage: Int
    var endPage: Int
    var notes: String

    var duration: TimeInterval {
        (endTime ?? Date()).timeIntervalSince(startTime)
    }

    var book: Book?

    init(book: Book, startPage: Int) {
        self.startTime = Date()
        self.startPage = startPage
        self.endPage = startPage
        self.pagesRead = 0
        self.notes = ""
        self.book = book
    }

    func finish(at endPage: Int) {
        self.endTime = Date()
        self.endPage = endPage
        self.pagesRead = max(0, endPage - startPage)
    }
}

@Model
final class Book {
    @Attribute(.spotlight)
    var title: String

    var author: String
    var totalPages: Int
    var currentPage: Int
    var publishedYear: Int
    var isRead: Bool
    var addedAt: Date

    @Relationship(deleteRule: .cascade, inverse: \ReadingSession.book)
    var readingSessions: [ReadingSession] = []

    var readingProgress: Double {
        guard totalPages > 0 else { return 0 }
        return Double(currentPage) / Double(totalPages)
    }

    var totalReadingTime: TimeInterval {
        readingSessions.compactMap(\.endTime).enumerated().reduce(0) { acc, pair in
            acc + readingSessions[pair.offset].duration
        }
    }

    var totalPagesRead: Int {
        readingSessions.reduce(0) { $0 + $1.pagesRead }
    }

    init(title: String, author: String, totalPages: Int, publishedYear: Int) {
        self.title = title
        self.author = author
        self.totalPages = totalPages
        self.currentPage = 0
        self.publishedYear = publishedYear
        self.isRead = false
        self.addedAt = Date()
    }
}

// MARK: - Statistics Actor

@ModelActor
actor ReadingStatisticsCalculator {
    struct MonthlyStats {
        let month: Date
        let booksStarted: Int
        let booksFinished: Int
        let totalPagesRead: Int
        let totalReadingMinutes: Int
    }

    func calculateMonthlyStats(for year: Int) async throws -> [MonthlyStats] {
        var stats: [MonthlyStats] = []
        let calendar = Calendar.current

        for month in 1...12 {
            var components = DateComponents(year: year, month: month)
            guard let start = calendar.date(from: components) else { continue }
            components.month = month + 1
            guard let end = calendar.date(from: components) else { continue }

            let sessionsDescriptor = FetchDescriptor<ReadingSession>(
                predicate: #Predicate { $0.startTime >= start && $0.startTime < end }
            )
            let sessions = try modelContext.fetch(sessionsDescriptor)

            let totalPages = sessions.reduce(0) { $0 + $1.pagesRead }
            let totalMinutes = Int(sessions.reduce(0) { $0 + $1.duration } / 60)

            let finishedBooksDescriptor = FetchDescriptor<Book>(
                predicate: #Predicate {
                    $0.isRead && $0.addedAt >= start && $0.addedAt < end
                }
            )

            stats.append(MonthlyStats(
                month: start,
                booksStarted: 0,
                booksFinished: try modelContext.fetchCount(finishedBooksDescriptor),
                totalPagesRead: totalPages,
                totalReadingMinutes: totalMinutes
            ))
        }

        return stats
    }

    func averageReadingSpeedPagesPerHour() async throws -> Double {
        let sessions = try modelContext.fetch(FetchDescriptor<ReadingSession>())
        let completedSessions = sessions.filter { $0.endTime != nil && $0.pagesRead > 0 }
        guard !completedSessions.isEmpty else { return 0 }

        let totalPages = completedSessions.reduce(0) { $0 + $1.pagesRead }
        let totalHours = completedSessions.reduce(0) { $0 + $1.duration } / 3600
        return Double(totalPages) / totalHours
    }
}

// MARK: - Active Reading View

struct ActiveReadingView: View {
    @Bindable var book: Book
    @Environment(\.modelContext) private var context
    @State private var activeSession: ReadingSession?
    @State private var currentPageInput: String = ""

    var body: some View {
        VStack(spacing: 20) {
            ProgressView(value: book.readingProgress)
                .progressViewStyle(.linear)
                .padding()

            Text("Halaman \(book.currentPage) dari \(book.totalPages)")
                .font(.headline)

            Text("\(Int(book.readingProgress * 100))% selesai")
                .foregroundStyle(.secondary)

            if let session = activeSession {
                VStack {
                    Label("Sesi aktif: \(session.startTime, style: .relative)",
                          systemImage: "clock.fill")
                        .foregroundStyle(.green)

                    TextField("Halaman saat ini", text: $currentPageInput)
                        .keyboardType(.numberPad)
                        .textFieldStyle(.roundedBorder)

                    Button("Selesai Sesi") {
                        finishSession(session)
                    }
                    .buttonStyle(.borderedProminent)
                }
            } else {
                Button("Mulai Sesi Membaca") {
                    startSession()
                }
                .buttonStyle(.bordered)
            }
        }
        .navigationTitle(book.title)
        .onAppear {
            currentPageInput = "\(book.currentPage)"
        }
    }

    private func startSession() {
        let session = ReadingSession(book: book, startPage: book.currentPage)
        context.insert(session)
        activeSession = session
        try? context.save()
    }

    private func finishSession(_ session: ReadingSession) {
        let endPage = Int(currentPageInput) ?? book.currentPage
        session.finish(at: endPage)
        book.currentPage = endPage
        if endPage >= book.totalPages {
            book.isRead = true
        }
        activeSession = nil
        try? context.save()
    }
}