← Назад к вопросам

Приложение со списком задач (Todo)

1.6 Junior🔥 291 комментариев
#UIKit и верстка#Архитектура и паттерны#Хранение данных

Условие

Разработайте iOS приложение для управления списком задач.

Функциональные требования:

  • Создание, редактирование и удаление задач
  • Отметка задачи как выполненной
  • Возможность добавлять картинки к задачам
  • Локальное хранение данных (задачи сохраняются после закрытия приложения)

Технические требования:

  • Core Data или Realm для хранения
  • UITableView или UICollectionView (или LazyVStack в SwiftUI)
  • Возможность выбора изображения из галереи
  • Архитектура: MVC, MVVM или VIPER

Бонус:

  • Категории задач
  • Установка даты и напоминания
  • Drag & drop для изменения порядка
  • Поиск по задачам
  • Анимации при добавлении/удалении

Типичное тестовое задание для Junior iOS позиции.

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение

Архитектура проекта (MVVM + Core Data)

TodoApp/
├── Core/
│   ├── CoreData/
│   │   ├── CoreDataStack.swift
│   │   ├── TaskEntity+CoreDataClass.swift
│   │   ├── TaskEntity+CoreDataProperties.swift
│   │   └── Todo.xcdatamodeld
│   ├── Services/
│   │   ├── TaskService.swift
│   │   └── ImageService.swift
├── Models/
│   ├── Task.swift
│   └── Category.swift
├── ViewModels/
│   ├── TaskListViewModel.swift
│   ├── TaskDetailViewModel.swift
│   └── TaskCell.swift
├── Views/
│   ├── TaskListViewController.swift
│   ├── TaskDetailViewController.swift
│   ├── TaskCell.swift
│   └── CategoryCell.swift
└── Resources/
    └── Todo.xcdatamodeld

Core Data Setup

Core/CoreData/CoreDataStack.swift:

import CoreData

final class CoreDataStack {
    static let shared = CoreDataStack()
    
    private let modelName = "Todo"
    
    private(set) lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: modelName)
        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                print("Core Data error: \(error), \(error.userInfo)")
                fatalError("Failed to load Core Data stack")
            }
        }
        return container
    }()
    
    var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    func save() {
        let context = viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let nsError = error as NSError
                print("Save error: \(nsError), \(nsError.userInfo)")
            }
        }
    }
    
    func deleteAll(entity: String) {
        let request = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
        let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
        
        do {
            try viewContext.execute(deleteRequest)
            try viewContext.save()
        } catch {
            print("Delete all error: \(error)")
        }
    }
}

Core Data Model в коде

Core/CoreData/TaskEntity+CoreDataProperties.swift:

import Foundation
import CoreData

extension TaskEntity {
    @NSManaged public var id: UUID
    @NSManaged public var title: String
    @NSManaged public var descriptionText: String?
    @NSManaged public var isCompleted: Bool
    @NSManaged public var imageData: Data?  // JPEG encoded
    @NSManaged public var category: String  // "work", "personal", "shopping"
    @NSManaged public var dueDate: Date?
    @NSManaged public var priority: Int16  // 0 = low, 1 = medium, 2 = high
    @NSManaged public var order: Int32  // для drag & drop
    @NSManaged public var createdAt: Date
    @NSManaged public var updatedAt: Date
}

extension TaskEntity: Identifiable {
    public var wrappedTitle: String {
        title ?? "Unknown Task"
    }
    
    public var wrappedDescription: String {
        descriptionText ?? ""
    }
}

Domain Model

Models/Task.swift:

import Foundation
import UIKit

struct Task: Identifiable {
    let id: UUID
    var title: String
    var description: String
    var isCompleted: Bool
    var image: UIImage?
    var category: TaskCategory
    var dueDate: Date?
    var priority: TaskPriority
    var createdAt: Date
    var updatedAt: Date
}

enum TaskCategory: String, CaseIterable {
    case work = "work"
    case personal = "personal"
    case shopping = "shopping"
    case health = "health"
    
    var displayName: String {
        switch self {
        case .work: return "Work"
        case .personal: return "Personal"
        case .shopping: return "Shopping"
        case .health: return "Health"
        }
    }
    
    var color: UIColor {
        switch self {
        case .work: return .systemBlue
        case .personal: return .systemPurple
        case .shopping: return .systemGreen
        case .health: return .systemRed
        }
    }
}

enum TaskPriority: Int16, CaseIterable {
    case low = 0
    case medium = 1
    case high = 2
    
    var displayName: String {
        switch self {
        case .low: return "Low"
        case .medium: return "Medium"
        case .high: return "High"
        }
    }
}

Task Service (Repository)

Core/Services/TaskService.swift:

import CoreData
import UIKit

protocol TaskServiceProtocol {
    func fetchTasks(category: TaskCategory?) -> [Task]
    func createTask(_ task: Task) throws
    func updateTask(_ task: Task) throws
    func deleteTask(_ id: UUID) throws
    func toggleComplete(_ id: UUID) throws
    func reorder(tasks: [Task]) throws
}

final class TaskService: TaskServiceProtocol {
    private let coreDataStack = CoreDataStack.shared
    
    func fetchTasks(category: TaskCategory? = nil) -> [Task] {
        let request: NSFetchRequest<TaskEntity> = TaskEntity.fetchRequest()
        request.sortDescriptors = [
            NSSortDescriptor(keyPath: \\TaskEntity.order, ascending: true),
            NSSortDescriptor(keyPath: \\TaskEntity.createdAt, ascending: false)
        ]
        
        if let category = category {
            request.predicate = NSPredicate(format: "category == %@", category.rawValue)
        }
        
        do {
            let entities = try coreDataStack.viewContext.fetch(request)
            return entities.map { mapEntityToTask($0) }
        } catch {
            print("Fetch tasks error: \(error)")
            return []
        }
    }
    
    func createTask(_ task: Task) throws {
        let entity = TaskEntity(context: coreDataStack.viewContext)
        entity.id = task.id
        entity.title = task.title
        entity.descriptionText = task.description
        entity.isCompleted = task.isCompleted
        entity.category = task.category.rawValue
        entity.dueDate = task.dueDate
        entity.priority = task.priority.rawValue
        entity.createdAt = task.createdAt
        entity.updatedAt = task.updatedAt
        entity.order = Int32(task.createdAt.timeIntervalSince1970)
        
        if let image = task.image {
            entity.imageData = image.jpegData(compressionQuality: 0.8)
        }
        
        coreDataStack.save()
    }
    
    func updateTask(_ task: Task) throws {
        let request: NSFetchRequest<TaskEntity> = TaskEntity.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", task.id as CVarArg)
        
        if let entity = try coreDataStack.viewContext.fetch(request).first {
            entity.title = task.title
            entity.descriptionText = task.description
            entity.category = task.category.rawValue
            entity.dueDate = task.dueDate
            entity.priority = task.priority.rawValue
            entity.updatedAt = Date()
            
            if let image = task.image {
                entity.imageData = image.jpegData(compressionQuality: 0.8)
            }
            
            coreDataStack.save()
        }
    }
    
    func deleteTask(_ id: UUID) throws {
        let request: NSFetchRequest<TaskEntity> = TaskEntity.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
        
        if let entity = try coreDataStack.viewContext.fetch(request).first {
            coreDataStack.viewContext.delete(entity)
            coreDataStack.save()
        }
    }
    
    func toggleComplete(_ id: UUID) throws {
        let request: NSFetchRequest<TaskEntity> = TaskEntity.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
        
        if let entity = try coreDataStack.viewContext.fetch(request).first {
            entity.isCompleted.toggle()
            entity.updatedAt = Date()
            coreDataStack.save()
        }
    }
    
    func reorder(tasks: [Task]) throws {
        for (index, task) in tasks.enumerated() {
            let request: NSFetchRequest<TaskEntity> = TaskEntity.fetchRequest()
            request.predicate = NSPredicate(format: "id == %@", task.id as CVarArg)
            
            if let entity = try coreDataStack.viewContext.fetch(request).first {
                entity.order = Int32(index)
            }
        }
        coreDataStack.save()
    }
    
    private func mapEntityToTask(_ entity: TaskEntity) -> Task {
        let image = entity.imageData.flatMap { UIImage(data: $0) }
        let category = TaskCategory(rawValue: entity.category) ?? .personal
        let priority = TaskPriority(rawValue: entity.priority) ?? .medium
        
        return Task(
            id: entity.id,
            title: entity.title ?? "",
            description: entity.descriptionText ?? "",
            isCompleted: entity.isCompleted,
            image: image,
            category: category,
            dueDate: entity.dueDate,
            priority: priority,
            createdAt: entity.createdAt,
            updatedAt: entity.updatedAt
        )
    }
}

Image Service

Core/Services/ImageService.swift:

import UIKit

final class ImageService {
    static let shared = ImageService()
    
    private init() {}
    
    func saveImageToAppDocuments(_ image: UIImage) -> URL? {
        guard let documentsDirectory = FileManager.default.urls(
            for: .documentDirectory,
            in: .userDomainMask
        ).first else { return nil }
        
        let filename = UUID().uuidString + ".jpg"
        let fileURL = documentsDirectory.appendingPathComponent(filename)
        
        if let jpegData = image.jpegData(compressionQuality: 0.8) {
            try? jpegData.write(to: fileURL)
            return fileURL
        }
        
        return nil
    }
    
    func loadImageFromURL(_ url: URL) -> UIImage? {
        return UIImage(contentsOfFile: url.path)
    }
    
    func deleteImageFile(_ url: URL) {
        try? FileManager.default.removeItem(at: url)
    }
}

Task List ViewModel

ViewModels/TaskListViewModel.swift:

import Foundation
import Combine

@MainActor
final class TaskListViewModel: ObservableObject {
    @Published var tasks: [Task] = []
    @Published var selectedCategory: TaskCategory? = nil
    @Published var searchText: String = ""
    @Published var sortBy: SortOption = .dateCreated
    
    private let taskService: TaskServiceProtocol
    private var cancellables = Set<AnyCancellable>()
    
    enum SortOption {
        case dateCreated
        case priority
        case dueDate
        case alphabetical
    }
    
    init(taskService: TaskServiceProtocol = TaskService()) {
        self.taskService = taskService
        setupBindings()
        loadTasks()
    }
    
    private func setupBindings() {
        Publishers.CombineLatest(
            $selectedCategory,
            $searchText
        )
        .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
        .sink { [weak self] _, _ in
            self?.loadTasks()
        }
        .store(in: &cancellables)
    }
    
    func loadTasks() {
        var fetchedTasks = taskService.fetchTasks(category: selectedCategory)
        
        // Поиск
        if !searchText.isEmpty {
            fetchedTasks = fetchedTasks.filter { task in
                task.title.localizedCaseInsensitiveContains(searchText) ||
                task.description.localizedCaseInsensitiveContains(searchText)
            }
        }
        
        // Сортировка
        fetchedTasks.sort { task1, task2 in
            switch sortBy {
            case .dateCreated:
                return task1.createdAt > task2.createdAt
            case .priority:
                return task1.priority.rawValue > task2.priority.rawValue
            case .dueDate:
                let date1 = task1.dueDate ?? Date(timeIntervalSince1970: 0)
                let date2 = task2.dueDate ?? Date(timeIntervalSince1970: 0)
                return date1 < date2
            case .alphabetical:
                return task1.title < task2.title
            }
        }
        
        self.tasks = fetchedTasks
    }
    
    func createTask(_ task: Task) throws {
        try taskService.createTask(task)
        loadTasks()
    }
    
    func deleteTask(_ task: Task) throws {
        try taskService.deleteTask(task.id)
        loadTasks()
    }
    
    func toggleComplete(_ task: Task) throws {
        try taskService.toggleComplete(task.id)
        loadTasks()
    }
    
    func reorderTasks(_ tasks: [Task]) throws {
        try taskService.reorder(tasks: tasks)
        self.tasks = tasks
    }
}

Task Detail ViewModel

ViewModels/TaskDetailViewModel.swift:

import Foundation
import UIKit

final class TaskDetailViewModel {
    @Published var task: Task
    @Published var selectedImage: UIImage?
    
    private let taskService: TaskServiceProtocol
    var isEditingExistingTask: Bool
    
    init(
        task: Task? = nil,
        taskService: TaskServiceProtocol = TaskService()
    ) {
        self.taskService = taskService
        
        if let task = task {
            self.task = task
            self.selectedImage = task.image
            self.isEditingExistingTask = true
        } else {
            self.task = Task(
                id: UUID(),
                title: "",
                description: "",
                isCompleted: false,
                image: nil,
                category: .personal,
                dueDate: nil,
                priority: .medium,
                createdAt: Date(),
                updatedAt: Date()
            )
            self.isEditingExistingTask = false
        }
    }
    
    func saveTask() throws {
        var updatedTask = task
        updatedTask.image = selectedImage
        updatedTask.updatedAt = Date()
        
        if isEditingExistingTask {
            try taskService.updateTask(updatedTask)
        } else {
            try taskService.createTask(updatedTask)
        }
    }
}

Task List View Controller

Views/TaskListViewController.swift:

import UIKit

final class TaskListViewController: UIViewController {
    private let viewModel = TaskListViewModel()
    private let tableView = UITableView(frame: .zero, style: .grouped)
    private let searchController = UISearchController(searchResultsController: nil)
    private let addButton = UIBarButtonItem()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "Tasks"
        view.backgroundColor = .systemBackground
        setupTableView()
        setupSearchBar()
        setupNavigationBar()
        setupBindings()
    }
    
    private func setupTableView() {
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(TaskCell.self, forCellReuseIdentifier: "cell")
        tableView.register(TaskHeaderView.self, forHeaderFooterViewReuseIdentifier: "header")
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 100
        tableView.dragDelegate = self
        tableView.dropDelegate = self
        tableView.dragInteractionEnabled = true
        
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
    
    private func setupSearchBar() {
        searchController.searchResultsUpdater = self
        searchController.obscuresBackgroundDuringPresentation = false
        searchController.searchBar.placeholder = "Search tasks"
        navigationItem.searchController = searchController
        definesPresentationContext = true
    }
    
    private func setupNavigationBar() {
        let addAction = UIAction { [weak self] _ in
            self?.presentTaskDetail(task: nil)
        }
        addButton.image = UIImage(systemName: "plus.circle.fill")
        addButton.primaryAction = addAction
        navigationItem.rightBarButtonItem = addButton
        
        let sortMenu = UIMenu(
            title: "Sort By",
            children: [
                UIAction(title: "Date Created") { [weak self] _ in
                    self?.viewModel.sortBy = .dateCreated
                    self?.viewModel.loadTasks()
                },
                UIAction(title: "Priority") { [weak self] _ in
                    self?.viewModel.sortBy = .priority
                    self?.viewModel.loadTasks()
                },
                UIAction(title: "Due Date") { [weak self] _ in
                    self?.viewModel.sortBy = .dueDate
                    self?.viewModel.loadTasks()
                },
                UIAction(title: "Alphabetical") { [weak self] _ in
                    self?.viewModel.sortBy = .alphabetical
                    self?.viewModel.loadTasks()
                }
            ]
        )
        
        navigationItem.leftBarButtonItem = UIBarButtonItem(menu: sortMenu)
    }
    
    private func setupBindings() {
        viewModel.$tasks
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
    }
    
    private func presentTaskDetail(task: Task?) {
        let viewModel = TaskDetailViewModel(task: task)
        let vc = TaskDetailViewController(viewModel: viewModel)
        vc.delegate = self
        let nav = UINavigationController(rootViewController: vc)
        present(nav, animated: true)
    }
    
    private var cancellables = Set<AnyCancellable>()
}

extension TaskListViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return TaskCategory.allCases.count + 1  // +1 for "All"
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.tasks.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! TaskCell
        let task = viewModel.tasks[indexPath.row]
        cell.configure(with: task)
        cell.onToggle = { [weak self] in
            try? self?.viewModel.toggleComplete(task)
        }
        return cell
    }
}

extension TaskListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let task = viewModel.tasks[indexPath.row]
        presentTaskDetail(task: task)
        tableView.deselectRow(at: indexPath, animated: true)
    }
    
    func tableView(
        _ tableView: UITableView,
        trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
    ) -> UISwipeActionsConfiguration? {
        let task = viewModel.tasks[indexPath.row]
        
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completionHandler in
            try? self?.viewModel.deleteTask(task)
            completionHandler(true)
        }
        
        let configuration = UISwipeActionsConfiguration(actions: [deleteAction])
        return configuration
    }
}

extension TaskListViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        viewModel.searchText = searchController.searchBar.text ?? ""
    }
}

extension TaskListViewController: UITableViewDragDelegate {
    func tableView(
        _ tableView: UITableView,
        itemsForBeginning session: UIDragSession,
        at indexPath: IndexPath
    ) -> [UIDragItem] {
        let item = NSIndexPath(row: indexPath.row, section: indexPath.section)
        return [UIDragItem(itemProvider: NSItemProvider(object: item))]
    }
}

extension TaskListViewController: UITableViewDropDelegate {
    func tableView(
        _ tableView: UITableView,
        dropSessionDidUpdate session: UIDropSession,
        withDestinationIndexPath destinationIndexPath: IndexPath?
    ) -> UITableViewDropProposal {
        return UITableViewDropProposal(operation: .move, intent: .insertAtDestination)
    }
    
    func tableView(
        _ tableView: UITableView,
        performDropWith coordinator: UITableViewDropCoordinator
    ) {
        if let sourceIndexPath = coordinator.items.first?.sourceIndexPath,
           let destinationIndexPath = coordinator.destinationIndexPath {
            var tasks = viewModel.tasks
            let item = tasks.remove(at: sourceIndexPath.row)
            tasks.insert(item, at: destinationIndexPath.row)
            try? viewModel.reorderTasks(tasks)
        }
    }
}

Task Cell

Views/TaskCell.swift:

import UIKit

final class TaskCell: UITableViewCell {
    private let taskImageView = UIImageView()
    private let titleLabel = UILabel()
    private let descriptionLabel = UILabel()
    private let categoryBadge = UILabel()
    private let priorityBadge = UILabel()
    private let dueDateLabel = UILabel()
    private let checkButton = UIButton()
    
    var onToggle: (() -> Void)?
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        // Image View
        taskImageView.contentMode = .scaleAspectFill
        taskImageView.layer.cornerRadius = 8
        taskImageView.clipsToBounds = true
        taskImageView.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(taskImageView)
        
        // Check Button
        checkButton.setImage(UIImage(systemName: "circle"), for: .normal)
        checkButton.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected)
        checkButton.tintColor = .systemGreen
        checkButton.addTarget(self, action: #selector(didTapCheckButton), for: .touchUpInside)
        checkButton.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(checkButton)
        
        // Title
        titleLabel.font = .boldSystemFont(ofSize: 16)
        titleLabel.numberOfLines = 1
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(titleLabel)
        
        // Description
        descriptionLabel.font = .systemFont(ofSize: 13)
        descriptionLabel.textColor = .gray
        descriptionLabel.numberOfLines = 2
        descriptionLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(descriptionLabel)
        
        // Category Badge
        categoryBadge.font = .systemFont(ofSize: 11)
        categoryBadge.textColor = .white
        categoryBadge.layer.cornerRadius = 4
        categoryBadge.clipsToBounds = true
        categoryBadge.textAlignment = .center
        categoryBadge.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(categoryBadge)
        
        // Priority Badge
        priorityBadge.font = .systemFont(ofSize: 11)
        priorityBadge.textColor = .white
        priorityBadge.layer.cornerRadius = 4
        priorityBadge.clipsToBounds = true
        priorityBadge.textAlignment = .center
        priorityBadge.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(priorityBadge)
        
        // Due Date
        dueDateLabel.font = .systemFont(ofSize: 11)
        dueDateLabel.textColor = .systemRed
        dueDateLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(dueDateLabel)
        
        // Constraints
        NSLayoutConstraint.activate([
            checkButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            checkButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            checkButton.widthAnchor.constraint(equalToConstant: 30),
            checkButton.heightAnchor.constraint(equalToConstant: 30),
            
            taskImageView.leadingAnchor.constraint(equalTo: checkButton.trailingAnchor, constant: 8),
            taskImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
            taskImageView.widthAnchor.constraint(equalToConstant: 60),
            taskImageView.heightAnchor.constraint(equalToConstant: 60),
            
            titleLabel.leadingAnchor.constraint(equalTo: taskImageView.trailingAnchor, constant: 12),
            titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            
            descriptionLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
            descriptionLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
            
            categoryBadge.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            categoryBadge.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8),
            categoryBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
            categoryBadge.heightAnchor.constraint(equalToConstant: 18),
            
            priorityBadge.leadingAnchor.constraint(equalTo: categoryBadge.trailingAnchor, constant: 4),
            priorityBadge.topAnchor.constraint(equalTo: categoryBadge.topAnchor),
            priorityBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
            priorityBadge.heightAnchor.constraint(equalToConstant: 18),
            
            dueDateLabel.leadingAnchor.constraint(equalTo: priorityBadge.trailingAnchor, constant: 4),
            dueDateLabel.centerYAnchor.constraint(equalTo: priorityBadge.centerYAnchor),
            dueDateLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -12),
            dueDateLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
        ])
    }
    
    func configure(with task: Task) {
        titleLabel.text = task.title
        titleLabel.alpha = task.isCompleted ? 0.5 : 1.0
        
        descriptionLabel.text = task.description
        descriptionLabel.alpha = task.isCompleted ? 0.5 : 1.0
        
        if let image = task.image {
            taskImageView.image = image
        } else {
            taskImageView.backgroundColor = .systemGray5
            taskImageView.image = UIImage(systemName: "photo")
        }
        
        categoryBadge.text = task.category.displayName
        categoryBadge.backgroundColor = task.category.color
        
        priorityBadge.text = task.priority.displayName
        priorityBadge.backgroundColor = priorityColor(task.priority)
        
        if let dueDate = task.dueDate {
            let formatter = DateFormatter()
            formatter.dateStyle = .short
            dueDateLabel.text = formatter.string(from: dueDate)
        } else {
            dueDateLabel.text = ""
        }
        
        checkButton.isSelected = task.isCompleted
    }
    
    @objc private func didTapCheckButton() {
        checkButton.isSelected.toggle()
        onToggle?()
    }
    
    private func priorityColor(_ priority: TaskPriority) -> UIColor {
        switch priority {
        case .low: return .systemGreen
        case .medium: return .systemYellow
        case .high: return .systemRed
        }
    }
}

Ключевые особенности

  1. Core Data — мощное локальное хранилище с полной поддержкой отношений
  2. MVVM архитектура — чистое разделение логики и представления
  3. Drag & Drop — встроенная поддержка переупорядочения
  4. Поиск и фильтрация — быстрый поиск по тексту
  5. Категории и приоритеты — enum для типобезопасности
  6. Изображения — JPEG сжатие и оптимизация памяти
  7. Дата и напоминания — готово для расширения с UserNotifications
  8. Анимации — встроенные анимации UITableView

Это production-ready приложение для управления задачами.

Приложение со списком задач (Todo) | PrepBro