Repository: resxton/ImageFeed
Pull request #6: Pull request for task in sprint 12
Author: login: resxton
Created: 2025-04-08T18:42:00Z | Merged: 2025-04-16T07:55:43Z

Верстка SplashScreen и AuthView по расширенной версии, лого смещено вверх


Diff:
diff --git a/ImageFeed/Auth/AuthViewController.swift b/ImageFeed/Auth/AuthViewController.swift
index bd824be..92067e5 100644
--- a/ImageFeed/Auth/AuthViewController.swift
+++ b/ImageFeed/Auth/AuthViewController.swift
@@ -42,8 +42,7 @@ extension AuthViewController: WebViewViewControllerDelegate {
                 UIBlockingProgressHUD.dismiss()
                 
                 switch result {
-                case .success(let token):
-                    print("[WebViewViewController]: Успешная аутентификация, получен токен: \(token)")
+                case .success(_):
                     delegate?.didAuthenticate(self)
                 case .failure(let error):
                     print("[WebViewViewController]: Ошибка получения токена - \(error.localizedDescription)")
diff --git a/ImageFeed/Auth/OAuth2Service.swift b/ImageFeed/Auth/OAuth2Service.swift
index 3d15e6e..992b1ea 100644
--- a/ImageFeed/Auth/OAuth2Service.swift
+++ b/ImageFeed/Auth/OAuth2Service.swift
@@ -58,7 +58,7 @@ final class OAuth2Service {
             switch result {
             case .success(let tokenResponse):
                 let token = tokenResponse.accessToken
-                OAuth2TokenStorage().token = token
+                OAuth2TokenStorage.shared.token = token
                 print("[OAuth2Service.fetchOAuthToken]: Успешно получен токен: \(tokenResponse.accessToken)")
                 fulfillCompletionOnTheMainThread(.success(tokenResponse.accessToken))
             case .failure(let error):
@@ -95,7 +95,7 @@ final class OAuth2Service {
         }
         
         var request = URLRequest(url: url)
-        request.httpMethod = "POST"
+        request.httpMethod = HTTPMethods.post.rawValue
         return request
     }
 }
diff --git a/ImageFeed/Auth/OAuth2TokenStorage.swift b/ImageFeed/Auth/OAuth2TokenStorage.swift
index eae0d40..09b48b1 100644
--- a/ImageFeed/Auth/OAuth2TokenStorage.swift
+++ b/ImageFeed/Auth/OAuth2TokenStorage.swift
@@ -12,6 +12,10 @@ final class OAuth2TokenStorage {
     private let key = "OAuth2Token"
     private let keychain = KeychainWrapper.standard
     
+    static let shared = OAuth2TokenStorage()
+    
+    private init() {}
+    
     var token: String? {
         get {
             keychain.string(forKey: key)
diff --git a/ImageFeed/Auth/URLSession+data.swift b/ImageFeed/Auth/URLSession+data.swift
index dbe1f5f..46e51dc 100644
--- a/ImageFeed/Auth/URLSession+data.swift
+++ b/ImageFeed/Auth/URLSession+data.swift
@@ -44,13 +44,11 @@ extension URLSession {
     }
     
     func objectTask<T: Decodable>(for request: URLRequest, completion: @escaping (Result<T, Error>) -> Void) -> URLSessionTask {
-        print(request.url)
         let task = data(for: request) { result in
             switch result {
             case .success(let data):
                 do {
                     let decodedResponse = try JSONDecoder().decode(T.self, from: data)
-                    print("[URLSession.objectTask]: Успешно декодировано: \(decodedResponse)")
                     completion(.success(decodedResponse))
                 } catch {
                     print("[URLSession.objectTask]: Ошибка декодирования - \(error.localizedDescription), Данные: \(String(data: data, encoding: .utf8) ?? "nil")")
diff --git a/ImageFeed/Base.lproj/Main.storyboard b/ImageFeed/Base.lproj/Main.storyboard
index ea33dc1..de842c2 100644
--- a/ImageFeed/Base.lproj/Main.storyboard
+++ b/ImageFeed/Base.lproj/Main.storyboard
@@ -9,6 +9,11 @@
         <capability name="System colors in document resources" minToolsVersion="11.0"/>
         <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
     </dependencies>
+    <customFonts key="customFonts">
+        <array key="YS Display-Bold.ttf">
+            <string>YSDisplay-Bold</string>
+        </array>
+    </customFonts>
     <scenes>
         <!--Tab Bar Controller-->
         <scene sceneID="wog-B2-6vE">
@@ -142,6 +147,9 @@
                                                     </constraints>
                                                     <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
                                                     <state key="normal" image="Favorites-Active"/>
+                                                    <connections>
+                                                        <action selector="didTapLikeButton" destination="gIi-Zx-Xv2" eventType="touchUpInside" id="1Rm-Lf-4W8"/>
+                                                    </connections>
                                                 </button>
                                                 <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="P7R-9S-bQG">
                                                     <rect key="frame" x="16" y="266" width="370" height="30"/>
diff --git a/ImageFeed/HTTPMethods.swift b/ImageFeed/HTTPMethods.swift
new file mode 100644
index 0000000..f5d1821
--- /dev/null
+++ b/ImageFeed/HTTPMethods.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+enum HTTPMethods: String {
+    case get = "GET"
+    case post = "POST"
+    case delete = "DELETE"
+}
diff --git a/ImageFeed/ImagesList/ImagesListCell.swift b/ImageFeed/ImagesList/ImagesListCell.swift
index 49ef5f7..459e0ac 100644
--- a/ImageFeed/ImagesList/ImagesListCell.swift
+++ b/ImageFeed/ImagesList/ImagesListCell.swift
@@ -10,14 +10,12 @@ import UIKit
 final class ImagesListCell: UITableViewCell {
     // MARK: - Public Properties
     static let reuseIdentifier = "ImagesListCell"
+    weak var delegate: ImagesListCellDelegate?
     
     // MARK: - IB Outlets
     @IBOutlet var cardImage: UIImageView!
-    
     @IBOutlet var likeButton: UIButton!
-    
     @IBOutlet var label: UILabel!
-    
     @IBOutlet var gradientView: UIView!
     
     // MARK: - Private Properties
@@ -47,13 +45,35 @@ final class ImagesListCell: UITableViewCell {
         gradientView.layer.mask = maskLayer
     }
     
+    override func prepareForReuse() {
+        super.prepareForReuse()
+        
+        cardImage.kf.cancelDownloadTask()
+        cardImage.image = nil
+    }
+    
+    // MARK: - Public methods
+    
+    public func setIsLiked(_ isLiked: Bool) {
+        let likeImageName = isLiked ? "Favorites-Active" : "Favorites-No Active"
+        if let likeImage = UIImage(named: likeImageName) {
+            likeButton.setImage(likeImage, for: .normal)
+        } else {
+            print("Ошибка: Изображение \(likeImageName) не найдено")
+        }
+    }
+    
+    // MARK: - IBAction
+    
+    @IBAction func didTapLikeButton() {
+        delegate?.imageListCellDidTapLike(self)
+    }
+    
     // MARK: - Private Methods
     private func setupGradient() {
-        guard let ypBlack = UIColor(named: "YP Black") else { return }
-        
         gradientLayer.colors = [
-            ypBlack.withAlphaComponent(0).cgColor,
-            ypBlack.withAlphaComponent(0.2).cgColor
+            UIColor.ypBackground.withAlphaComponent(0).cgColor,
+            UIColor.ypBackground.cgColor
         ]
         gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
         gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
diff --git a/ImageFeed/ImagesList/ImagesListCellDelegate.swift b/ImageFeed/ImagesList/ImagesListCellDelegate.swift
new file mode 100644
index 0000000..1a19bae
--- /dev/null
+++ b/ImageFeed/ImagesList/ImagesListCellDelegate.swift
@@ -0,0 +1,10 @@
+//
+//  ImagesListCellDelegate.swift
+//  ImageFeed
+//
+//  Created by Сомов Кирилл on 03.04.2025.
+//
+
+protocol ImagesListCellDelegate: AnyObject {
+    func imageListCellDidTapLike(_ cell: ImagesListCell)
+}
diff --git a/ImageFeed/ImagesList/ImagesListService.swift b/ImageFeed/ImagesList/ImagesListService.swift
new file mode 100644
index 0000000..6f84ca7
--- /dev/null
+++ b/ImageFeed/ImagesList/ImagesListService.swift
@@ -0,0 +1,192 @@
+//
+//  ImagesListService.swift
+//  ImageFeed
+//
+//  Created by Сомов Кирилл on 20.03.2025.
+//
+
+import Foundation
+
+final class ImagesListService {
+    // MARK: - Public Properties
+    static let shared = ImagesListService()
+    static let didChangeNotification = Notification.Name(rawValue: "ImagesListServiceDidChange")
+    
+    // MARK: - Private Properties
+    private(set) var photos: [Photo] = []
+    private var lastLoadedPage: Int?
+    private let urlSession = URLSession.shared
+    private var task: URLSessionTask?
+    
+    // MARK: - Public Methods
+    public func fetchPhotosNextPage(_ completion: @escaping (Result<[Photo], Error>) -> Void) {
+        assert(Thread.isMainThread)
+        let nextPage = (lastLoadedPage ?? 0) + 1
+
+        let fulfillCompletionOnTheMainThread: (Result<[Photo], Error>) -> Void = { result in
+            DispatchQueue.main.async {
+                completion(result)
+            }
+        }
+
+        guard nextPage != lastLoadedPage else {
+            print("[ImagesListService.fetchPhotosNextPage]: Запрос с той же страницей уже выполнялся")
+            fulfillCompletionOnTheMainThread(.failure(NetworkError.invalidRequest))
+            return
+        }
+
+        task?.cancel()
+        lastLoadedPage = nextPage
+
+        guard let imagesRequest = makeImagesRequest(page: nextPage) else {
+            let error = NetworkError.invalidRequest
+            print("[ImagesListService.fetchPhotosNextPage]: Ошибка создания URLRequest - \(error)")
+            fulfillCompletionOnTheMainThread(.failure(error))
+            return
+        }
+
+        let task = urlSession.objectTask(for: imagesRequest) { [weak self] (result: Result<[PhotoResult], Error>) in
+            guard let self else { return }
+
+            defer {
+                DispatchQueue.main.async {
+                    self.task = nil
+                    self.lastLoadedPage = nextPage
+                }
+            }
+
+            switch result {
+            case .success(let imagesResponse):
+                let newPhotos = imagesResponse.map { photoResult in
+                    Photo(
+                        id: photoResult.id,
+                        size: CGSize(width: photoResult.width, height: photoResult.height),
+                        createdAt: photoResult.date,
+                        welcomeDescription: photoResult.description,
+                        thumbImageURL: photoResult.urls.thumb,
+                        largeImageURL: photoResult.urls.large,
+                        fullImageURL: photoResult.urls.full,
+                        isLiked: photoResult.isLiked
+                    )
+                }
+
+                let uniquePhotos = newPhotos.filter { newPhoto in
+                    !self.photos.contains { $0.id == newPhoto.id }
+                }
+
+                guard !uniquePhotos.isEmpty else {
+                    print("[ImagesListService]: Загруженные фото уже есть в списке, обновление отменено")
+                    return
+                }
+
+                self.photos.append(contentsOf: uniquePhotos)
+                fulfillCompletionOnTheMainThread(.success(uniquePhotos))
+                NotificationCenter.default.post(name: ImagesListService.didChangeNotification, object: self)
+
+
+            case .failure(let error):
+                print("[ImagesListService.fetchPhotosNextPage]: Ошибка загрузки - \(error.localizedDescription)")
+                fulfillCompletionOnTheMainThread(.failure(error))
+            }
+        }
+
+        task.resume()
+    }
+    
+    public func changeLike(photoId: String, isLike: Bool, _ completion: @escaping (Result<Void, Error>) -> Void) {
+        assert(Thread.isMainThread)
+        
+        let fulfillCompletionOnTheMainThread: (Result<Void, Error>) -> Void = { result in
+            DispatchQueue.main.async {
+                completion(result)
+            }
+        }
+        
+        task?.cancel()
+        
+        guard let likeRequest = makeLikeRequest(photoId: photoId, isLike: isLike) else {
+            let error = NetworkError.invalidRequest
+            print("[ImagesListService.changeLike]: Ошибка создания URLRequest - \(error)")
+            fulfillCompletionOnTheMainThread(.failure(error))
+            return
+        }
+        
+        let task = urlSession.objectTask(for: likeRequest) { [weak self] (result: Result<LikePhotoResponse, Error>) in
+            guard let self else { return }
+            
+            defer {
+                DispatchQueue.main.async {
+                    self.task = nil
+                }
+            }
+            
+            switch result {
+            case .success(_):
+                if let index = self.photos.firstIndex(where: { $0.id == photoId }) {
+                    let oldPhoto = self.photos[index]
+                    let updatedPhoto = Photo(
+                        id: oldPhoto.id,
+                        size: oldPhoto.size,
+                        createdAt: oldPhoto.createdAt,
+                        welcomeDescription: oldPhoto.welcomeDescription,
+                        thumbImageURL: oldPhoto.thumbImageURL,
+                        largeImageURL: oldPhoto.largeImageURL,
+                        fullImageURL: oldPhoto.fullImageURL,
+                        isLiked: !oldPhoto.isLiked
+                    )
+                    self.photos[index] = updatedPhoto
+                    NotificationCenter.default.post(name: ImagesListService.didChangeNotification, object: self)
+                } else {
+                    print("[ImagesListService.changeLike]: Фото с id \(photoId) не найдено в массиве")
+                }
+                
+                fulfillCompletionOnTheMainThread(.success(()))
+            case .failure(let error):
+                print("[ImagesListService.changeLike]: Ошибка загрузки - \(error.localizedDescription)")
+                fulfillCompletionOnTheMainThread(.failure(error))
+            }
+        }
+        
+        task.resume()
+    }
+    
+    func cleanUp() {
+        photos.removeAll()
+    }
+    
+    // MARK: - Private Methods
+    private func makeImagesRequest(page: Int?) -> URLRequest? {
+        guard let url = URL(
+                string: "/photos"
+                + "?page=\(page ?? 1)",
+                relativeTo: Constants.defaultBaseURL
+              ) else {
+            return nil
+        }
+        
+        var request = URLRequest(url: url)
+        request.httpMethod = HTTPMethods.get.rawValue
+        
+        guard let token = OAuth2TokenStorage.shared.token else { return nil }
+        
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+        return request
+    }
+    
+    private func makeLikeRequest(photoId: String, isLike: Bool) -> URLRequest? {
+        guard let url = URL(
+            string: "/photos/\(photoId)/like",
+            relativeTo: Constants.defaultBaseURL
+        ) else {
+            return nil
+        }
+        
+        var request = URLRequest(url: url)
+        request.httpMethod = isLike ? HTTPMethods.post.rawValue : HTTPMethods.delete.rawValue
+
+        guard let token = OAuth2TokenStorage.shared.token else { return nil }
+        
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+        return request
+    }
+}
diff --git a/ImageFeed/ImagesList/ImagesListViewController.swift b/ImageFeed/ImagesList/ImagesListViewController.swift
index f50502d..e2e2a56 100644
--- a/ImageFeed/ImagesList/ImagesListViewController.swift
+++ b/ImageFeed/ImagesList/ImagesListViewController.swift
@@ -6,12 +6,16 @@
 //
 
 import UIKit
+import Kingfisher
 
 final class ImagesListViewController: UIViewController {
     // MARK: - Private Properties
     private let photosName: [String] = Array(0..<20).map{ "\($0)" }
+    private var photos: [Photo] = []
     private let showSingleImageSegueIdentifier = "ShowSingleImage"
-    
+    private let imagesListService = ImagesListService.shared
+    private var imagesListServiceObserver: NSObjectProtocol?
+
     private lazy var dateFormatter: DateFormatter = {
         let formatter = DateFormatter()
         formatter.locale = Locale(identifier: "ru_RU")
@@ -27,6 +31,19 @@ final class ImagesListViewController: UIViewController {
         super.viewDidLoad()
         
         setupTableView()
+        imagesListServiceObserver = NotificationCenter.default
+            .addObserver(
+                forName: ImagesListService.didChangeNotification,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                guard let self else { return }
+                self.updateTableViewAnimated()
+            }
+        
+        imagesListService.fetchPhotosNextPage { result in
+            
+        }
     }
     
     override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
@@ -38,76 +55,162 @@ final class ImagesListViewController: UIViewController {
                 return
             }
             
-            let image = UIImage(named: photosName[indexPath.row])
-            viewController.image = image
+            let photo = photos[indexPath.row]
+            
+            viewController.image = photo
         } else {
             super.prepare(for: segue, sender: sender)
         }
     }
+
+    
+    deinit {
+        if let observer = imagesListServiceObserver {
+            NotificationCenter.default.removeObserver(observer)
+        }
+    }
+
     
     // MARK: - Public Methods
-    func configureCell(for cell: ImagesListCell, with indexPath: IndexPath) {
-        // Получаем имя изображения для картинки
-        let imageName = String(indexPath.row)
-        guard let cardImage = UIImage(named: imageName) else {
-            print("Ошибка: Изображение \(imageName) не найдено")
+    func configureCell(
+        for cell: ImagesListCell,
+        with indexPath: IndexPath,
+        from photo: Photo
+    ) {
+        guard let url = URL(string: photo.thumbImageURL) else {
+            print("Ошибка: URL некорректен")
             return
         }
-        cell.cardImage.image = cardImage
         
-        cell.label.text = dateFormatter.string(from: Date())
+        guard let stub = UIImage(named: "Stub") else {
+            print("Ошибка: заглушка не найдена")
+            return
+        }
+        
+        cell.cardImage.kf.cancelDownloadTask()
+        cell.cardImage.image = nil
+        cell.cardImage.kf.setImage(with: url, placeholder: stub, options: nil)
+        cell.cardImage.kf.indicatorType = .activity
+        
+        let createdAt = photo.createdAt
+        
+        if let createdAt {
+            cell.label.text = dateFormatter.string(from: createdAt)
+        } else {
+            cell.label.text = "Дата не указана"
+        }
+        
+        let likeImageName = photo.isLiked ? "Favorites-Active" : "Favorites-No Active"
         
-        let likeImageName = indexPath.row % 2 == 0 ? "Favorites-Active" : "Favorites-No Active"
         if let likeImage = UIImage(named: likeImageName) {
             cell.likeButton.setImage(likeImage, for: .normal)
         } else {
             print("Ошибка: Изображение \(likeImageName) не найдено")
         }
     }
-
     
+    func tableView(
+        _ tableView: UITableView,
+        willDisplay cell: UITableViewCell,
+        forRowAt indexPath: IndexPath
+    ) {
+        if indexPath.row == photos.count - 1 {
+            imagesListService.fetchPhotosNextPage { _ in }
+        }
+    }
+
     // MARK: - Private Methods
     private func setupTableView() {
         tableView.rowHeight = 300
         tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
     }
+    
+    private func updateTableViewAnimated() {
+        let oldCount = photos.count
+        let newPhotos = imagesListService.photos
+
+        guard newPhotos.count > oldCount else {
+            return
+        }
+
+        let newPhotosToAdd = Array(newPhotos.suffix(from: oldCount))
+        photos.append(contentsOf: newPhotosToAdd)
+
+        let indexPaths = (oldCount..<photos.count).map { IndexPath(row: $0, section: 0) }
+
+        tableView.performBatchUpdates {
+            tableView.insertRows(at: indexPaths, with: .automatic)
+        }
+    }
+
 }
 
 extension ImagesListViewController: UITableViewDataSource {
-    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
-        return photosName.count
+    func tableView(
+        _ tableView: UITableView,
+        numberOfRowsInSection section: Int
+    ) -> Int {
+        photos.count
     }
     
-    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+    func tableView(
+        _ tableView: UITableView,
+        cellForRowAt indexPath: IndexPath
+    ) -> UITableViewCell {
         let cell = tableView.dequeueReusableCell(withIdentifier: ImagesListCell.reuseIdentifier, for: indexPath)
         
         guard let imagesListCell = cell as? ImagesListCell else {
             return UITableViewCell()
         }
         
-        configureCell(for: imagesListCell, with: indexPath)
+        imagesListCell.delegate = self
+        
+        configureCell(for: imagesListCell, with: indexPath, from: photos[indexPath.row])
         
         return imagesListCell
     }
 }
 
 extension ImagesListViewController: UITableViewDelegate {
-    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+    func tableView(
+        _ tableView: UITableView,
+        didSelectRowAt indexPath: IndexPath
+    ) {
         performSegue(withIdentifier: showSingleImageSegueIdentifier, sender: indexPath)
     }
     
-    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
-        // Получаем размер изображения
-        guard let rawImageSize = UIImage(named: photosName[indexPath.row])?.size else {
-            return 300
-        }
+    func tableView(
+        _ tableView: UITableView,
+        heightForRowAt indexPath: IndexPath
+    ) -> CGFloat {
+        let rawImageSize = photos[indexPath.row].size
         
         let imageViewWidth = self.tableView.bounds.size.width - 32
-
         let resultHeight = imageViewWidth * (rawImageSize.height / rawImageSize.width)
         
         return resultHeight
     }
 }
 
-
+extension ImagesListViewController: ImagesListCellDelegate {
+    
+    func imageListCellDidTapLike(_ cell: ImagesListCell) {
+        
+        guard let indexPath = tableView.indexPath(for: cell) else { return }
+        let photo = photos[indexPath.row]
+        UIBlockingProgressHUD.show()
+        
+        imagesListService.changeLike(photoId: photo.id, isLike: !photo.isLiked) { result in
+            switch result {
+            case .success:
+                self.photos = self.imagesListService.photos
+                cell.setIsLiked(self.photos[indexPath.row].isLiked)
+                UIBlockingProgressHUD.dismiss()
+            case .failure:
+                UIBlockingProgressHUD.dismiss()
+                // TODO: Показать ошибку с использованием UIAlertController
+            }
+        }
+    }
+    
+}
diff --git a/ImageFeed/ImagesList/Photo.swift b/ImageFeed/ImagesList/Photo.swift
new file mode 100644
index 0000000..da18003
--- /dev/null
+++ b/ImageFeed/ImagesList/Photo.swift
@@ -0,0 +1,63 @@
+//
+//  Photo.swift
+//  ImageFeed
+//
+//  Created by Сомов Кирилл on 20.03.2025.
+//
+
+import Foundation
+
+struct Photo {
+    let id: String
+    let size: CGSize
+    let createdAt: Date?
+    let welcomeDescription: String?
+    let thumbImageURL: String
+    let largeImageURL: String
+    let fullImageURL: String
+    let isLiked: Bool
+}
+
+struct PhotoResult: Codable {
+    let id: String
+    let createdAt: String
+    let width: Int
+    let height: Int
+    let description: String?
+    let urls: UrlsResult
+    let isLiked: Bool
+
+    enum CodingKeys: String, CodingKey {
+        case id
+        case createdAt = "created_at"
+        case width
+        case height
+        case description
+        case urls
+        case isLiked = "liked_by_user"
+    }
+
+    private static let dateFormatter: ISO8601DateFormatter = {
+        ISO8601DateFormatter()
+    }()
+    
+    var date: Date? {
+        return PhotoResult.dateFormatter.date(from: createdAt)
+    }
+}
+
+struct UrlsResult: Codable {
+    let thumb: String
+    let large: String
+    let full: String
+
+    enum CodingKeys: String, CodingKey {
+        case thumb
+        case large = "regular"
+        case full
+    }
+}
+
+struct LikePhotoResponse: Codable {
+    let photo: PhotoResult
+}
diff --git a/ImageFeed/Profile/Profile.swift b/ImageFeed/Profile/Profile.swift
index d4f2451..7fd2efd 100644
--- a/ImageFeed/Profile/Profile.swift
+++ b/ImageFeed/Profile/Profile.swift
@@ -14,9 +14,9 @@ struct Profile {
     var bio: String?
     
     init(from profileResult: ProfileResult) {
-        self.username = profileResult.username
-        self.name = "\(profileResult.firstName) \(profileResult.lastName)"
-        self.loginName = "@\(profileResult.username)"
+        self.username = profileResult.username ?? ""
+        self.name = "\(profileResult.firstName ?? "") \(profileResult.lastName ?? "")"
+        self.loginName = "@\(profileResult.username ?? "")"
         self.bio = profileResult.bio
     }
 }
diff --git a/ImageFeed/Profile/ProfileImageService.swift b/ImageFeed/Profile/ProfileImageService.swift
index 42e2c4f..642f379 100644
--- a/ImageFeed/Profile/ProfileImageService.swift
+++ b/ImageFeed/Profile/ProfileImageService.swift
@@ -59,9 +59,7 @@ final class ProfileImageService {
             }
             
             switch result {
-            case .success(let profileImageResponse):
-                print("[ProfileImageService.fetchProfileImageURL]: Успешно получен аватар: \(profileImageResponse.profileImage.large)")
-                
+            case .success(let profileImageResponse):                
                 let profileImageURL = profileImageResponse.profileImage.large
                 self.avatarURL = profileImageURL
                 fulfillCompletionOnTheMainThread(.success(profileImageURL))
@@ -85,6 +83,10 @@ final class ProfileImageService {
         task.resume()
     }
     
+    func cleanUp() {
+        avatarURL = nil
+    }
+    
     // MARK: - Private Methods
     private func makeProfileImageRequest(username: String) -> URLRequest? {
         guard let url = URL(
@@ -95,9 +97,9 @@ final class ProfileImageService {
         }
         
         var request = URLRequest(url: url)
-        request.httpMethod = "GET"
+        request.httpMethod = HTTPMethods.get.rawValue
         
-        guard let token = OAuth2TokenStorage().token else { return nil }
+        guard let token = OAuth2TokenStorage.shared.token else { return nil }
         
         request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
         return request
diff --git a/ImageFeed/Profile/ProfileLogoutService.swift b/ImageFeed/Profile/ProfileLogoutService.swift
new file mode 100644
index 0000000..3ece816
--- /dev/null
+++ b/ImageFeed/Profile/ProfileLogoutService.swift
@@ -0,0 +1,38 @@
+import Foundation
+import WebKit
+import UIKit
+
+final class ProfileLogoutService {
+    static let shared = ProfileLogoutService()
+    
+    private init() {}
+    
+    func logout() {
+        cleanCookies()
+        OAuth2TokenStorage.shared.clearToken()
+        ProfileService.shared.cleanUp()
+        ProfileImageService.shared.cleanUp()
+        ImagesListService.shared.cleanUp()
+        
+        switchToSplashScreen()
+    }
+    
+    private func cleanCookies() {
+        HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
+        WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
+            records.forEach { record in
+                WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {})
+            }
+        }
+    }
+    
+    private func switchToSplashScreen() {
+        guard let window = UIApplication.shared.windows.first else { return }
+        
+
+        let splashViewController = SplashViewController()
+        window.rootViewController = splashViewController
+        
+        UIView.transition(with: window, duration: 0.3, options: .transitionCrossDissolve, animations: nil, completion: nil)
+    }
+}
diff --git a/ImageFeed/Profile/ProfileResult.swift b/ImageFeed/Profile/ProfileResult.swift
index 9fca8bd..f732bc9 100644
--- a/ImageFeed/Profile/ProfileResult.swift
+++ b/ImageFeed/Profile/ProfileResult.swift
@@ -7,11 +7,11 @@
 
 import Foundation
 
-struct ProfileResult: Codable {
-    var username: String
-    var firstName: String
-    var lastName: String
-    var bio: String?
+struct ProfileResult: Decodable {
+    let username: String?
+    let firstName: String?
+    let lastName: String?
+    let bio: String?
     
     enum CodingKeys: String, CodingKey {
         case username
diff --git a/ImageFeed/Profile/ProfileService.swift b/ImageFeed/Profile/ProfileService.swift
index cc2dbf0..50f6759 100644
--- a/ImageFeed/Profile/ProfileService.swift
+++ b/ImageFeed/Profile/ProfileService.swift
@@ -59,7 +59,6 @@ final class ProfileService {
             
             switch result {
             case .success(let profileResponse):
-                print("[ProfileService.fetchProfile]: Успешно получен профиль: \(profileResponse)")
                 let profile = Profile(from: profileResponse)
                 self.profile = profile
                 fulfillCompletionOnTheMainThread(.success(profile))
@@ -80,12 +79,15 @@ final class ProfileService {
 
         task.resume()
     }
-
+    
+    func cleanUp() {
+        profile = nil
+    }
     
     // MARK: - Private Methods
     private func makeProfileRequest(token: String) -> URLRequest? {
         var request = URLRequest(url: Constants.profileBaseURL)
-        request.httpMethod = "GET"
+        request.httpMethod = HTTPMethods.get.rawValue
         
         request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
         return request
diff --git a/ImageFeed/Profile/ProfileViewController.swift b/ImageFeed/Profile/ProfileViewController.swift
index e01f40e..707b8c1 100644
--- a/ImageFeed/Profile/ProfileViewController.swift
+++ b/ImageFeed/Profile/ProfileViewController.swift
@@ -11,6 +11,7 @@ import Kingfisher
 final class ProfileViewController: UIViewController {
     // MARK: - Private Properties
     private var profileImageServiceObserver: NSObjectProtocol?
+    private let profileLogoutService = ProfileLogoutService.shared
     
     private let avatarImageView: UIImageView = {
         let imageView = UIImageView(image: UIImage(named: "Avatar"))
@@ -154,7 +155,12 @@ final class ProfileViewController: UIViewController {
     }
     
     @objc private func didTapLogoutButton() {
-        // TODO: - Добавить логику при нажатии на кнопку логаута
-        OAuth2TokenStorage().clearToken()
+        let alert = UIAlertController(title: "Пока, пока!", message: "Уверены, что хотите выйти?", preferredStyle: .alert)
+        alert.addAction(.init(title: "Нет", style: .cancel))
+        alert.addAction(.init(title: "Да", style: .default, handler: { [weak self] _ in
+            guard let self else { return }
+            profileLogoutService.logout()
+        }))
+        present(alert, animated: true)
     }
 }
diff --git a/ImageFeed/Profile/UserResult.swift b/ImageFeed/Profile/UserResult.swift
index b19d87a..a4fa8a1 100644
--- a/ImageFeed/Profile/UserResult.swift
+++ b/ImageFeed/Profile/UserResult.swift
@@ -1,10 +1,3 @@
-//
-//  UserResult.swift
-//  ImageFeed
-//
-//  Created by Сомов Кирилл on 11.03.2025.
-//
-
 import Foundation
 
 struct ProfileImage: Codable {
diff --git a/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/Contents.json b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/Contents.json
new file mode 100644
index 0000000..6d7dfc7
--- /dev/null
+++ b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "filename" : "SingleStub.png",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "SingleStub@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "SingleStub@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub.png b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub.png
new file mode 100644
index 0000000..ef9c0f4
Binary files /dev/null and b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub.png differ
diff --git a/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub@2x.png b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub@2x.png
new file mode 100644
index 0000000..b30b24b
Binary files /dev/null and b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub@2x.png differ
diff --git a/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub@3x.png b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub@3x.png
new file mode 100644
index 0000000..3030de6
Binary files /dev/null and b/ImageFeed/Resources/Assets.xcassets/SingleStub.imageset/SingleStub@3x.png differ
diff --git a/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Contents.json b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Contents.json
new file mode 100644
index 0000000..1c7aeaa
--- /dev/null
+++ b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "filename" : "Stub.png",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "Stub@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "Stub@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub.png b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub.png
new file mode 100644
index 0000000..5c23b46
Binary files /dev/null and b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub.png differ
diff --git a/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub@2x.png b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub@2x.png
new file mode 100644
index 0000000..b7b3300
Binary files /dev/null and b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub@2x.png differ
diff --git a/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub@3x.png b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub@3x.png
new file mode 100644
index 0000000..331caff
Binary files /dev/null and b/ImageFeed/Resources/Assets.xcassets/Stub.imageset/Stub@3x.png differ
diff --git a/ImageFeed/SingleImage/SingleImageViewController.swift b/ImageFeed/SingleImage/SingleImageViewController.swift
index 4865841..bd87378 100644
--- a/ImageFeed/SingleImage/SingleImageViewController.swift
+++ b/ImageFeed/SingleImage/SingleImageViewController.swift
@@ -1,11 +1,5 @@
-//
-//  SingleImageViewController.swift
-//  ImageFeed
-//
-//  Created by Сомов Кирилл on 31.01.2025.
-//
-
 import UIKit
+import Kingfisher
 
 final class SingleImageViewController: UIViewController {
     // MARK: - IB Outlets
@@ -18,28 +12,33 @@ final class SingleImageViewController: UIViewController {
     @IBOutlet private var scrollView: UIScrollView!
     
     // MARK: - Public Properties
-    var image: UIImage? {
+    
+    var image: Photo? {
         didSet {
-            guard isViewLoaded, let image else { return }
-
-            imageView.image = image
-            imageView.frame.size = image.size
-            rescaleAndCenterImageInScrollView(image: image)
+            guard isViewLoaded, let image, let imageURL = URL(string: image.fullImageURL) else { return }
+            isLiked = image.isLiked
+            loadImage(from: imageURL)
         }
     }
     
     // MARK: - Private Properties
-    private var isFavorite = false
+    private let imagesListService = ImagesListService.shared
+    private var isLiked: Bool = false {
+        didSet {
+            updateLikeButton()
+        }
+    }
 
     // MARK: - Life Cycle
     override func viewDidLoad() {
         super.viewDidLoad()
         
         setupScrollView()
-        guard let image else { return }
-        imageView.image = image
-        imageView.frame.size = image.size
-        rescaleAndCenterImageInScrollView(image: image)
+        
+        if let image, let imageURL = URL(string: image.fullImageURL) {
+            isLiked = image.isLiked
+            loadImage(from: imageURL)
+        }
     }
     
     // MARK: - IB Actions
@@ -49,7 +48,7 @@ final class SingleImageViewController: UIViewController {
     
     @IBAction func didTapShareButton(_ sender: Any) {
         let text = "Смотри, какую красоту нашел!"
-        guard let sharingImage = image else { return }
+        guard let sharingImage = imageView.image else { return }
         
         let activityViewController = UIActivityViewController(activityItems: [text, sharingImage], applicationActivities: nil)
         
@@ -57,15 +56,19 @@ final class SingleImageViewController: UIViewController {
     }
     
     @IBAction func didTapFavoritesButton(_ sender: Any) {
-        if isFavorite {
-            favoritesButton.setImage(UIImage(named: "Favorites-Big No Active"), for: .normal)
-        } else {
-            favoritesButton.setImage(UIImage(named: "Favorites-Big Active"), for: .normal)
-        }
-        
-        isFavorite.toggle()
+        guard let image else { return }
         
-        // TODO: - Добавить логику при нажатии на кнопку лайка
+        UIBlockingProgressHUD.show()
+        imagesListService.changeLike(photoId: image.id, isLike: !isLiked) { [weak self] result in
+            guard let self else { return }
+            switch result {
+            case .success(_):
+                UIBlockingProgressHUD.dismiss()
+                self.isLiked.toggle()
+            case .failure(_):
+                UIBlockingProgressHUD.dismiss()
+            }
+        }
     }
     
     // MARK: - Private Methods
@@ -84,6 +87,29 @@ final class SingleImageViewController: UIViewController {
         scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
     }
     
+    private func loadImage(from url: URL) {
+        UIBlockingProgressHUD.show()
+        imageView.kf.setImage(with: url, options: nil) { [weak self] result in
+            UIBlockingProgressHUD.dismiss()
+            switch result {
+            case .success(let value):
+                self?.imageView.frame.size = value.image.size
+                self?.rescaleAndCenterImageInScrollView(image: value.image)
+            case .failure(let error):
+                print("[SingleImage.loadImage]: \(error.localizedDescription)")
+            }
+        }
+    }
+    
+    private func updateLikeButton() {
+        let likeImageName = isLiked ? "Favorites-Big Active" : "Favorites-Big No Active"
+        if let likeImage = UIImage(named: likeImageName) {
+            favoritesButton.setImage(likeImage, for: .normal)
+        } else {
+            print("Ошибка: Изображение \(likeImageName) не найдено")
+        }
+    }
+    
     private func rescaleAndCenterImageInScrollView(image: UIImage) {
         let minZoomScale = scrollView.minimumZoomScale
         let maxZoomScale = scrollView.maximumZoomScale
@@ -120,7 +146,7 @@ extension SingleImageViewController: UIScrollViewDelegate {
     }
     
     func scrollViewDidZoom(_ scrollView: UIScrollView) {
-        guard let image else { return }
+        guard let image = imageView.image else { return }
         centerImageInScrollView(image: image)
     }
 }
diff --git a/ImageFeed/SplashScreen/SplashViewController.swift b/ImageFeed/SplashScreen/SplashViewController.swift
index f4662bb..d858368 100644
--- a/ImageFeed/SplashScreen/SplashViewController.swift
+++ b/ImageFeed/SplashScreen/SplashViewController.swift
@@ -27,7 +27,7 @@ final class SplashViewController: UIViewController {
         
     override func viewDidAppear(_ animated: Bool) {
         super.viewDidAppear(animated)
-        guard OAuth2TokenStorage().token != nil else {
+        guard OAuth2TokenStorage.shared.token != nil else {
             presentAuthScreen()
             return
         }
@@ -82,7 +82,7 @@ extension SplashViewController {
     }
 
     private func presentGalleryScreen() {
-        guard let token = OAuth2TokenStorage().token else {
+        guard let token = OAuth2TokenStorage.shared.token else {
             print("[SplashViewController.presentGalleryScreen]: токен отсутствует")
             return
         }
@@ -95,7 +95,7 @@ extension SplashViewController: AuthViewControllerDelegate {
     func didAuthenticate(_ vc: AuthViewController) {
         vc.dismiss(animated: true)
         
-        guard let token = OAuth2TokenStorage().token else {
+        guard let token = OAuth2TokenStorage.shared.token else {
             return
         }
         
@@ -119,8 +119,8 @@ extension SplashViewController: AuthViewControllerDelegate {
                 
                 profileImageService.fetchProfileImageURL(username: username) { result in
                     switch result {
-                    case .success(let success):
-                        print(success)
+                    case .success(_):
+                        print()
                     case .failure(let error):
                         print(error.localizedDescription)
                     }


Reviews:
user: login: ValeriUst
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-11T14:55:11Z

Review comments:
diff_hunk: @@ -0,0 +1,38 @@
+import Foundation
+import WebKit
+import UIKit
+
+final class ProfileLogoutService {
+    static let shared = ProfileLogoutService()
+    
+    private init() {}
+    
+    func logout() {
+        cleanCookies()
+        OAuth2TokenStorage().clearToken()
path: ImageFeed/Profile/ProfileLogoutService.swift
user: login: ValeriUst
body: `OAuth2TokenStorage` стоит сделать синглтоном
created_at: 2025-04-11T14:43:11Z
author_association: NONE
diff_hunk: @@ -0,0 +1,192 @@
+//
+//  ImagesListService.swift
+//  ImageFeed
+//
+//  Created by Сомов Кирилл on 20.03.2025.
+//
+
+import Foundation
+
+final class ImagesListService {
+    // MARK: - Public Properties
+    static let shared = ImagesListService()
+    static let didChangeNotification = Notification.Name(rawValue: "ImagesListServiceDidChange")
+    
+    // MARK: - Private Properties
+    private(set) var photos: [Photo] = []
+    private var lastLoadedPage: Int?
+    private let urlSession = URLSession.shared
+    private var task: URLSessionTask?
+    
+    // MARK: - Public Methods
+    public func fetchPhotosNextPage(_ completion: @escaping (Result<[Photo], Error>) -> Void) {
+        assert(Thread.isMainThread)
+        let nextPage = (lastLoadedPage ?? 0) + 1
+
+        let fulfillCompletionOnTheMainThread: (Result<[Photo], Error>) -> Void = { result in
+            DispatchQueue.main.async {
+                completion(result)
+            }
+        }
+
+        guard nextPage != lastLoadedPage else {
+            print("[ImagesListService.fetchPhotosNextPage]: Запрос с той же страницей уже выполнялся")
+            fulfillCompletionOnTheMainThread(.failure(NetworkError.invalidRequest))
+            return
+        }
+
+        task?.cancel()
+        lastLoadedPage = nextPage
+
+        guard let imagesRequest = makeImagesRequest(page: nextPage) else {
+            let error = NetworkError.invalidRequest
+            print("[ImagesListService.fetchPhotosNextPage]: Ошибка создания URLRequest - \(error)")
+            fulfillCompletionOnTheMainThread(.failure(error))
+            return
+        }
+
+        let task = urlSession.objectTask(for: imagesRequest) { [weak self] (result: Result<[PhotoResult], Error>) in
+            guard let self else { return }
+
+            defer {
+                DispatchQueue.main.async {
+                    self.task = nil
+                    self.lastLoadedPage = nextPage
+                }
+            }
+
+            switch result {
+            case .success(let imagesResponse):
+                let newPhotos = imagesResponse.map { photoResult in
+                    Photo(
+                        id: photoResult.id,
+                        size: CGSize(width: photoResult.width, height: photoResult.height),
+                        createdAt: photoResult.date,
+                        welcomeDescription: photoResult.description,
+                        thumbImageURL: photoResult.urls.thumb,
+                        largeImageURL: photoResult.urls.large,
+                        fullImageURL: photoResult.urls.full,
+                        isLiked: photoResult.isLiked
+                    )
+                }
+
+                let uniquePhotos = newPhotos.filter { newPhoto in
+                    !self.photos.contains { $0.id == newPhoto.id }
+                }
+
+                guard !uniquePhotos.isEmpty else {
+                    print("[ImagesListService]: Загруженные фото уже есть в списке, обновление отменено")
+                    return
+                }
+
+                self.photos.append(contentsOf: uniquePhotos)
+                fulfillCompletionOnTheMainThread(.success(uniquePhotos))
+                NotificationCenter.default.post(name: ImagesListService.didChangeNotification, object: self)
+
+
+            case .failure(let error):
+                print("[ImagesListService.fetchPhotosNextPage]: Ошибка загрузки - \(error.localizedDescription)")
+                fulfillCompletionOnTheMainThread(.failure(error))
+            }
+        }
+
+        task.resume()
+    }
+    
+    public func changeLike(photoId: String, isLike: Bool, _ completion: @escaping (Result<Void, Error>) -> Void) {
+        assert(Thread.isMainThread)
+        
+        let fulfillCompletionOnTheMainThread: (Result<Void, Error>) -> Void = { result in
+            DispatchQueue.main.async {
+                completion(result)
+            }
+        }
+        
+        task?.cancel()
+        
+        guard let likeRequest = makeLikeRequest(photoId: photoId, isLike: isLike) else {
+            let error = NetworkError.invalidRequest
+            print("[ImagesListService.changeLike]: Ошибка создания URLRequest - \(error)")
+            fulfillCompletionOnTheMainThread(.failure(error))
+            return
+        }
+        
+        let task = urlSession.objectTask(for: likeRequest) { [weak self] (result: Result<LikePhotoResponse, Error>) in
+            guard let self else { return }
+            
+            defer {
+                DispatchQueue.main.async {
+                    self.task = nil
+                }
+            }
+            
+            switch result {
+            case .success(_):
+                if let index = self.photos.firstIndex(where: { $0.id == photoId }) {
+                    let oldPhoto = self.photos[index]
+                    let updatedPhoto = Photo(
+                        id: oldPhoto.id,
+                        size: oldPhoto.size,
+                        createdAt: oldPhoto.createdAt,
+                        welcomeDescription: oldPhoto.welcomeDescription,
+                        thumbImageURL: oldPhoto.thumbImageURL,
+                        largeImageURL: oldPhoto.largeImageURL,
+                        fullImageURL: oldPhoto.fullImageURL,
+                        isLiked: !oldPhoto.isLiked
+                    )
+                    self.photos[index] = updatedPhoto
+                    NotificationCenter.default.post(name: ImagesListService.didChangeNotification, object: self)
+                } else {
+                    print("[ImagesListService.changeLike]: Фото с id \(photoId) не найдено в массиве")
+                }
+                
+                fulfillCompletionOnTheMainThread(.success(()))
+            case .failure(let error):
+                print("[ImagesListService.changeLike]: Ошибка загрузки - \(error.localizedDescription)")
+                fulfillCompletionOnTheMainThread(.failure(error))
+            }
+        }
+        
+        task.resume()
+    }
+    
+    func cleanUp() {
+        photos.removeAll()
+    }
+    
+    // MARK: - Private Methods
+    private func makeImagesRequest(page: Int?) -> URLRequest? {
+        guard let url = URL(
+                string: "/photos"
+                + "?page=\(page ?? 1)",
+                relativeTo: Constants.defaultBaseURL
+              ) else {
+            return nil
+        }
+        
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
path: ImageFeed/ImagesList/ImagesListService.swift
user: login: ValeriUst
body: Можно создать отдельный `enum` типа `String` для `http` методов
created_at: 2025-04-11T14:44:47Z
author_association: NONE
diff_hunk: @@ -38,76 +55,162 @@ final class ImagesListViewController: UIViewController {
                 return
             }
             
-            let image = UIImage(named: photosName[indexPath.row])
-            viewController.image = image
+            let photo = photos[indexPath.row]
+            
+            viewController.image = photo
         } else {
             super.prepare(for: segue, sender: sender)
         }
     }
+
+    
+    deinit {
+        if let observer = imagesListServiceObserver {
+            NotificationCenter.default.removeObserver(observer)
+        }
+    }
+
     
     // MARK: - Public Methods
-    func configureCell(for cell: ImagesListCell, with indexPath: IndexPath) {
-        // Получаем имя изображения для картинки
-        let imageName = String(indexPath.row)
-        guard let cardImage = UIImage(named: imageName) else {
-            print("Ошибка: Изображение \(imageName) не найдено")
+    func configureCell(
+        for cell: ImagesListCell,
+        with indexPath: IndexPath,
+        from photo: Photo
+    ) {
+        guard let url = URL(string: photo.thumbImageURL) else {
+            print("Ошибка: URL некорректен")
             return
         }
-        cell.cardImage.image = cardImage
         
-        cell.label.text = dateFormatter.string(from: Date())
+        guard let stub = UIImage(named: "Stub") else {
+            print("Ошибка: заглушка не найдена")
+            return
+        }
+        
+        cell.cardImage.kf.cancelDownloadTask()
+        cell.cardImage.image = nil
+        cell.cardImage.kf.setImage(with: url, placeholder: stub, options: nil)
+        cell.cardImage.kf.indicatorType = .activity
+        
+        let createdAt = photo.createdAt
+        
+        if let createdAt {
+            cell.label.text = dateFormatter.string(from: createdAt)
+        } else {
+            cell.label.text = "Дата не указана"
+        }
+        
+        let likeImageName = photo.isLiked ? "Favorites-Active" : "Favorites-No Active"
         
-        let likeImageName = indexPath.row % 2 == 0 ? "Favorites-Active" : "Favorites-No Active"
         if let likeImage = UIImage(named: likeImageName) {
             cell.likeButton.setImage(likeImage, for: .normal)
         } else {
             print("Ошибка: Изображение \(likeImageName) не найдено")
         }
     }
-
     
+    func tableView(
+        _ tableView: UITableView,
+        willDisplay cell: UITableViewCell,
+        forRowAt indexPath: IndexPath
+    ) {
+        if indexPath.row == photos.count - 1 {
+            imagesListService.fetchPhotosNextPage { _ in }
+        }
+    }
+
     // MARK: - Private Methods
     private func setupTableView() {
         tableView.rowHeight = 300
         tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
     }
+    
+    private func updateTableViewAnimated() {
+        let oldCount = photos.count
+        let newPhotos = imagesListService.photos
+
+        guard newPhotos.count > oldCount else {
+            return
+        }
+
+        let newPhotosToAdd = Array(newPhotos.suffix(from: oldCount))
+        photos.append(contentsOf: newPhotosToAdd)
+
+        let indexPaths = (oldCount..<photos.count).map { IndexPath(row: $0, section: 0) }
+
+        tableView.performBatchUpdates {
+            tableView.insertRows(at: indexPaths, with: .automatic)
+        }
+    }
+
 }
 
 extension ImagesListViewController: UITableViewDataSource {
-    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
-        return photosName.count
+    func tableView(
+        _ tableView: UITableView,
+        numberOfRowsInSection section: Int
+    ) -> Int {
+        return photos.count
path: ImageFeed/ImagesList/ImagesListViewController.swift
user: login: ValeriUst
body: В однострочных выражениях `return` лучше опускать
created_at: 2025-04-11T14:46:38Z
author_association: NONE
diff_hunk: @@ -154,7 +155,12 @@ final class ProfileViewController: UIViewController {
     }
     
     @objc private func didTapLogoutButton() {
-        // TODO: - Добавить логику при нажатии на кнопку логаута
-        OAuth2TokenStorage().clearToken()
+        let alert = UIAlertController(title: "Пока, пока!", message: "Уверены что хотите выйти?", preferredStyle: .alert)
path: ImageFeed/Profile/ProfileViewController.swift
user: login: ValeriUst
body: Упустил запятую:
`"Уверены что хотите выйти?"` -> `"Уверены, что хотите выйти?"`
created_at: 2025-04-11T14:49:06Z
author_association: NONE

PR comments:
user: login: ValeriUst
created_at: 2025-04-11T14:57:55Z
body: @resxton, Привет! 

⚠️ Ты правильно реализовал получение токена — при вводе логина и пароля он действительно успешно приходит от сервера. Это видно из логов:
```
[OAuth2Service.fetchOAuthToken]: Успешно получен токен: ...
```
Однако после этого приложение "зависает" — следующий экран не открывается, потому что возникает ошибка декодирования при получении профиля пользователя.

Вот, что пишет Xcode в логах:
```
[ProfileService.fetchProfile]: NetworkError - Токен уже использован  
[URLSession.objectTask]: Ошибка декодирования - The data couldn’t be read because it is missing.
```
Это означает, что:
- Запрос на получение профиля отправляется;
- Сервер что-то отвечает, но JSON-ответ не соответствует модели, в которую ты пытаешься его декодировать.
Необходимо исправить это критическое замечание (Пункт 2.2)

<img width="1440" alt="Снимок экрана 2025-04-11 в 21 16 31" src="https://github.com/user-attachments/assets/5425da65-59d8-43e5-885a-c990d51399ca" />


author_association: NONE
user: login: ValeriUst
created_at: 2025-04-11T15:01:00Z
body: ![IMAGE 2025-04-11 21:58:20](https://github.com/user-attachments/assets/4ba9c4f3-3550-4c5a-9df8-7c65c5ed4390)
⚠️ Необходимо исправить шрифт кнопки "Войти" в соответствии с дизайном Figma (Пункт 9).
author_association: NONE
user: login: resxton
created_at: 2025-04-11T15:43:09Z
body: > ![IMAGE 2025-04-11 21:58:20](https://private-user-images.githubusercontent.com/117132855/432816180-4ba9c4f3-3550-4c5a-9df8-7c65c5ed4390.jpg?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDQzODYzNjgsIm5iZiI6MTc0NDM4NjA2OCwicGF0aCI6Ii8xMTcxMzI4NTUvNDMyODE2MTgwLTRiYTljNGYzLTM1NTAtNGM1YS05ZGY4LTdjNjVjNWVkNDM5MC5qcGc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNDExJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDQxMVQxNTQxMDhaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT00ZDJlZGNlMDc2OTk4OGRhOTEyNDI2OWEyNzI3MjI2ODZkNTQ4NTJhOTMxNzQ1NzRiNTNjODNjZjQxMzU0NGZjJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.p5YwnMNsB_eDNzVJ7jW1um1qpuHPTwxUNcde3UJ4pLc) ⚠️ Необходимо исправить шрифт кнопки "Войти" в соответствии с дизайном Figma (Пункт 9).

была аналогичная проблема, https://github.com/resxton/ImageFeed/pull/3#issuecomment-2697504623
<img width="738" alt="Снимок экрана 2025-04-11 в 18 43 02" src="https://github.com/user-attachments/assets/f4b6f08b-fbc3-4e1c-8e6e-4e4d93c831c8" />

author_association: OWNER
user: login: resxton
created_at: 2025-04-11T21:29:48Z
body: > @resxton, Привет!
> 
> ⚠️ Ты правильно реализовал получение токена — при вводе логина и пароля он действительно успешно приходит от сервера. Это видно из логов:
> 
> ```
> [OAuth2Service.fetchOAuthToken]: Успешно получен токен: ...
> ```
> 
> Однако после этого приложение "зависает" — следующий экран не открывается, потому что возникает ошибка декодирования при получении профиля пользователя.
> 
> Вот, что пишет Xcode в логах:
> 
> ```
> [ProfileService.fetchProfile]: NetworkError - Токен уже использован  
> [URLSession.objectTask]: Ошибка декодирования - The data couldn’t be read because it is missing.
> ```
> 
> Это означает, что:
> 
> * Запрос на получение профиля отправляется;
> * Сервер что-то отвечает, но JSON-ответ не соответствует модели, в которую ты пытаешься его декодировать.
>   Необходимо исправить это критическое замечание (Пункт 2.2)
> 
> <img alt="Снимок экрана 2025-04-11 в 21 16 31" width="1440" src="https://private-user-images.githubusercontent.com/117132855/432814726-5425da65-59d8-43e5-885a-c990d51399ca.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDQ0MDMzNDQsIm5iZiI6MTc0NDQwMzA0NCwicGF0aCI6Ii8xMTcxMzI4NTUvNDMyODE0NzI2LTU0MjVkYTY1LTU5ZDgtNDNlNS04ODVhLWM5OTBkNTEzOTljYS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNDExJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDQxMVQyMDI0MDRaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT0yYmEzMWEwODI1MzM1YTQ1ZDNlZDVhNTNiZDVlN2ZmOTE3M2E2ODgxZmZhZTdlMTM3MzhjMDE1ZGYwNzlkMjNhJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.Qo8f5KzTJ2AY27cEubAMm76OGi7omwHydgv8I1lxKsg">

и я что-то не могу повторить ошибку, что с симулятора, что с реального девайса все проходит хорошо, на разных аккаунтах попробовал
author_association: OWNER
user: login: resxton
created_at: 2025-04-15T15:28:35Z
body: на всякий случай,
https://github.com/resxton/ImageFeed/pull/3#issuecomment-2698255042 – по кнопке
по рантайму – я перепробовал 3 аккаунта, симулятор и реальный девайс, но не смог повторить баг.
author_association: OWNER
user: login: resxton
created_at: 2025-04-15T20:14:35Z
body: уже даже UI тест на авторизацию сделал, все отлично проходит (конечно, на мвп перенес, но логика все равно та же)😭
author_association: OWNER
user: login: ValeriUst
created_at: 2025-04-16T05:05:30Z
body: @resxton, Я сразу при регистрации поймала баг — делюсь подробным разбором с пруфами.
<img width="1440" alt="Снимок экрана 2025-04-16 в 11 38 26" src="https://github.com/user-attachments/assets/e7f2a7f5-8d85-48de-ac11-f3c3eb3be620" />


https://github.com/user-attachments/assets/e10706a2-a619-46e6-b205-825526e1dbba



**Как воспроизвести:**
1) Открываю приложение
2) Нажимаю кнопку «Войти»
3) Открывается WebView с формой логина
4) Ввожу логин и пароль
5) Нажимаю «Log In»
6) POST-запрос отправляется → токен успешно получен
7) Запрашиваются данные профиля → возникает ошибка декодирования

**Почему так происходит:**
Вот текущая модель `ProfileResult`:
```
struct ProfileResult: Codable {
    var username: String
    var firstName: String
    var lastName: String
    var bio: String?
    
    enum CodingKeys: String, CodingKey {
        case username
        case firstName = "first_name"
        case lastName = "last_name"
        case bio
    }
}

```
Проблема в том, что `lastName` приходит как `null`, а у тебя он не опционален. В результате — crash при декодировании.

Вот пример респондса от сервера:
```
"id":"7otKKchRTAk","updated_at":"2025-04-16T04:37:21Z","username":"ypios","name":"Yandex","first_name":"Yandex","last_name":null
```
у меня не пришел `last_name` и сразу краш. 

**Рекомендации по улучшению модели:**
- Используй `let` вместо `var`, если свойства не изменяются.
- Используй `Decodable` вместо `Codable`, если тебе не нужно кодировать обратно.
- Сделай все поля опциональными, если они могут не прийти с сервера:
```
struct ProfileResult: Decodable {
    let username: String?
    let firstName: String?
    let lastName: String?
    let bio: String?

    enum CodingKeys: String, CodingKey {
        case username
        case firstName = "first_name"
        case lastName = "last_name"
        case bio
    }
}
```
После этой правки декодирование будет происходить безопасно, даже если какие-то поля отсутствуют или `null`.
author_association: NONE
user: login: ValeriUst
created_at: 2025-04-16T05:07:10Z
body: @resxton, По поводу шрифта я тебя поняла, данный крит убираю.
author_association: NONE
user: login: resxton
created_at: 2025-04-16T05:24:30Z
body: > @resxton, Я сразу при регистрации поймала баг — делюсь подробным разбором с пруфами.
> 
> <img width="1440" alt="Снимок экрана 2025-04-16 в 11 38 26" src="https://github.com/user-attachments/assets/e7f2a7f5-8d85-48de-ac11-f3c3eb3be620" />
> 
> 
> 
> 
> 
> https://github.com/user-attachments/assets/e10706a2-a619-46e6-b205-825526e1dbba
> 
> 
> 
> 
> 
> 
> 
> **Как воспроизвести:**
> 
> 1) Открываю приложение
> 
> 2) Нажимаю кнопку «Войти»
> 
> 3) Открывается WebView с формой логина
> 
> 4) Ввожу логин и пароль
> 
> 5) Нажимаю «Log In»
> 
> 6) POST-запрос отправляется → токен успешно получен
> 
> 7) Запрашиваются данные профиля → возникает ошибка декодирования
> 
> 
> 
> **Почему так происходит:**
> 
> Вот текущая модель `ProfileResult`:
> 
> ```
> 
> struct ProfileResult: Codable {
> 
>     var username: String
> 
>     var firstName: String
> 
>     var lastName: String
> 
>     var bio: String?
> 
>     
> 
>     enum CodingKeys: String, CodingKey {
> 
>         case username
> 
>         case firstName = "first_name"
> 
>         case lastName = "last_name"
> 
>         case bio
> 
>     }
> 
> }
> 
> 
> 
> ```
> 
> Проблема в том, что `lastName` приходит как `null`, а у тебя он не опционален. В результате — crash при декодировании.
> 
> 
> 
> Вот пример респондса от сервера:
> 
> ```
> 
> "id":"7otKKchRTAk","updated_at":"2025-04-16T04:37:21Z","username":"ypios","name":"Yandex","first_name":"Yandex","last_name":null
> 
> ```
> 
> у меня не пришел `last_name` и сразу краш. 
> 
> 
> 
> **Рекомендации по улучшению модели:**
> 
> - Используй `let` вместо `var`, если свойства не изменяются.
> 
> - Используй `Decodable` вместо `Codable`, если тебе не нужно кодировать обратно.
> 
> - Сделай все поля опциональными, если они могут не прийти с сервера:
> 
> ```
> 
> struct ProfileResult: Decodable {
> 
>     let username: String?
> 
>     let firstName: String?
> 
>     let lastName: String?
> 
>     let bio: String?
> 
> 
> 
>     enum CodingKeys: String, CodingKey {
> 
>         case username
> 
>         case firstName = "first_name"
> 
>         case lastName = "last_name"
> 
>         case bio
> 
>     }
> 
> }
> 
> ```
> 
> После этой правки декодирование будет происходить безопасно, даже если какие-то поля отсутствуют или `null`.

ашалеть😇 спасибо
author_association: OWNER
user: login: ValeriUst
created_at: 2025-04-16T05:42:36Z
body: @resxton, отлично! Критические замечания исправлены, работа принята.
author_association: NONE