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()
}
}