diff --git a/ImageFeed.xcodeproj/project.pbxproj b/ImageFeed.xcodeproj/project.pbxproj index 9847820..1ae1d13 100644 --- a/ImageFeed.xcodeproj/project.pbxproj +++ b/ImageFeed.xcodeproj/project.pbxproj @@ -15,8 +15,27 @@ E7F6CAE02F6D92BA00CECCE0 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E7CA17482F6D8ECA00AFB99C /* WebKit.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + E74DEF602FC3AB7900625DAE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E75C1E3D2EB7AD1400947D37 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E75C1E442EB7AD1400947D37; + remoteInfo = ImageFeed; + }; + E7BAF71B2FC389A2009E0D80 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E75C1E3D2EB7AD1400947D37 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E75C1E442EB7AD1400947D37; + remoteInfo = ImageFeed; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + E74DEF5A2FC3AB7900625DAE /* ImageFeedUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ImageFeedUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E79FB9AC2EBA21BC00E4E049 /* ImageFeed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ImageFeed.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E7BAF7172FC389A2009E0D80 /* ImageFeedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ImageFeedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E7CA17482F6D8ECA00AFB99C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -31,6 +50,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + E74DEF5B2FC3AB7900625DAE /* ImageFeedUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ImageFeedUITests; + sourceTree = ""; + }; E75C1E822EB8425E00947D37 /* ImageFeed */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -39,9 +63,21 @@ path = ImageFeed; sourceTree = ""; }; + E7BAF7182FC389A2009E0D80 /* ImageFeedTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ImageFeedTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + E74DEF572FC3AB7900625DAE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E75C1E422EB7AD1400947D37 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -55,6 +91,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E7BAF7142FC389A2009E0D80 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -63,7 +106,11 @@ children = ( E75C1E822EB8425E00947D37 /* ImageFeed */, E79FB9AC2EBA21BC00E4E049 /* ImageFeed.app */, + E7BAF7182FC389A2009E0D80 /* ImageFeedTests */, + E74DEF5B2FC3AB7900625DAE /* ImageFeedUITests */, E7CA17472F6D8ECA00AFB99C /* Frameworks */, + E7BAF7172FC389A2009E0D80 /* ImageFeedTests.xctest */, + E74DEF5A2FC3AB7900625DAE /* ImageFeedUITests.xctest */, ); sourceTree = ""; }; @@ -78,6 +125,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + E74DEF592FC3AB7900625DAE /* ImageFeedUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E74DEF642FC3AB7900625DAE /* Build configuration list for PBXNativeTarget "ImageFeedUITests" */; + buildPhases = ( + E74DEF562FC3AB7900625DAE /* Sources */, + E74DEF572FC3AB7900625DAE /* Frameworks */, + E74DEF582FC3AB7900625DAE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E74DEF612FC3AB7900625DAE /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E74DEF5B2FC3AB7900625DAE /* ImageFeedUITests */, + ); + name = ImageFeedUITests; + packageProductDependencies = ( + ); + productName = ImageFeedUITests; + productReference = E74DEF5A2FC3AB7900625DAE /* ImageFeedUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E75C1E442EB7AD1400947D37 /* ImageFeed */ = { isa = PBXNativeTarget; buildConfigurationList = E75C1E582EB7AD1600947D37 /* Build configuration list for PBXNativeTarget "ImageFeed" */; @@ -105,6 +175,29 @@ productReference = E79FB9AC2EBA21BC00E4E049 /* ImageFeed.app */; productType = "com.apple.product-type.application"; }; + E7BAF7162FC389A2009E0D80 /* ImageFeedTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E7BAF71D2FC389A2009E0D80 /* Build configuration list for PBXNativeTarget "ImageFeedTests" */; + buildPhases = ( + E7BAF7132FC389A2009E0D80 /* Sources */, + E7BAF7142FC389A2009E0D80 /* Frameworks */, + E7BAF7152FC389A2009E0D80 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E7BAF71C2FC389A2009E0D80 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E7BAF7182FC389A2009E0D80 /* ImageFeedTests */, + ); + name = ImageFeedTests; + packageProductDependencies = ( + ); + productName = ImageFeedTests; + productReference = E7BAF7172FC389A2009E0D80 /* ImageFeedTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -112,12 +205,20 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 1640; TargetAttributes = { + E74DEF592FC3AB7900625DAE = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = E75C1E442EB7AD1400947D37; + }; E75C1E442EB7AD1400947D37 = { CreatedOnToolsVersion = 16.4; }; + E7BAF7162FC389A2009E0D80 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = E75C1E442EB7AD1400947D37; + }; }; }; buildConfigurationList = E75C1E402EB7AD1400947D37 /* Build configuration list for PBXProject "ImageFeed" */; @@ -141,11 +242,20 @@ projectRoot = ""; targets = ( E75C1E442EB7AD1400947D37 /* ImageFeed */, + E7BAF7162FC389A2009E0D80 /* ImageFeedTests */, + E74DEF592FC3AB7900625DAE /* ImageFeedUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + E74DEF582FC3AB7900625DAE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E75C1E432EB7AD1400947D37 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -153,9 +263,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E7BAF7152FC389A2009E0D80 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + E74DEF562FC3AB7900625DAE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E75C1E412EB7AD1400947D37 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -163,9 +287,69 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E7BAF7132FC389A2009E0D80 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + E74DEF612FC3AB7900625DAE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E75C1E442EB7AD1400947D37 /* ImageFeed */; + targetProxy = E74DEF602FC3AB7900625DAE /* PBXContainerItemProxy */; + }; + E7BAF71C2FC389A2009E0D80 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E75C1E442EB7AD1400947D37 /* ImageFeed */; + targetProxy = E7BAF71B2FC389A2009E0D80 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + E74DEF622FC3AB7900625DAE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ImageFeed; + }; + name = Debug; + }; + E74DEF632FC3AB7900625DAE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ImageFeed; + }; + name = Release; + }; E75C1E592EB7AD1600947D37 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -345,9 +529,60 @@ }; name = Release; }; + E7BAF71E2FC389A2009E0D80 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ImageFeed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ImageFeed"; + }; + name = Debug; + }; + E7BAF71F2FC389A2009E0D80 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ImageFeed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ImageFeed"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + E74DEF642FC3AB7900625DAE /* Build configuration list for PBXNativeTarget "ImageFeedUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E74DEF622FC3AB7900625DAE /* Debug */, + E74DEF632FC3AB7900625DAE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; E75C1E402EB7AD1400947D37 /* Build configuration list for PBXProject "ImageFeed" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -366,6 +601,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E7BAF71D2FC389A2009E0D80 /* Build configuration list for PBXNativeTarget "ImageFeedTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E7BAF71E2FC389A2009E0D80 /* Debug */, + E7BAF71F2FC389A2009E0D80 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/ImageFeed/Auth/AuthHelper.swift b/ImageFeed/Auth/AuthHelper.swift new file mode 100644 index 0000000..11086f9 --- /dev/null +++ b/ImageFeed/Auth/AuthHelper.swift @@ -0,0 +1,61 @@ +import Foundation + +final class AuthHelper: AuthHelperProtocol { + // MARK: - Properties + + let configuration: AuthConfiguration + + // MARK: - Initialization + + init(configuration: AuthConfiguration = .standard) { + self.configuration = configuration + } + + // MARK: - AuthHelperProtocol + + func makeAuthRequest() -> URLRequest { + let url = makeAuthUrl() + + return URLRequest(url: url) + } + + func extractAuthCode(from url: URL) -> String? { + if let urlComponents = URLComponents(string: url.absoluteString), + urlComponents.path == "/oauth/authorize/native", + let items = urlComponents.queryItems, + let codeItem = items.first(where: { $0.name == "code" }) + { + return codeItem.value + } else { + return nil + } + } + + // MARK: - Internal Methods + + func makeAuthUrl() -> URL { + guard var urlComponents = URLComponents(string: configuration.unsplashAuthorizeURLString) else { + preconditionFailure("Invalid unsplashAuthorizeURLString: \(configuration.unsplashAuthorizeURLString)") + } + + urlComponents.queryItems = [ + URLQueryItem(name: "client_id", value: configuration.accessKey), + URLQueryItem(name: "redirect_uri", value: configuration.redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: configuration.accessScope) + ] + + guard let url = urlComponents.url else { + preconditionFailure("Failed to build URL from components: \(urlComponents)") + } + + return url + } +} + +// MARK: - AuthHelperProtocol + +protocol AuthHelperProtocol { + func makeAuthRequest() -> URLRequest + func extractAuthCode(from url: URL) -> String? +} diff --git a/ImageFeed/Auth/AuthViewController.swift b/ImageFeed/Auth/AuthViewController.swift index 7a24fb4..860f2fc 100644 --- a/ImageFeed/Auth/AuthViewController.swift +++ b/ImageFeed/Auth/AuthViewController.swift @@ -1,19 +1,17 @@ import UIKit -import Logging final class AuthViewController: UIViewController { // MARK: - Properties - private let logger = Logger(label: "AuthViewController") - weak var delegate: AuthViewControllerDelegate? - // MARK: - Lifecycle + // MARK: - Navigation override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "ShowWebView", let webVC = segue.destination as? WebViewViewController { webVC.delegate = self + webVC.configure(WebViewPresenter(authHelper: AuthHelper())) } else { super.prepare(for: segue, sender: sender) } @@ -36,8 +34,7 @@ extension AuthViewController: WebViewViewControllerDelegate { switch result { case .success: self.delegate?.didAuthenticate(self) - case .failure(let error): - self.logger.error("fetchOAuthToken failed - \(error.localizedDescription)") + case .failure: showErrorAlert(message: "Не удалось войти в систему") } } diff --git a/ImageFeed/Auth/WebViewPresenter.swift b/ImageFeed/Auth/WebViewPresenter.swift new file mode 100644 index 0000000..ba059c0 --- /dev/null +++ b/ImageFeed/Auth/WebViewPresenter.swift @@ -0,0 +1,73 @@ +import Foundation + +final class WebViewPresenter: WebViewPresenterProtocol { + // MARK: - Properties + + weak var view: WebViewViewControllerProtocol? + + var authHelper: AuthHelperProtocol + + // MARK: - initializer + + init(authHelper: AuthHelperProtocol) { + self.authHelper = authHelper + } + + // MARK: - WebViewPresenterProtocol + + func viewDidLoad() { + let request = authHelper.makeAuthRequest() + view?.load(request: request) + + didUpdateProgressValue(0) + } + + func didUpdateProgressValue(_ newValue: Double) { + let newProgressValue = Float(newValue) + view?.setProgressValue(newProgressValue) + + let shouldHideProgress = shouldHideProgress(for: newProgressValue) + view?.setProgressHidden(shouldHideProgress) + } + + func extractAuthCode(from url: URL) -> String? { + authHelper.extractAuthCode(from: url) + } + + // MARK: - Internal Methods + + func shouldHideProgress(for value: Float) -> Bool { + abs(value - 1.0) <= 0.0001 + } + + // MARK: - Private Methods + + private func makeAuthRequest() -> URLRequest { + guard var urlComponents = URLComponents(string: AuthConfiguration.standard.unsplashAuthorizeURLString) else { + preconditionFailure("Invalid unsplashAuthorizeURLString: \(AuthConfiguration.standard.unsplashAuthorizeURLString)") + } + + urlComponents.queryItems = [ + URLQueryItem(name: "client_id", value: AuthConfiguration.standard.accessKey), + URLQueryItem(name: "redirect_uri", value: AuthConfiguration.standard.redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: AuthConfiguration.standard.accessScope), + ] + + guard let url = urlComponents.url else { + preconditionFailure("Failed to build URL from components: \(urlComponents)") + } + + return URLRequest(url: url) + } +} + +// MARK: - WebViewPresenterProtocol + +protocol WebViewPresenterProtocol: AnyObject { + var view: WebViewViewControllerProtocol? { get set } + + func viewDidLoad() + func didUpdateProgressValue(_ newValue: Double) + func extractAuthCode(from url: URL) -> String? +} diff --git a/ImageFeed/Auth/WebViewViewController.swift b/ImageFeed/Auth/WebViewViewController.swift index 1133133..ad2a44a 100644 --- a/ImageFeed/Auth/WebViewViewController.swift +++ b/ImageFeed/Auth/WebViewViewController.swift @@ -1,9 +1,7 @@ import UIKit import WebKit -import Logging - -final class WebViewViewController: UIViewController { +final class WebViewViewController: UIViewController & WebViewViewControllerProtocol { // MARK: - Outlets @IBOutlet private weak var webView: WKWebView? @@ -11,57 +9,54 @@ final class WebViewViewController: UIViewController { // MARK: - Properties + private var presenter: WebViewPresenterProtocol! weak var delegate: WebViewViewControllerDelegate? - private let logger = Logger(label: "WebViewViewController") private var estimatedProgressObservation: NSKeyValueObservation? + // MARK: - Configuration + + func configure(_ presenter: WebViewPresenterProtocol) { + self.presenter = presenter + presenter.view = self + } + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() + webView?.accessibilityIdentifier = "UnsplashWebView" // для тестов webView?.navigationDelegate = self - estimatedProgressObservation = webView?.observe( - \.estimatedProgress, - options: [] - ) { [weak self] _, _ in - guard let self else { return } + setupProgressObservation() - self.updateProgress() - } - loadAuthView() + presenter.viewDidLoad() } - // MARK: - Private Methods - - private func loadAuthView() { - guard var urlComponents = URLComponents(string: Constants.unsplashAuthorizeURLString) else { - logger.error("loadAuthView: failed to create URLComponents from \(Constants.unsplashAuthorizeURLString)") - return - } + // MARK: - WebViewViewControllerProtocol - urlComponents.queryItems = [ - URLQueryItem(name: "client_id", value: Constants.accessKey), - URLQueryItem(name: "redirect_uri", value: Constants.redirectURI), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "scope", value: Constants.accessScope), - ] + func load(request: URLRequest) { + webView?.load(request) + } - guard let url = urlComponents.url else { - logger.error("loadAuthView: failed to build URL from URLComponents: \(urlComponents)") - return - } + func setProgressValue(_ newValue: Float) { + progressView?.progress = newValue + } - let request = URLRequest(url: url) - webView?.load(request) + func setProgressHidden(_ isHidden: Bool) { + progressView?.isHidden = isHidden } - private func updateProgress() { - guard let webView else { return } + // MARK: - Private Methods - progressView?.progress = Float(webView.estimatedProgress) - progressView?.isHidden = fabs(webView.estimatedProgress - 1.0) <= 0.0001 + private func setupProgressObservation() { + estimatedProgressObservation = webView?.observe( + \.estimatedProgress, + options: [] + ) { [weak self] _, _ in + guard let self else { return } + self.presenter.didUpdateProgressValue(self.webView?.estimatedProgress ?? 0) + } } } @@ -82,17 +77,9 @@ extension WebViewViewController: WKNavigationDelegate { } private func code(from navigationAction: WKNavigationAction) -> String? { - if - let url = navigationAction.request.url, - let urlComponents = URLComponents(string: url.absoluteString), - urlComponents.path == "/oauth/authorize/native", - let items = urlComponents.queryItems, - let codeItem = items.first(where: { $0.name == "code" }) - { - return codeItem.value - } else { - return nil - } + guard let url = navigationAction.request.url else { return nil } + + return presenter.extractAuthCode(from: url) } } @@ -103,3 +90,13 @@ protocol WebViewViewControllerDelegate: AnyObject { func webViewViewControllerDidCancel(_ vc: WebViewViewController) } + +// MARK: - WebViewViewControllerProtocol + +protocol WebViewViewControllerProtocol: AnyObject { + func load(request: URLRequest) + + func setProgressValue(_ newValue: Float) + + func setProgressHidden(_ isHidden: Bool) +} diff --git a/ImageFeed/Helpers/AuthConfiguration.swift b/ImageFeed/Helpers/AuthConfiguration.swift new file mode 100644 index 0000000..6eea84f --- /dev/null +++ b/ImageFeed/Helpers/AuthConfiguration.swift @@ -0,0 +1,47 @@ +enum Constants { + static let accessKey = "AD3mth0JjFP4_HSz59cknghLFx5MHnVAlQxEaCIbvwg" + static let secretKey = "4Hd6rAavk-ZRIp5jD81CaxQFaeBVkgvDriLU0VUEf90" + static let redirectURI = "urn:ietf:wg:oauth:2.0:oob" + static let accessScope = "public+read_user+write_likes" + static let unsplashAuthorizeURLString = "https://unsplash.com/oauth/authorize" + static let defaultBaseURLString = "https://api.unsplash.com" + static let photosPerPage = 10 +} + +struct AuthConfiguration { + static let standard = AuthConfiguration( + accessKey: Constants.accessKey, + secretKey: Constants.secretKey, + redirectURI: Constants.redirectURI, + accessScope: Constants.accessScope, + unsplashAuthorizeURLString: Constants.unsplashAuthorizeURLString, + defaultBaseURLString: Constants.defaultBaseURLString, + photosPerPage: Constants.photosPerPage + ) + + let accessKey: String + let secretKey: String + let redirectURI: String + let accessScope: String + let unsplashAuthorizeURLString: String + let defaultBaseURLString: String + let photosPerPage: Int + + init( + accessKey: String, + secretKey: String, + redirectURI: String, + accessScope: String, + unsplashAuthorizeURLString: String, + defaultBaseURLString: String, + photosPerPage: Int + ) { + self.accessKey = accessKey + self.secretKey = secretKey + self.redirectURI = redirectURI + self.accessScope = accessScope + self.unsplashAuthorizeURLString = unsplashAuthorizeURLString + self.defaultBaseURLString = defaultBaseURLString + self.photosPerPage = photosPerPage + } +} diff --git a/ImageFeed/Helpers/Constants.swift b/ImageFeed/Helpers/Constants.swift deleted file mode 100644 index 4b0a9ab..0000000 --- a/ImageFeed/Helpers/Constants.swift +++ /dev/null @@ -1,9 +0,0 @@ -enum Constants { - static let accessKey = "AD3mth0JjFP4_HSz59cknghLFx5MHnVAlQxEaCIbvwg" - static let secretKey = "4Hd6rAavk-ZRIp5jD81CaxQFaeBVkgvDriLU0VUEf90" - static let redirectURI = "urn:ietf:wg:oauth:2.0:oob" - static let accessScope = "public+read_user+write_likes" - static let unsplashAuthorizeURLString = "https://unsplash.com/oauth/authorize" - static let defaultBaseURLString = "https://api.unsplash.com" - static let photosPerPage = 10 -} diff --git a/ImageFeed/Helpers/UIColor+Extensions.swift b/ImageFeed/Helpers/UIColor+Extensions.swift index 07a3a40..f4dde3f 100644 --- a/ImageFeed/Helpers/UIColor+Extensions.swift +++ b/ImageFeed/Helpers/UIColor+Extensions.swift @@ -1,6 +1,8 @@ import UIKit extension UIColor { - static let background = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 1) // #1A1B22 - static let backgroundTransparent = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 0) + static let background = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 1) + static let backgroundTransparent = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 0) + static let subtitleGray = UIColor(red: 174/255, green: 175/255, blue: 180/255, alpha: 1) + static let logoutRed = UIColor(red: 245/255, green: 107/255, blue: 108/255, alpha: 1) } diff --git a/ImageFeed/Helpers/UIViewController+Extensions.swift b/ImageFeed/Helpers/UIViewController+Extensions.swift index 1ea261c..d819a9a 100644 --- a/ImageFeed/Helpers/UIViewController+Extensions.swift +++ b/ImageFeed/Helpers/UIViewController+Extensions.swift @@ -1,13 +1,10 @@ import UIKit extension UIViewController { - /// Алерт ошибки с типовым заголовком и одной кнопкой «ОК». func showErrorAlert(message: String) { showAlert(title: "Что-то пошло не так(", message: message) } - /// Универсальный алерт. По умолчанию показывает одну кнопку «ОК»; - /// для confirmation-диалогов передайте свой набор UIAlertAction. func showAlert( title: String, message: String, diff --git a/ImageFeed/ImagesList/ImagesListPresenter.swift b/ImageFeed/ImagesList/ImagesListPresenter.swift new file mode 100644 index 0000000..cbe37ff --- /dev/null +++ b/ImageFeed/ImagesList/ImagesListPresenter.swift @@ -0,0 +1,76 @@ +import Foundation + +final class ImagesListPresenter: ImagesListPresenterProtocol { + // MARK: - Properties + + weak var view: ImagesListViewControllerProtocol? + + private(set) var photos: [Photo] = [] + private var imagesListServiceObserver: NSObjectProtocol? + + // MARK: - ImagesListPresenterProtocol + + func viewDidLoad() { + imagesListServiceObserver = NotificationCenter.default.addObserver( + forName: ImagesListService.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + let oldCount = self.photos.count + self.photos = ImagesListService.shared.photos + self.view?.updateTableViewAnimated(oldCount: oldCount, newCount: self.photos.count) + } + + ImagesListService.shared.fetchPhotosNextPage() + } + + func photo(at indexPath: IndexPath) -> Photo { + photos[indexPath.row] + } + + func willDisplayRow(at indexPath: IndexPath) { + if indexPath.row == photos.count - 1 { + ImagesListService.shared.fetchPhotosNextPage() + } + } + + func didTapLike(at indexPath: IndexPath) { + let photo = photos[indexPath.row] + view?.setLoading(true) + ImagesListService.shared.changeLike(photoId: photo.id, isLike: !photo.isLiked) { [weak self] result in + guard let self else { return } + self.view?.setLoading(false) + + switch result { + case .success: + self.photos = ImagesListService.shared.photos + self.view?.reloadRow(at: indexPath) + case .failure: + self.view?.showLikeError() + } + } + } +} + +// MARK: - ImagesListViewControllerProtocol + +protocol ImagesListViewControllerProtocol: AnyObject { + func updateTableViewAnimated(oldCount: Int, newCount: Int) + func reloadRow(at indexPath: IndexPath) + func setLoading(_ isLoading: Bool) + func showLikeError() +} + +// MARK: - ImagesListPresenterProtocol + +protocol ImagesListPresenterProtocol: AnyObject { + var view: ImagesListViewControllerProtocol? { get set } + var photos: [Photo] { get } + + func viewDidLoad() + func photo(at indexPath: IndexPath) -> Photo + func willDisplayRow(at indexPath: IndexPath) + func didTapLike(at indexPath: IndexPath) +} diff --git a/ImageFeed/ImagesList/ImagesListViewController.swift b/ImageFeed/ImagesList/ImagesListViewController.swift index 1e69e29..278918e 100644 --- a/ImageFeed/ImagesList/ImagesListViewController.swift +++ b/ImageFeed/ImagesList/ImagesListViewController.swift @@ -1,42 +1,35 @@ import UIKit -import Logging -final class ImagesListViewController: UIViewController { +final class ImagesListViewController: UIViewController, ImagesListViewControllerProtocol { // MARK: - Outlets @IBOutlet private weak var tableView: UITableView? + // MARK: - Properties + + private var presenter: ImagesListPresenterProtocol! + // MARK: - Private Properties private static let showSingleImageSegueId = "ShowSingleImage" - private var imagesListServiceObserver: NSObjectProtocol? + // MARK: - Configuration - private var photos: [Photo] = [] + func configure(_ presenter: ImagesListPresenterProtocol) { + self.presenter = presenter + presenter.view = self + } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() - - imagesListServiceObserver = NotificationCenter.default.addObserver( - forName: ImagesListService.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - - let oldCount = self.photos.count - self.photos = ImagesListService.shared.photos - self.updateTableViewAnimated(oldCount: oldCount, newCount: self.photos.count) - } - - ImagesListService.shared.fetchPhotosNextPage() + presenter.viewDidLoad() } - // MARK: - Private Methods + // MARK: - ImagesListViewControllerProtocol - private func updateTableViewAnimated(oldCount: Int, newCount: Int) { + func updateTableViewAnimated(oldCount: Int, newCount: Int) { guard let tableView, newCount > oldCount else { return } tableView.performBatchUpdates { @@ -45,14 +38,30 @@ final class ImagesListViewController: UIViewController { } } + func reloadRow(at indexPath: IndexPath) { + tableView?.reloadRows(at: [indexPath], with: .none) + } + + func setLoading(_ isLoading: Bool) { + if isLoading { + UIBlockingProgressHUD.show() + } else { + UIBlockingProgressHUD.dismiss() + } + } + + func showLikeError() { + showErrorAlert(message: "Не удалось обновить лайк") + } + + // MARK: - Navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == Self.showSingleImageSegueId, let singleImageVC = segue.destination as? SingleImageViewController, - let cell = sender as? ImagesListCell { - - guard let indexPath = tableView?.indexPath(for: cell) else { return } - let photo = photos[indexPath.row] - singleImageVC.imageUrl = photo.largeImageURL + let cell = sender as? ImagesListCell, + let indexPath = tableView?.indexPath(for: cell) { + singleImageVC.imageUrl = presenter.photo(at: indexPath).largeImageURL } else { super.prepare(for: segue, sender: sender) } @@ -63,7 +72,7 @@ final class ImagesListViewController: UIViewController { extension ImagesListViewController { func configCell(for cell: ImagesListCell, with indexPath: IndexPath) { - let photo = photos[indexPath.row] + let photo = presenter.photo(at: indexPath) cell.setImage(photo.regularImageURL) cell.setDate(photo.createdAt) cell.setIsLiked(photo.isLiked) @@ -74,7 +83,7 @@ extension ImagesListViewController { extension ImagesListViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - photos.count + presenter.photos.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -92,46 +101,27 @@ extension ImagesListViewController: UITableViewDataSource { extension ImagesListViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if indexPath.row == photos.count - 1 { - ImagesListService.shared.fetchPhotosNextPage() - } + presenter.willDisplayRow(at: indexPath) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let photo = photos[indexPath.row] + let photo = presenter.photo(at: indexPath) let imageWidth = photo.size.width let imageHeight = photo.size.height let tableWidth = tableView.bounds.width - guard imageWidth > 0 else { - return 0 - } + guard imageWidth > 0 else { return 0 } - let scaledHeight = imageHeight * (tableWidth / imageWidth) - - return scaledHeight + return imageHeight * (tableWidth / imageWidth) } } // MARK: - ImagesListCellDelegate + extension ImagesListViewController: ImagesListCellDelegate { func imagesListCellDidTapLike(_ cell: ImagesListCell) { guard let indexPath = tableView?.indexPath(for: cell) else { return } - let photo = photos[indexPath.row] - UIBlockingProgressHUD.show() - ImagesListService.shared.changeLike(photoId: photo.id, isLike: !photo.isLiked) { [weak self] result in - UIBlockingProgressHUD.dismiss() - - guard let self else { return } - - switch result { - case .success: - self.photos = ImagesListService.shared.photos - self.tableView?.reloadRows(at: [indexPath], with: .none) - case .failure: - self.showErrorAlert(message: "Не удалось обновить лайк") - } - } + presenter.didTapLike(at: indexPath) } } diff --git a/ImageFeed/Main.storyboard b/ImageFeed/Main.storyboard index b6c100d..0be967c 100644 --- a/ImageFeed/Main.storyboard +++ b/ImageFeed/Main.storyboard @@ -42,6 +42,7 @@