diff --git a/Decompositions/.gitkeep b/Decompositions/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/FakeNFT.xcodeproj/project.pbxproj b/FakeNFT.xcodeproj/project.pbxproj index e9ad82fe6f..4ee26c02ab 100644 --- a/FakeNFT.xcodeproj/project.pbxproj +++ b/FakeNFT.xcodeproj/project.pbxproj @@ -26,7 +26,6 @@ 3F478ECF29DB474E00F6D39E /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F478ECE29DB474E00F6D39E /* Colors.swift */; }; 3F478ED129DB476500F6D39E /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F478ED029DB476500F6D39E /* Fonts.swift */; }; 3F603CCD29DB4A53000C43D7 /* ProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = 3F603CCC29DB4A53000C43D7 /* ProgressHUD */; }; - 3F603CD029DB4A74000C43D7 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 3F603CCF29DB4A74000C43D7 /* Kingfisher */; }; 3F68069729CBBAF100B4F915 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F68069629CBBAF100B4F915 /* AppDelegate.swift */; }; 3F68069929CBBAF100B4F915 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F68069829CBBAF100B4F915 /* SceneDelegate.swift */; }; 3F68069B29CBBAF100B4F915 /* ProductDetailsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F68069A29CBBAF100B4F915 /* ProductDetailsTableViewController.swift */; }; @@ -46,6 +45,24 @@ E19CD5AB2A98B56600CA39A5 /* NftImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19CD5AA2A98B56600CA39A5 /* NftImageCollectionViewCell.swift */; }; E1A1B9DA2AA01CE400C3AFBC /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1B9D92AA01CE400C3AFBC /* TabBarController.swift */; }; E1CD40DC2A96BECC00BE7FE8 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E1CD40DB2A96BECC00BE7FE8 /* Localizable.strings */; }; + EA665D102D900E2400BF5DA8 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D0F2D900E1000BF5DA8 /* ProfileViewController.swift */; }; + EA665D122D904BB000BF5DA8 /* ProfileTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D112D904BAB00BF5DA8 /* ProfileTableViewCell.swift */; }; + EA665D302D90833300BF5DA8 /* ProfileModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D2F2D90832D00BF5DA8 /* ProfileModel.swift */; }; + EA665D562D90B3A500BF5DA8 /* EditProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D552D90B39C00BF5DA8 /* EditProfileViewController.swift */; }; + EA665D582D90B48400BF5DA8 /* ProfileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D572D90B47800BF5DA8 /* ProfileService.swift */; }; + EA665D5A2D90B4A600BF5DA8 /* ProfileRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D592D90B49E00BF5DA8 /* ProfileRequest.swift */; }; + EA665D5D2D9197FB00BF5DA8 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = EA665D5C2D9197FB00BF5DA8 /* Kingfisher */; }; + EA665D5F2D92DED100BF5DA8 /* EditProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D5E2D92DEAF00BF5DA8 /* EditProfileView.swift */; }; + EA665D662D98478700BF5DA8 /* My NFT.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D652D98476D00BF5DA8 /* My NFT.swift */; }; + EA665D692D984B3B00BF5DA8 /* MyNFTViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D682D984B2E00BF5DA8 /* MyNFTViewController.swift */; }; + EA665D6B2D984C0F00BF5DA8 /* MyNFTView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D6A2D984BFD00BF5DA8 /* MyNFTView.swift */; }; + EA665D6D2D984C5000BF5DA8 /* NFTCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D6C2D984C3F00BF5DA8 /* NFTCell.swift */; }; + EA665D6F2D98976400BF5DA8 /* MyNFTService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D6E2D98975800BF5DA8 /* MyNFTService.swift */; }; + EA665D712D9897D100BF5DA8 /* MyNFTStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D702D9897CA00BF5DA8 /* MyNFTStorage.swift */; }; + EA665D7E2D9C79D300BF5DA8 /* FavoritesNftViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D7D2D9C79B800BF5DA8 /* FavoritesNftViewController.swift */; }; + EA665D802D9C7A0300BF5DA8 /* FavoritesNftView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D7F2D9C7A0100BF5DA8 /* FavoritesNftView.swift */; }; + EA665D822D9C7A4800BF5DA8 /* NFTCollectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D812D9C7A2C00BF5DA8 /* NFTCollectionCell.swift */; }; + EA665D842D9C7AB400BF5DA8 /* LikesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA665D832D9C7AA600BF5DA8 /* LikesStorage.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -109,6 +126,23 @@ E1CD40D82A96BE7D00BE7FE8 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Main.strings; sourceTree = ""; }; E1CD40D92A96BE7D00BE7FE8 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/LaunchScreen.strings; sourceTree = ""; }; E1CD40DB2A96BECC00BE7FE8 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; + EA665D0F2D900E1000BF5DA8 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; + EA665D112D904BAB00BF5DA8 /* ProfileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTableViewCell.swift; sourceTree = ""; }; + EA665D2F2D90832D00BF5DA8 /* ProfileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModel.swift; sourceTree = ""; }; + EA665D552D90B39C00BF5DA8 /* EditProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewController.swift; sourceTree = ""; }; + EA665D572D90B47800BF5DA8 /* ProfileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileService.swift; sourceTree = ""; }; + EA665D592D90B49E00BF5DA8 /* ProfileRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRequest.swift; sourceTree = ""; }; + EA665D5E2D92DEAF00BF5DA8 /* EditProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileView.swift; sourceTree = ""; }; + EA665D652D98476D00BF5DA8 /* My NFT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "My NFT.swift"; sourceTree = ""; }; + EA665D682D984B2E00BF5DA8 /* MyNFTViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyNFTViewController.swift; sourceTree = ""; }; + EA665D6A2D984BFD00BF5DA8 /* MyNFTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyNFTView.swift; sourceTree = ""; }; + EA665D6C2D984C3F00BF5DA8 /* NFTCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFTCell.swift; sourceTree = ""; }; + EA665D6E2D98975800BF5DA8 /* MyNFTService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyNFTService.swift; sourceTree = ""; }; + EA665D702D9897CA00BF5DA8 /* MyNFTStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyNFTStorage.swift; sourceTree = ""; }; + EA665D7D2D9C79B800BF5DA8 /* FavoritesNftViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = " FavoritesNftViewController.swift"; sourceTree = ""; }; + EA665D7F2D9C7A0100BF5DA8 /* FavoritesNftView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesNftView.swift; sourceTree = ""; }; + EA665D812D9C7A2C00BF5DA8 /* NFTCollectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFTCollectionCell.swift; sourceTree = ""; }; + EA665D832D9C7AA600BF5DA8 /* LikesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikesStorage.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -116,7 +150,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 3F603CD029DB4A74000C43D7 /* Kingfisher in Frameworks */, + EA665D5D2D9197FB00BF5DA8 /* Kingfisher in Frameworks */, 3F603CCD29DB4A53000C43D7 /* ProgressHUD in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -267,6 +301,7 @@ 3F6806C729CBBC5B00B4F915 /* Scenes */ = { isa = PBXGroup; children = ( + EA665D0E2D900DFE00BF5DA8 /* ProfileViewController */, E1A1B9D82AA01C9700C3AFBC /* TabBarController */, 0CF2C2D92A783C1600FDC837 /* Common */, 0CFCB7472A7808870009A829 /* Catalog */, @@ -318,6 +353,7 @@ 3F6806D829CC979D00B4F915 /* Network */ = { isa = PBXGroup; children = ( + EA665D652D98476D00BF5DA8 /* My NFT.swift */, 0CFCB7412A78013E0009A829 /* Nft.swift */, ); path = Network; @@ -361,6 +397,53 @@ path = Resources; sourceTree = ""; }; + EA665D0E2D900DFE00BF5DA8 /* ProfileViewController */ = { + isa = PBXGroup; + children = ( + EA665D7C2D9C79A500BF5DA8 /* Favorites */, + EA665D672D984B2000BF5DA8 /* My NFT */, + EA665D602D9347E600BF5DA8 /* EditProfile */, + EA665D592D90B49E00BF5DA8 /* ProfileRequest.swift */, + EA665D572D90B47800BF5DA8 /* ProfileService.swift */, + EA665D2F2D90832D00BF5DA8 /* ProfileModel.swift */, + EA665D0F2D900E1000BF5DA8 /* ProfileViewController.swift */, + EA665D112D904BAB00BF5DA8 /* ProfileTableViewCell.swift */, + ); + path = ProfileViewController; + sourceTree = ""; + }; + EA665D602D9347E600BF5DA8 /* EditProfile */ = { + isa = PBXGroup; + children = ( + EA665D552D90B39C00BF5DA8 /* EditProfileViewController.swift */, + EA665D5E2D92DEAF00BF5DA8 /* EditProfileView.swift */, + ); + path = EditProfile; + sourceTree = ""; + }; + EA665D672D984B2000BF5DA8 /* My NFT */ = { + isa = PBXGroup; + children = ( + EA665D702D9897CA00BF5DA8 /* MyNFTStorage.swift */, + EA665D6E2D98975800BF5DA8 /* MyNFTService.swift */, + EA665D6C2D984C3F00BF5DA8 /* NFTCell.swift */, + EA665D6A2D984BFD00BF5DA8 /* MyNFTView.swift */, + EA665D682D984B2E00BF5DA8 /* MyNFTViewController.swift */, + ); + path = "My NFT"; + sourceTree = ""; + }; + EA665D7C2D9C79A500BF5DA8 /* Favorites */ = { + isa = PBXGroup; + children = ( + EA665D832D9C7AA600BF5DA8 /* LikesStorage.swift */, + EA665D812D9C7A2C00BF5DA8 /* NFTCollectionCell.swift */, + EA665D7F2D9C7A0100BF5DA8 /* FavoritesNftView.swift */, + EA665D7D2D9C79B800BF5DA8 /* FavoritesNftViewController.swift */, + ); + path = Favorites; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -379,7 +462,7 @@ name = FakeNFT; packageProductDependencies = ( 3F603CCC29DB4A53000C43D7 /* ProgressHUD */, - 3F603CCF29DB4A74000C43D7 /* Kingfisher */, + EA665D5C2D9197FB00BF5DA8 /* Kingfisher */, ); productName = FakeNFT; productReference = 3F68069329CBBAF100B4F915 /* FakeNFT.app */; @@ -429,7 +512,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1420; + LastUpgradeCheck = 1620; TargetAttributes = { 3F68069229CBBAF100B4F915 = { CreatedOnToolsVersion = 14.2; @@ -456,7 +539,7 @@ mainGroup = 3F68068A29CBBAF100B4F915; packageReferences = ( 3F603CCB29DB4A53000C43D7 /* XCRemoteSwiftPackageReference "ProgressHUD" */, - 3F603CCE29DB4A74000C43D7 /* XCRemoteSwiftPackageReference "Kingfisher" */, + EA665D5B2D9197FB00BF5DA8 /* XCRemoteSwiftPackageReference "Kingfisher" */, ); productRefGroup = 3F68069429CBBAF100B4F915 /* Products */; projectDirPath = ""; @@ -502,33 +585,50 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + EA665D712D9897D100BF5DA8 /* MyNFTStorage.swift in Sources */, + EA665D302D90833300BF5DA8 /* ProfileModel.swift in Sources */, 3FC8C38B29D242E90081F015 /* ProductDetailsTableViewCell.swift in Sources */, 3F478ECF29DB474E00F6D39E /* Colors.swift in Sources */, 0C79EE662A76DDFF00EE90EA /* NftDetailViewController.swift in Sources */, E1A1B9DA2AA01CE400C3AFBC /* TabBarController.swift in Sources */, + EA665D6D2D984C5000BF5DA8 /* NFTCell.swift in Sources */, 3F478ED129DB476500F6D39E /* Fonts.swift in Sources */, 0C79EE6A2A76DE1000EE90EA /* NftDetailAssembly.swift in Sources */, + EA665D102D900E2400BF5DA8 /* ProfileViewController.swift in Sources */, 3FC8C39329D246BA0081F015 /* DateFormatters+Presets.swift in Sources */, 0CFCB7422A78013E0009A829 /* Nft.swift in Sources */, 0CF2C2DD2A783CE600FDC837 /* ErrorView.swift in Sources */, 0C79EE682A76DE0900EE90EA /* NftDetailPresenter.swift in Sources */, 3F68069B29CBBAF100B4F915 /* ProductDetailsTableViewController.swift in Sources */, + EA665D662D98478700BF5DA8 /* My NFT.swift in Sources */, + EA665D802D9C7A0300BF5DA8 /* FavoritesNftView.swift in Sources */, 0CFCB7402A78002A0009A829 /* ExamplePutService.swift in Sources */, 3F6806D529CBBEC700B4F915 /* NetworkTask.swift in Sources */, 0CF2C2DB2A783C1B00FDC837 /* LoadingView.swift in Sources */, + EA665D692D984B3B00BF5DA8 /* MyNFTViewController.swift in Sources */, 558E39E72C68CE0A00FB86AC /* NftService.swift in Sources */, 3F68069729CBBAF100B4F915 /* AppDelegate.swift in Sources */, 3F68069929CBBAF100B4F915 /* SceneDelegate.swift in Sources */, + EA665D122D904BB000BF5DA8 /* ProfileTableViewCell.swift in Sources */, 0C79EE6C2A76DE2E00EE90EA /* ServicesAssemly.swift in Sources */, + EA665D842D9C7AB400BF5DA8 /* LikesStorage.swift in Sources */, 0CFCB74E2A7817DC0009A829 /* NftStorage.swift in Sources */, 3FC8C39129D2453B0081F015 /* ExamplePutRequest.swift in Sources */, + EA665D582D90B48400BF5DA8 /* ProfileService.swift in Sources */, 0CFCB7462A78064B0009A829 /* NftDetailCellModel.swift in Sources */, + EA665D562D90B3A500BF5DA8 /* EditProfileViewController.swift in Sources */, 0CFCB74B2A780EA80009A829 /* UIView+Constraints.swift in Sources */, 3F6806D729CBC50A00B4F915 /* CellsReusingUtils.swift in Sources */, 0C79EE612A76DCD600EE90EA /* NftByIdRequest.swift in Sources */, E19CD5AB2A98B56600CA39A5 /* NftImageCollectionViewCell.swift in Sources */, + EA665D6F2D98976400BF5DA8 /* MyNFTService.swift in Sources */, + EA665D822D9C7A4800BF5DA8 /* NFTCollectionCell.swift in Sources */, + EA665D6B2D984C0F00BF5DA8 /* MyNFTView.swift in Sources */, 0CF2C2E12A784C7000FDC837 /* LinePageControl.swift in Sources */, 3F6806D329CBBE9600B4F915 /* NetworkRequest.swift in Sources */, + EA665D5F2D92DED100BF5DA8 /* EditProfileView.swift in Sources */, + EA665D7E2D9C79D300BF5DA8 /* FavoritesNftViewController.swift in Sources */, + EA665D5A2D90B4A600BF5DA8 /* ProfileRequest.swift in Sources */, 0CFCB7492A7808900009A829 /* TestCatalogController.swift in Sources */, 3F6806D129CBBE6B00B4F915 /* NetworkClient.swift in Sources */, 0CFCB7442A7802440009A829 /* NftDetailInput.swift in Sources */, @@ -626,6 +726,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -687,6 +788,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -766,7 +868,6 @@ 3F6806C129CBBAF200B4F915 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -785,7 +886,6 @@ 3F6806C229CBBAF200B4F915 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -804,7 +904,6 @@ 3F6806C429CBBAF200B4F915 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; @@ -821,7 +920,6 @@ 3F6806C529CBBAF200B4F915 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; @@ -885,12 +983,12 @@ minimumVersion = 13.0.0; }; }; - 3F603CCE29DB4A74000C43D7 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + EA665D5B2D9197FB00BF5DA8 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + repositoryURL = "https://github.com/onevcat/Kingfisher"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.0.0; + minimumVersion = 8.3.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -901,9 +999,9 @@ package = 3F603CCB29DB4A53000C43D7 /* XCRemoteSwiftPackageReference "ProgressHUD" */; productName = ProgressHUD; }; - 3F603CCF29DB4A74000C43D7 /* Kingfisher */ = { + EA665D5C2D9197FB00BF5DA8 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; - package = 3F603CCE29DB4A74000C43D7 /* XCRemoteSwiftPackageReference "Kingfisher" */; + package = EA665D5B2D9197FB00BF5DA8 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; /* End XCSwiftPackageProductDependency section */ diff --git a/FakeNFT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/FakeNFT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 72a738f521..c975fdef26 100644 --- a/FakeNFT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/FakeNFT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { + "originHash" : "3ca36a61ff4abf417842daa08db83926a5b53c93475117297e3cb9099bc72fb9", "pins" : [ { "identity" : "kingfisher", "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "af4be924ad984cf4d16f4ae4df424e79a443d435", - "version" : "7.6.2" + "revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3", + "version" : "8.3.1" } }, { @@ -19,5 +20,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/FakeNFT.xcodeproj/xcshareddata/xcschemes/FakeNFT.xcscheme b/FakeNFT.xcodeproj/xcshareddata/xcschemes/FakeNFT.xcscheme index d682c47de1..787ec1ccf2 100644 --- a/FakeNFT.xcodeproj/xcshareddata/xcschemes/FakeNFT.xcscheme +++ b/FakeNFT.xcodeproj/xcshareddata/xcschemes/FakeNFT.xcscheme @@ -1,6 +1,6 @@ UISceneConfiguration { - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + configuration.storyboard = nil + configuration.sceneClass = UIWindowScene.self + configuration.delegateClass = SceneDelegate.self + return configuration } } diff --git a/FakeNFT/Models/Network/My NFT.swift b/FakeNFT/Models/Network/My NFT.swift new file mode 100644 index 0000000000..d89665524b --- /dev/null +++ b/FakeNFT/Models/Network/My NFT.swift @@ -0,0 +1,15 @@ +// +// My NFT.swift +// FakeNFT +// +// Created by Давид Бекоев on 29.03.2025. +// + +struct MyNFT: Decodable { + let id: String + let name: String + let images: [String] + let rating: Int + let price: Float + let author: String +} diff --git a/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Contents.json new file mode 100644 index 0000000000..ed39a38750 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Dark@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@1x.png b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@1x.png new file mode 100644 index 0000000000..d5f6c91df4 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@1x.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@2x.png b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@2x.png new file mode 100644 index 0000000000..ff8616bd69 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@2x.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@3x.png b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@3x.png new file mode 100644 index 0000000000..23e2a38088 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/Edit.imageset/Dark@3x.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/LightGrayColor.colorset/Contents.json b/FakeNFT/Resources/Assets.xcassets/LightGrayColor.colorset/Contents.json new file mode 100644 index 0000000000..e7665b9c77 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/LightGrayColor.colorset/Contents.json @@ -0,0 +1,29 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xF8", + "green" : "0xF7", + "red" : "0xF7" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/Profile.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/Profile.imageset/Contents.json new file mode 100644 index 0000000000..ce6d56362e --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/Profile.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "No Active.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "No Active 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/Profile.imageset/No Active 1.png b/FakeNFT/Resources/Assets.xcassets/Profile.imageset/No Active 1.png new file mode 100644 index 0000000000..e780030728 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/Profile.imageset/No Active 1.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/Profile.imageset/No Active.png b/FakeNFT/Resources/Assets.xcassets/Profile.imageset/No Active.png new file mode 100644 index 0000000000..e780030728 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/Profile.imageset/No Active.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Contents.json new file mode 100644 index 0000000000..7b0d09bef6 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Light.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Light 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Light 1.png b/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Light 1.png new file mode 100644 index 0000000000..094900bba6 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Light 1.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Light.png b/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Light.png new file mode 100644 index 0000000000..094900bba6 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/Sort_button.imageset/Light.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/YBlackColor.colorset/Contents.json b/FakeNFT/Resources/Assets.xcassets/YBlackColor.colorset/Contents.json new file mode 100644 index 0000000000..84dd06fa4c --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/YBlackColor.colorset/Contents.json @@ -0,0 +1,29 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x22", + "green" : "0x1B", + "red" : "0x1A" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/backward.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/backward.imageset/Contents.json new file mode 100644 index 0000000000..30a9444c05 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/backward.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Light.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Light 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/backward.imageset/Light 2.png b/FakeNFT/Resources/Assets.xcassets/backward.imageset/Light 2.png new file mode 100644 index 0000000000..8a9db02f35 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/backward.imageset/Light 2.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/backward.imageset/Light.png b/FakeNFT/Resources/Assets.xcassets/backward.imageset/Light.png new file mode 100644 index 0000000000..8a9db02f35 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/backward.imageset/Light.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Contents.json new file mode 100644 index 0000000000..0549bdf130 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Favourites icons 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Favourites icons.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Favourites icons 1.png b/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Favourites icons 1.png new file mode 100644 index 0000000000..ef16675134 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Favourites icons 1.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Favourites icons.png b/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Favourites icons.png new file mode 100644 index 0000000000..ef16675134 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/red_heart.imageset/Favourites icons.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Contents.json new file mode 100644 index 0000000000..cdb0806553 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Favourites icons.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Favourites icons 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Favourites icons 1.png b/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Favourites icons 1.png new file mode 100644 index 0000000000..7ff45d4aee Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Favourites icons 1.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Favourites icons.png b/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Favourites icons.png new file mode 100644 index 0000000000..7ff45d4aee Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/white_heart.imageset/Favourites icons.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Contents.json new file mode 100644 index 0000000000..f1fe3c7700 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Stars 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Stars 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Stars.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars 1.png b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars 1.png new file mode 100644 index 0000000000..d35e3da7a7 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars 1.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars 2.png b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars 2.png new file mode 100644 index 0000000000..65993e74b3 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars 2.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars.png b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars.png new file mode 100644 index 0000000000..6f5f43ed3c Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/white_star.imageset/Stars.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Contents.json b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Contents.json new file mode 100644 index 0000000000..e09b35f6c4 --- /dev/null +++ b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Stars 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Stars 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Stars.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars 1.png b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars 1.png new file mode 100644 index 0000000000..ca86fd4796 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars 1.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars 2.png b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars 2.png new file mode 100644 index 0000000000..09f34c02a1 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars 2.png differ diff --git a/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars.png b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars.png new file mode 100644 index 0000000000..cbbf3cb1b5 Binary files /dev/null and b/FakeNFT/Resources/Assets.xcassets/yellow_star.imageset/Stars.png differ diff --git a/FakeNFT/Resources/Localizable.strings b/FakeNFT/Resources/Localizable.strings index c498776710..c5cc8cae7b 100644 --- a/FakeNFT/Resources/Localizable.strings +++ b/FakeNFT/Resources/Localizable.strings @@ -4,3 +4,30 @@ "Error.unknown" = "Произошла неизвестная ошибка"; "Error.repeat" = "Повторить"; "Error.title" = "Ошибка"; + +"Tab.profile" = "Профиль"; +"MyNFT" = "Мои NFT"; +"Favorites" = "Избранные NFT"; +"AboutDeveloper" = "О разработчике"; +"NoInformation" = "Нет дополнительной информации"; +"Name" = "Имя"; +"Description" = "Описание"; +"WebSite" = "Сайт"; +"ChangePhoto" = "Сменить\nфото"; +"LoadImage" = "Загрузить изображение"; +"EnterImageURL" = "Введите ссылку"; +"PleaseEnterURLForAvatar" = "Введите ссылку на изображение"; +"FailedToLoadProfile" = "Не удалось загрузить профиль"; +"Cancel" = "Отмена"; +"TryAgain" = "Попробовать снова"; +"by" = "от"; +"Price" = "Цена"; +"MyNFT" = "Мои NFT"; +"Sort by" = "Сортировка"; +"by Price" = "По цене"; +"by Rating" = "По рейтингу"; +"by Name" = "По названию"; +"Close" = "Закрыть"; +"noNFTs" = "У Вас еще нет NFT"; +"noFavoritesNFTs" = "У Вас еще нет избранных NFT"; + diff --git a/FakeNFT/SceneDelegate.swift b/FakeNFT/SceneDelegate.swift index 5cc4b031b3..78644423a7 100644 --- a/FakeNFT/SceneDelegate.swift +++ b/FakeNFT/SceneDelegate.swift @@ -2,14 +2,34 @@ import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - - let servicesAssembly = ServicesAssembly( - networkClient: DefaultNetworkClient(), - nftStorage: NftStorageImpl() - ) - - func scene(_: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { - let tabBarController = window?.rootViewController as? TabBarController - tabBarController?.servicesAssembly = servicesAssembly + var servicesAssembly: ServicesAssembly! + func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + let tabBarController = TabBarController() + + window.rootViewController = tabBarController + self.window = window + window.makeKeyAndVisible() + } + + func sceneWillResignActive(_ scene: UIScene) { + sendLikesToServer() + } + + func sceneDidEnterBackground(_ scene: UIScene) { + sendLikesToServer() + } + + private func sendLikesToServer() { + let likes = LikesStorageImpl.shared.getAllLikes() + servicesAssembly.profileService.updateLikes(likes: likes) { result in + switch result { + case .success: + print("Лайки успешно отправлены перед выходом из приложения.") + case .failure(let error): + print("Ошибка отправки лайков: \(error.localizedDescription)") + } + } } } diff --git a/FakeNFT/Scenes /ProfileViewController/Edit.swift b/FakeNFT/Scenes /ProfileViewController/Edit.swift new file mode 100644 index 0000000000..215f698483 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/Edit.swift @@ -0,0 +1,16 @@ +// +// EditProfileViewController.swift +// FakeNFT +// +// Created by Давид Бекоев on 23.03.2025. +// + +import UIKit + +final class EditProfileViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/EditProfile/EditProfileView.swift b/FakeNFT/Scenes /ProfileViewController/EditProfile/EditProfileView.swift new file mode 100644 index 0000000000..2430e0a78d --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/EditProfile/EditProfileView.swift @@ -0,0 +1,254 @@ +// +// EditProfileView.swift +// FakeNFT +// +// Created by Давид Бекоев on 25.03.2025. +// + + +import UIKit + +final class EditProfileView: UIView { + + var closeTapped: (() -> Void)? + var avatarTapped: (() -> Void)? + + private lazy var closeButton: UIButton = { + let button = UIButton(type: .system) + + let image = UIImage(named: "close") + button.setImage(image, for: .normal) + button.tintColor = UIColor(named: "YBlackColor") + button.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + return button + }() + + lazy var profileAvatar: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(systemName: "person.circle.fill") + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 35 + imageView.clipsToBounds = true + return imageView + }() + + private lazy var profileAvatarButton: UIButton = { + let button = UIButton() + button.layer.cornerRadius = 35 + button.clipsToBounds = true + button.addTarget(self, action: #selector(avatarImageTapped), for: .touchUpInside) + button.backgroundColor = UIColor(named: "YBlackColor")?.withAlphaComponent(0.6) + button.setTitle(NSLocalizedString("ChangePhoto", comment: ""), for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 10, weight: .medium) + button.titleLabel?.numberOfLines = 2 + button.titleLabel?.textAlignment = .center + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 10, weight: .medium) + return button + }() + + private lazy var loadImageButton: UIButton = { + let button = UIButton(type: .system) + button.frame = CGRect(x: 0, y: 0, width: 250, height: 44) + button.isHidden = true + button.setTitle(NSLocalizedString("LoadImage", comment: ""), for: .normal) + button.setTitleColor(UIColor(named: "YBlackColor"), for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 17) + button.backgroundColor = .clear + button.addTarget(self, action: #selector(loadImageButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var nameLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("Name", comment: "") + label.textColor = UIColor(named: "YBlackColor") + label.font = UIFont.systemFont(ofSize: 22, weight: .bold) + return label + }() + + lazy var nameTextView: UITextView = { + let textView = UITextView() + textView.font = UIFont.systemFont(ofSize: 17, weight: .regular) + textView.layer.cornerRadius = 12 + textView.backgroundColor = UIColor(named: "LightGrayColor") + textView.textContainerInset = UIEdgeInsets(top: 11, left: 16, bottom: 11, right: 16) + textView.heightAnchor.constraint(equalToConstant: 44).isActive = true + return textView + }() + + private lazy var userInfoLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("Description", comment: "") + label.textColor = UIColor(named: "YBlackColor") + label.font = UIFont.systemFont(ofSize: 22, weight: .bold) + return label + }() + + lazy var infoTextView: UITextView = { + let textView = UITextView() + textView.font = UIFont.systemFont(ofSize: 17, weight: .regular) + textView.layer.cornerRadius = 12 + textView.backgroundColor = UIColor(named: "LightGrayColor") + textView.textContainerInset = UIEdgeInsets(top: 11, left: 16, bottom: 11, right: 16) + textView.heightAnchor.constraint(equalToConstant: 132).isActive = true + return textView + }() + + private lazy var userSiteLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("WebSite", comment: "") + label.textColor = UIColor(named: "YBlackColor") + label.font = UIFont.systemFont(ofSize: 22, weight: .bold) + return label + }() + + lazy var siteTextView: UITextView = { + let textView = UITextView() + textView.font = UIFont.systemFont(ofSize: 17, weight: .regular) + textView.layer.cornerRadius = 12 + textView.backgroundColor = UIColor(named: "LightGrayColor") + textView.textContainerInset = UIEdgeInsets(top: 11, left: 16, bottom: 11, right: 16) + textView.heightAnchor.constraint(equalToConstant: 44).isActive = true + return textView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + setupConstraints() + setupKeyboardHandling() + + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + backgroundColor = .white + closeButton.translatesAutoresizingMaskIntoConstraints = false + profileAvatar.translatesAutoresizingMaskIntoConstraints = false + profileAvatarButton.translatesAutoresizingMaskIntoConstraints = false + loadImageButton.translatesAutoresizingMaskIntoConstraints = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameTextView.translatesAutoresizingMaskIntoConstraints = false + userInfoLabel.translatesAutoresizingMaskIntoConstraints = false + infoTextView.translatesAutoresizingMaskIntoConstraints = false + userSiteLabel.translatesAutoresizingMaskIntoConstraints = false + siteTextView.translatesAutoresizingMaskIntoConstraints = false + + [closeButton, profileAvatar, profileAvatarButton, loadImageButton, nameLabel, nameTextView, userInfoLabel, infoTextView, userSiteLabel, siteTextView].forEach { addSubview($0) } + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Constants.closeButtonTrailing), + closeButton.topAnchor.constraint(equalTo: topAnchor, constant: Constants.closeButtonTop), + closeButton.heightAnchor.constraint(equalToConstant: Constants.buttonSize), + closeButton.widthAnchor.constraint(equalToConstant: Constants.buttonSize), + + profileAvatar.centerXAnchor.constraint(equalTo: centerXAnchor), + profileAvatar.topAnchor.constraint(equalTo: closeButton.bottomAnchor, constant: Constants.avatarTop), + profileAvatar.widthAnchor.constraint(equalToConstant: Constants.avatarSize), + profileAvatar.heightAnchor.constraint(equalToConstant: Constants.avatarSize), + + profileAvatarButton.centerXAnchor.constraint(equalTo: profileAvatar.centerXAnchor), + profileAvatarButton.centerYAnchor.constraint(equalTo: profileAvatar.centerYAnchor), + profileAvatarButton.widthAnchor.constraint(equalToConstant: Constants.avatarButtonSize), + profileAvatarButton.heightAnchor.constraint(equalToConstant: Constants.avatarButtonSize), + + loadImageButton.topAnchor.constraint(equalTo: profileAvatar.bottomAnchor, constant: Constants.loadImageButtonTop), + loadImageButton.centerXAnchor.constraint(equalTo: centerXAnchor), + + nameLabel.topAnchor.constraint(equalTo: profileAvatar.bottomAnchor, constant: Constants.nameLabelTop), + nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.horizontalPadding), + nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.horizontalPadding), + + nameTextView.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: Constants.textViewTop), + nameTextView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.horizontalPadding), + nameTextView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.horizontalPadding), + + userInfoLabel.topAnchor.constraint(equalTo: nameTextView.bottomAnchor, constant: Constants.userInfoLabelTop), + userInfoLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.horizontalPadding), + userInfoLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.horizontalPadding), + + infoTextView.topAnchor.constraint(equalTo: userInfoLabel.bottomAnchor, constant: Constants.textViewTop), + infoTextView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.horizontalPadding), + infoTextView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.horizontalPadding), + + userSiteLabel.topAnchor.constraint(equalTo: infoTextView.bottomAnchor, constant: Constants.userSiteLabelTop), + userSiteLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.horizontalPadding), + userSiteLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.horizontalPadding), + + siteTextView.topAnchor.constraint(equalTo: userSiteLabel.bottomAnchor, constant: Constants.textViewTop), + siteTextView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.horizontalPadding), + siteTextView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.horizontalPadding) + ]) + } + + private func setupKeyboardHandling() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(hideKeyboard)) + addGestureRecognizer(tapGesture) + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + private func textViewShouldReturn(_ textView: UITextView) -> Bool { + textView.resignFirstResponder() + return true + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + + @objc private func hideKeyboard() { + endEditing(true) + } + + @objc private func keyboardWillShow(notification: Notification) { + guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + self.frame.origin.y = -keyboardFrame.height / 2 + } + + @objc private func keyboardWillHide(notification: Notification) { + self.frame.origin.y = 0 + } + + + @objc private func closeButtonTapped() { + closeTapped?() + } + + @objc private func loadImageButtonTapped() { + avatarTapped?() + } + + @objc private func avatarImageTapped() { + loadImageButton.isHidden.toggle() + } + +} + + +private extension EditProfileView { + enum Constants { + static let closeButtonTrailing: CGFloat = -16 + static let closeButtonTop: CGFloat = 30 + static let buttonSize: CGFloat = 44 + static let avatarTop: CGFloat = 22 + static let avatarSize: CGFloat = 70 + static let avatarButtonSize: CGFloat = 70 + static let loadImageButtonTop: CGFloat = 4 + static let nameLabelTop: CGFloat = 24 + static let textViewTop: CGFloat = 8 + static let userInfoLabelTop: CGFloat = 24 + static let userSiteLabelTop: CGFloat = 24 + static let horizontalPadding: CGFloat = 16 + } + +} diff --git a/FakeNFT/Scenes /ProfileViewController/EditProfile/EditProfileViewController.swift b/FakeNFT/Scenes /ProfileViewController/EditProfile/EditProfileViewController.swift new file mode 100644 index 0000000000..233eea0580 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/EditProfile/EditProfileViewController.swift @@ -0,0 +1,118 @@ +// +// EditProfileViewController.swift +// FakeNFT +// +// Created by Давид Бекоев on 24.03.2025. +// + + +import UIKit +import Kingfisher + +protocol EditProfileDelegate: AnyObject { + func didUpdateProfile(_ profile: Profile) +} + +final class EditProfileViewController: UIViewController { + + // MARK: - Properties + weak var delegate: EditProfileDelegate? + private lazy var editProfileView = EditProfileView() + private var profile: Profile + private var updatedAvatarURL: String? + + // MARK: - Initializers + + init(profile: Profile) { + self.profile = profile + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func loadView() { + super.loadView() + self.view = editProfileView + } + + override func viewDidLoad() { + super.viewDidLoad() + + editProfileView.closeTapped = { [weak self] in + self?.closeButtonTapped() + } + editProfileView.avatarTapped = { [weak self] in + self?.presentImageURLInputDialog() + } + + populateProfileData() + } + + // MARK: - Data Population + + private func populateProfileData() { + editProfileView.nameTextView.text = profile.name + editProfileView.infoTextView.text = profile.description + editProfileView.siteTextView.text = profile.website + + if let avatarURLString = profile.avatar, let url = URL(string: avatarURLString) { + editProfileView.profileAvatar.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle")) + } else { + editProfileView.profileAvatar.image = UIImage(systemName: "person.crop.circle") + } + } + + // MARK: - Actions + + @objc private func closeButtonTapped() { + profile.name = editProfileView.nameTextView.text + profile.description = editProfileView.infoTextView.text + profile.website = editProfileView.siteTextView.text + profile.avatar = updatedAvatarURL ?? profile.avatar + + delegate?.didUpdateProfile(profile) + + dismiss(animated: true, completion: nil) + } + + // MARK: - Avatar Handling + + private func chooseNewAvatar(url: URL) { + updatedAvatarURL = url.absoluteString + + editProfileView.profileAvatar.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle")) + } + +} + + + +extension EditProfileViewController { + private func presentImageURLInputDialog() { + let alertController = UIAlertController(title: NSLocalizedString("EnterImageURL", comment: ""), + message: NSLocalizedString("PleaseEnterURLForAvatar", comment: ""), + preferredStyle: .alert) + + alertController.addTextField { textField in + textField.placeholder = NSLocalizedString("AvatarURL", comment: "") + textField.keyboardType = .URL + } + + let confirmAction = UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default) { [weak self] _ in + if let urlString = alertController.textFields?.first?.text, let url = URL(string: urlString) { + self?.chooseNewAvatar(url: url) + } + } + + alertController.addAction(confirmAction) + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel, handler: nil)) + + present(alertController, animated: true, completion: nil) + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/Favorites/ FavoritesNftViewController.swift b/FakeNFT/Scenes /ProfileViewController/Favorites/ FavoritesNftViewController.swift new file mode 100644 index 0000000000..5587034492 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/Favorites/ FavoritesNftViewController.swift @@ -0,0 +1,88 @@ +// +// FavoritesNftViewController.swift +// FakeNFT +// +// Created by Давид Бекоев on 01.04.2025. +// + +import UIKit + +final class FavoritesNftViewController: UIViewController { + + var favoriteNfts: [MyNFT] = [] + var saveLikes: (() -> Void)? + private let likesStorage = LikesStorageImpl.shared + private lazy var nftFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencySymbol = "ETH" + formatter.maximumFractionDigits = 2 + return formatter + }() + + override func loadView() { + self.view = FavoritesNftView() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + setupNavigationBar() + setupCollectionView() + updateNFTView() + } + + private func setupNavigationBar() { + title = NSLocalizedString("Favorites", comment: "") + navigationController?.navigationBar.tintColor = UIColor(named: "YBlackColor") + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(backButtonTapped) + ) + } + + private func setupCollectionView() { + guard let favoritesView = view as? FavoritesNftView else { return } + favoritesView.setCollectionViewDataSourceDelegate(dataSource: self, delegate: self) + } + + @objc private func backButtonTapped() { + saveLikes?() + navigationController?.popViewController(animated: true) + } + + private func updateNFTView() { + guard let favoritesView = view as? FavoritesNftView else { return } + favoritesView.updateNFTs(with: favoriteNfts) + } +} + +extension FavoritesNftViewController: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + favoriteNfts.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: NFTCollectionCell.reuseIdentifier, + for: indexPath + ) as? NFTCollectionCell else { + return UICollectionViewCell() + } + + let nft = favoriteNfts[indexPath.item] + let formattedPrice = nftFormatter.string(from: nft.price as NSNumber) ?? "\(nft.price) ETH" + cell.configure(with: nft, formattedPrice: formattedPrice) + + cell.likeButtonTapped = { [weak self] in + guard let self = self else { return } + self.likesStorage.removeLike(for: nft.id) + self.favoriteNfts.removeAll { $0.id == nft.id } + self.updateNFTView() + } + + return cell + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/Favorites/FavoritesNftView.swift b/FakeNFT/Scenes /ProfileViewController/Favorites/FavoritesNftView.swift new file mode 100644 index 0000000000..79145edf69 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/Favorites/FavoritesNftView.swift @@ -0,0 +1,88 @@ +// +// FavoritesNftView.swift +// FakeNFT +// +// Created by Давид Бекоев on 01.04.2025. +// + +import UIKit +import Kingfisher + +final class FavoritesNftView: UIView { + + private var nftItems: [MyNFT] = [] { + didSet { + updateUI() + } + } + + private lazy var placeholderLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("noFavoritesNFTs", comment: "") + label.textAlignment = .center + label.font = UIFont.boldSystemFont(ofSize: 17) + label.textColor = UIColor(named: "YBlackColor") + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumLineSpacing = 20 + layout.minimumInteritemSpacing = 7 + layout.sectionInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + layout.itemSize = CGSize(width: (UIScreen.main.bounds.width - 16 * 2 - 7) / 2, height: 80) + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemBackground + collectionView.register(NFTCollectionCell.self, forCellWithReuseIdentifier: NFTCollectionCell.reuseIdentifier) + collectionView.translatesAutoresizingMaskIntoConstraints = false + return collectionView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + setupConstraints() + updateUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + backgroundColor = .white + addSubview(placeholderLabel) + addSubview(collectionView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + placeholderLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + collectionView.topAnchor.constraint(equalTo: topAnchor), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + private func updateUI() { + collectionView.reloadData() + collectionView.isHidden = nftItems.isEmpty + } + + func updateNFTs(with nfts: [MyNFT]) { + nftItems = nfts + updateUI() + } + + func setCollectionViewDataSourceDelegate(dataSource: UICollectionViewDataSource, delegate: UICollectionViewDelegate) { + collectionView.dataSource = dataSource + collectionView.delegate = delegate + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/Favorites/LikesStorage.swift b/FakeNFT/Scenes /ProfileViewController/Favorites/LikesStorage.swift new file mode 100644 index 0000000000..08b2013855 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/Favorites/LikesStorage.swift @@ -0,0 +1,52 @@ +// +// LikesStorage.swift +// FakeNFT +// +// Created by Давид Бекоев on 01.04.2025. +// + +import Foundation + +protocol LikesStorage: AnyObject { + func saveLike(for nftID: String) + func removeLike(for nftID: String) + func isLiked(_ nftID: String) -> Bool + func getAllLikes() -> [String] +} + +final class LikesStorageImpl: LikesStorage { + static let shared = LikesStorageImpl() + private init() {} + private var likedNfts: Set = [] + private let syncQueue = DispatchQueue(label: "sync-likes-queue") + + func saveLike(for nftID: String) { + syncQueue.async { [weak self] in + self?.likedNfts.insert(nftID) + } + } + + func removeLike(for nftID: String) { + syncQueue.async { [weak self] in + self?.likedNfts.remove(nftID) + } + } + + func isLiked(_ nftID: String) -> Bool { + syncQueue.sync { + likedNfts.contains(nftID) + } + } + + func getAllLikes() -> [String] { + syncQueue.sync { + Array(likedNfts) + } + } + + func syncLikes(with likes: [String]) { + syncQueue.async { [weak self] in + self?.likedNfts = Set(likes) + } + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/Favorites/NFTCollectionCell.swift b/FakeNFT/Scenes /ProfileViewController/Favorites/NFTCollectionCell.swift new file mode 100644 index 0000000000..a18cfd4179 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/Favorites/NFTCollectionCell.swift @@ -0,0 +1,139 @@ +// +// NFTCollectionCell.swift +// FakeNFT +// +// Created by Давид Бекоев on 01.04.2025. +// + + +import UIKit +import Kingfisher + +final class NFTCollectionCell: UICollectionViewCell { + + static let reuseIdentifier = "NFTCollectionCell" + var likeButtonTapped: (() -> Void)? + + private lazy var nftImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 12 + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var infoStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .leading + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private lazy var nftNameLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13, weight: .bold) + label.textColor = UIColor(named: "YBlackColor") + label.numberOfLines = 1 + return label + }() + + private lazy var ratingStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 2 + for _ in 0..<5 { + let star = UIImageView(image: UIImage(named: "white_star")) + star.contentMode = .scaleAspectFit + stackView.addArrangedSubview(star) + } + return stackView + }() + + private lazy var priceLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13, weight: .regular) + label.textColor = UIColor(named: "YBlackColor") + return label + }() + + private lazy var likeButton: UIButton = { + let button = UIButton() + button.setImage(UIImage(named: "red_heart"), for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(didTapLikeButton), for: .touchUpInside) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + contentView.addSubview(nftImageView) + contentView.addSubview(infoStackView) + contentView.addSubview(likeButton) + infoStackView.addArrangedSubview(nftNameLabel) + infoStackView.addArrangedSubview(ratingStackView) + infoStackView.addArrangedSubview(priceLabel) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + nftImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + nftImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + nftImageView.widthAnchor.constraint(equalToConstant: 80), + nftImageView.heightAnchor.constraint(equalToConstant: 80), + + likeButton.topAnchor.constraint(equalTo: nftImageView.topAnchor), + likeButton.trailingAnchor.constraint(equalTo: nftImageView.trailingAnchor), + likeButton.widthAnchor.constraint(equalToConstant: 29.63), + likeButton.heightAnchor.constraint(equalToConstant: 29.63), + + infoStackView.leadingAnchor.constraint(equalTo: nftImageView.trailingAnchor, constant: 12), + infoStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 7), + infoStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + infoStackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -7), + ]) + } + + func configure(with nft: MyNFT, formattedPrice: String) { + if let imageURL = nft.images.first, let url = URL(string: imageURL) { + nftImageView.kf.setImage(with: url) + } + + let extractedName = nft.images.first.flatMap { imageURL in + extractName(from: [imageURL]) + } + nftNameLabel.text = extractedName + priceLabel.text = formattedPrice + updateRating(for: nft.rating) + } + + private func extractName(from images: [String]) -> String? { + guard let firstImage = images.first else { return nil } + let components = firstImage.split(separator: "/") + guard components.count > 2 else { return nil } + return String(components[components.count - 2]) + } + + private func updateRating(for rating: Int) { + for (index, view) in ratingStackView.arrangedSubviews.enumerated() { + guard let starImageView = view as? UIImageView else { continue } + starImageView.image = UIImage(named: index < rating ? "yellow_star" : "white_star") + } + } + + @objc private func didTapLikeButton() { + likeButtonTapped?() + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTService.swift b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTService.swift new file mode 100644 index 0000000000..794d22c07a --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTService.swift @@ -0,0 +1,45 @@ +// +// MyNFTService.swift +// FakeNFT +// +// Created by Давид Бекоев on 30.03.2025. +// + + +import Foundation + + +typealias MyNftCompletion = (Result) -> Void + +protocol MyNftService { + func loadNft(id: String, completion: @escaping MyNftCompletion) +} + +final class MyNftServiceImpl: MyNftService { + + private let networkClient: NetworkClient + private let storage: MyNftStorage + + init(networkClient: NetworkClient, storage: MyNftStorage) { + self.storage = storage + self.networkClient = networkClient + } + + func loadNft(id: String, completion: @escaping MyNftCompletion) { + if let nft = storage.getNft(with: id) { + completion(.success(nft)) + return + } + + let request = NFTRequest(id: id) + networkClient.send(request: request, type: MyNFT.self) { [weak storage] result in + switch result { + case .success(let nft): + storage?.saveNft(nft) + completion(.success(nft)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTStorage.swift b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTStorage.swift new file mode 100644 index 0000000000..5bb03e6c22 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTStorage.swift @@ -0,0 +1,33 @@ +// +// MyNFTStorage.swift +// FakeNFT +// +// Created by Давид Бекоев on 30.03.2025. +// + + +import Foundation + + +protocol MyNftStorage: AnyObject { + func saveNft(_ nft: MyNFT) + func getNft(with id: String) -> MyNFT? +} + +final class MyNftStorageImpl: MyNftStorage { + private var storage: [String: MyNFT] = [:] + + private let syncQueue = DispatchQueue(label: "sync-myNft-queue") + + func saveNft(_ nft: MyNFT) { + syncQueue.async { [weak self] in + self?.storage[nft.id] = nft + } + } + + func getNft(with id: String) -> MyNFT? { + syncQueue.sync { + storage[id] + } + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTView.swift b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTView.swift new file mode 100644 index 0000000000..f506ac232e --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTView.swift @@ -0,0 +1,118 @@ +// +// MyNFTView.swift +// FakeNFT +// +// Created by Давид Бекоев on 29.03.2025. +// + + +import UIKit +import Kingfisher + + +final class MyNFTView: UIView { + + var nftItems: [MyNFT] = [] { + didSet { + updateUI() + nftTableView.reloadData() + } + } + + var isLiked: ((String) -> Bool)? + var likeButtonTapped: ((String) -> Void)? + + lazy var nftTableView: UITableView = { + let tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.dataSource = self + tableView.separatorStyle = .none + tableView.register(NFTCell.self, forCellReuseIdentifier: NFTCell.reuseIdentifier) + return tableView + }() + + private lazy var placeholderLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("noNFTs", comment: "") + label.textAlignment = .center + label.font = UIFont.boldSystemFont(ofSize: 17) + label.textColor = UIColor(named: "YBlackColor") + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = true + return label + }() + + private lazy var numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencySymbol = "ETH" + formatter.maximumFractionDigits = 2 + return formatter + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + backgroundColor = .white + addSubview(nftTableView) + addSubview(placeholderLabel) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + nftTableView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20), + nftTableView.bottomAnchor.constraint(equalTo: bottomAnchor), + nftTableView.leadingAnchor.constraint(equalTo: leadingAnchor), + nftTableView.trailingAnchor.constraint(equalTo: trailingAnchor), + + placeholderLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + func updateUI() { + placeholderLabel.isHidden = !nftItems.isEmpty + } + + func updateNFTs(with nfts: [MyNFT]) { + nftItems = nfts + nftTableView.reloadData() + } +} + +// MARK: - UITableViewDataSource & UITableViewDelegate + +extension MyNFTView: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return nftItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: NFTCell.reuseIdentifier, for: indexPath) as! NFTCell + let nft = nftItems[indexPath.row] + let formattedPrice = numberFormatter.string(from: nft.price as NSNumber) ?? "\(nft.price) ETH" + let liked = isLiked?(nft.id) ?? false + cell.configure(with: nft, isLiked: liked, formattedPrice: formattedPrice) + cell.selectionStyle = .none + cell.likeButtonTapped = { [weak self] in + self?.likeButtonTapped?(nft.id) + } + + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 140 + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTViewController.swift b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTViewController.swift new file mode 100644 index 0000000000..5ffc4d51db --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/My NFT/MyNFTViewController.swift @@ -0,0 +1,143 @@ +// +// MyNFTViewController.swift +// FakeNFT +// +// Created by Давид Бекоев on 29.03.2025. +// + +import UIKit + +enum SortType: String { + case price + case rating + case name +} + +final class MyNFTViewController: UIViewController { + + var nfts: [MyNFT] = [] + private let sortTypeKey = "selectedSortType" + + var saveLikes: (() -> Void)? + private let likesStorage = LikesStorageImpl.shared + + override func loadView() { + let nftView = MyNFTView() + nftView.isLiked = { [weak self] id in + return self?.likesStorage.isLiked(id) ?? false + } + nftView.likeButtonTapped = { [weak self] id in + guard let self = self else { return } + if self.likesStorage.isLiked(id) { + self.likesStorage.removeLike(for: id) + } else { + self.likesStorage.saveLike(for: id) + } + nftView.nftTableView.reloadData() + } + view = nftView + + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + setupNavigationBar() + + let savedSortType = getSavedSortType() + sortNFTs(by: savedSortType) + + updateNFTView() + } + + private func setupNavigationBar() { + title = NSLocalizedString("MyNFT", comment: "") + navigationController?.navigationBar.tintColor = UIColor(named: "YBlackColor") + + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(backButtonTapped) + ) + + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(named: "Sort_button"), + style: .plain, + target: self, + action: #selector(filterButtonTapped) + ) + } + + @objc private func backButtonTapped() { + saveLikes?() + navigationController?.popViewController(animated: true) + } + + @objc private func filterButtonTapped() { + let alert = UIAlertController( + title: NSLocalizedString("Sort by", comment: ""), + message: nil, + preferredStyle: .actionSheet + ) + + alert.addAction(UIAlertAction( + title: NSLocalizedString("by Price", comment: ""), + style: .default, + handler: { [weak self] _ in + self?.sortNFTs(by: .price) + } + )) + + alert.addAction(UIAlertAction( + title: NSLocalizedString("by Rating", comment: ""), + style: .default, + handler: { [weak self] _ in + self?.sortNFTs(by: .rating) + } + )) + + alert.addAction(UIAlertAction( + title: NSLocalizedString("by Name", comment: ""), + style: .default, + handler: { [weak self] _ in + self?.sortNFTs(by: .name) + } + )) + + alert.addAction(UIAlertAction( + title: NSLocalizedString("Close", comment: ""), + style: .cancel, + handler: nil + )) + + present(alert, animated: true, completion: nil) + } + + func sortNFTs(by type: SortType) { + UserDefaults.standard.set(type.rawValue, forKey: sortTypeKey) + switch type { + case .price: + nfts.sort { $0.price < $1.price } + case .rating: + nfts.sort { $0.rating > $1.rating } + case .name: + nfts.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + updateNFTView() + } + + private func getSavedSortType() -> SortType { + guard let rawValue = UserDefaults.standard.string(forKey: sortTypeKey), + let savedType = SortType(rawValue: rawValue) else { + return .rating + } + return savedType + } + + private func updateNFTView() { + (view as? MyNFTView)?.updateNFTs(with: nfts) + } + } diff --git a/FakeNFT/Scenes /ProfileViewController/My NFT/NFTCell.swift b/FakeNFT/Scenes /ProfileViewController/My NFT/NFTCell.swift new file mode 100644 index 0000000000..3c757eb716 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/My NFT/NFTCell.swift @@ -0,0 +1,197 @@ +// +// NFTCell.swift +// FakeNFT +// +// Created by Давид Бекоев on 29.03.2025. +// + + +import UIKit +import Kingfisher + +final class NFTCell: UITableViewCell { + + static let reuseIdentifier = "NFTCell" + private var priceLabel: UILabel? + var likeButtonTapped: (() -> Void)? + + private lazy var nftImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 12 + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var likeButton: UIButton = { + let button = UIButton() + button.setImage(UIImage(named: "white_heart"), for: .normal) + button.tintColor = .red + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(didTapLikeButton), for: .touchUpInside) + return button + }() + + private lazy var nftNameLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + label.textColor = UIColor(named: "YBlackColor") + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var ratingStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .fill + stackView.distribution = .fillProportionally + stackView.spacing = 2 + stackView.translatesAutoresizingMaskIntoConstraints = false + + for _ in 0..<5 { + let starImageView = UIImageView() + starImageView.contentMode = .scaleAspectFit + starImageView.image = UIImage(named: "white_star") + starImageView.translatesAutoresizingMaskIntoConstraints = false + starImageView.widthAnchor.constraint(equalToConstant: 12).isActive = true + starImageView.heightAnchor.constraint(equalToConstant: 12).isActive = true + stackView.addArrangedSubview(starImageView) + } + return stackView + }() + + private lazy var authorLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13, weight: .regular) + label.textColor = UIColor(named: "YBlackColor") + label.numberOfLines = 2 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var priceStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .leading + stackView.distribution = .equalSpacing + stackView.spacing = 2 + stackView.translatesAutoresizingMaskIntoConstraints = false + + let priceTitleLabel = UILabel() + priceTitleLabel.text = NSLocalizedString("Price", comment: "") + priceTitleLabel.font = UIFont.systemFont(ofSize: 13, weight: .regular) + priceTitleLabel.textColor = UIColor(named: "YBlackColor") + stackView.addArrangedSubview(priceTitleLabel) + + let priceLabel = UILabel() + priceLabel.font = UIFont.systemFont(ofSize: 17, weight: .bold) + priceLabel.textColor = UIColor(named: "YBlackColor") + priceLabel.text = "0.0" + self.priceLabel = priceLabel + stackView.addArrangedSubview(priceLabel) + + return stackView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + contentView.addSubview(nftImageView) + contentView.addSubview(likeButton) + contentView.addSubview(nftNameLabel) + contentView.addSubview(ratingStackView) + contentView.addSubview(authorLabel) + contentView.addSubview(priceStack) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + nftImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + nftImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + nftImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), + nftImageView.widthAnchor.constraint(equalToConstant: 108), + nftImageView.heightAnchor.constraint(equalToConstant: 108), + + likeButton.topAnchor.constraint(equalTo: nftImageView.topAnchor), + likeButton.trailingAnchor.constraint(equalTo: nftImageView.trailingAnchor), + likeButton.widthAnchor.constraint(equalToConstant: 40), + likeButton.heightAnchor.constraint(equalToConstant: 40), + + nftNameLabel.leadingAnchor.constraint(equalTo: nftImageView.trailingAnchor, constant: 20), + nftNameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 39), + nftNameLabel.heightAnchor.constraint(equalToConstant: 22), + + ratingStackView.leadingAnchor.constraint(equalTo: nftImageView.trailingAnchor, constant: 20), + ratingStackView.topAnchor.constraint(equalTo: nftNameLabel.bottomAnchor, constant: 4), + ratingStackView.heightAnchor.constraint(equalToConstant: 12), + + authorLabel.leadingAnchor.constraint(equalTo: nftImageView.trailingAnchor, constant: 20), + authorLabel.topAnchor.constraint(equalTo: ratingStackView.bottomAnchor, constant: 4), + authorLabel.heightAnchor.constraint(equalToConstant: 40), + + priceStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -39), + priceStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + + func configure(with nft: MyNFT, isLiked: Bool, formattedPrice: String) { + let extractedName = nft.images.first.flatMap { imageURL in + extractName(from: [imageURL]) + } + nftNameLabel.text = extractedName + + let authorName = nft.name + if authorName.count > 10 { + let formattedAuthorName = authorName.replacingOccurrences(of: " ", with: "\n") + authorLabel.text = "\(NSLocalizedString("by", comment: "")) \(formattedAuthorName)" + } else { + authorLabel.text = "\(NSLocalizedString("by", comment: "")) \(authorName)" + } + if let priceLabel = priceLabel { + priceLabel.text = formattedPrice + } + + if let imageURL = nft.images.first, let url = URL(string: imageURL) { + nftImageView.kf.setImage(with: url) + } + updateRating(for: nft.rating) + let likeImage = isLiked ? "red_heart" : "white_heart" + likeButton.setImage(UIImage(named: likeImage), for: .normal) + + } + + private func extractName(from images: [String]) -> String? { + guard let firstImage = images.first else { return nil } + let components = firstImage.split(separator: "/") + guard components.count > 2 else { return nil } + return String(components[components.count - 2]) + } + + private func updateRating(for rating: Int) { + guard rating >= 0 && rating <= 5 else { return } + + for (index, view) in ratingStackView.arrangedSubviews.enumerated() { + guard let starImageView = view as? UIImageView else { continue } + + if index < rating { + starImageView.image = UIImage(named: "yellow_star") + } else { + starImageView.image = UIImage(named: "white_star") + } + } + } + + @objc private func didTapLikeButton() { + likeButtonTapped?() + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/ProfileModel.swift b/FakeNFT/Scenes /ProfileViewController/ProfileModel.swift new file mode 100644 index 0000000000..d0b1c04af2 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/ProfileModel.swift @@ -0,0 +1,20 @@ +// +// ProfileModel.swift +// FakeNFT +// +// Created by Давид Бекоев on 23.03.2025. +// + + +import Foundation + +struct Profile: Codable { + var name: String? + var avatar: String? + var description: String? + var website: String? + var nfts: [String] + var likes: [String] + var id: String +} + diff --git a/FakeNFT/Scenes /ProfileViewController/ProfileRequest.swift b/FakeNFT/Scenes /ProfileViewController/ProfileRequest.swift new file mode 100644 index 0000000000..d166dbb7dc --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/ProfileRequest.swift @@ -0,0 +1,57 @@ +// +// ProfileRequest.swift +// FakeNFT +// +// Created by Давид Бекоев on 24.03.2025. +// + + +import Foundation + +struct ProfileRequest: NetworkRequest { + + var endpoint: URL? { + URL(string: "\(RequestConstants.baseURL)/api/v1/profile/1") + } + + var dto: Dto? { nil } +} + +struct ProfilePutRequest: NetworkRequest { + var endpoint: URL? { + URL(string: "\(RequestConstants.baseURL)/api/v1/profile/1") + } + var httpMethod: HttpMethod = .put + var dto: Dto? + + init(dto: ProfileDtoObject) { + self.dto = dto + } +} + +struct ProfileDtoObject: Dto { + + let name: String + let description: String + let website: String + let avatar: String + let likes: [String] + + enum CodingKeys: String, CodingKey { + case name + case description + case website + case avatar + case likes + } + + func asDictionary() -> [String: String] { + return [ + CodingKeys.name.rawValue: name.isEmpty ? "" : name, + CodingKeys.description.rawValue: description.isEmpty ? "" : description, + CodingKeys.website.rawValue: website.isEmpty ? "" : website, + CodingKeys.avatar.rawValue: avatar.isEmpty ? "" : avatar, + CodingKeys.likes.rawValue: likes.isEmpty ? "null" : likes.joined(separator: ","), + ] + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/ProfileService.swift b/FakeNFT/Scenes /ProfileViewController/ProfileService.swift new file mode 100644 index 0000000000..53e291e15b --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/ProfileService.swift @@ -0,0 +1,89 @@ +// +// ProfileService.swift +// FakeNFT +// +// Created by Давид Бекоев on 24.03.2025. +// + + +import Foundation + +typealias ProfileCompletion = (Result) -> Void + +protocol ProfileService { + func loadProfile(completion: @escaping ProfileCompletion) + func updateProfile( + name: String, + description: String, + website: String, + avatar: String, + completion: @escaping ProfileCompletion + ) + + func updateLikes( + likes: [String], + completion: @escaping ProfileCompletion + ) +} + +final class ProfileServiceImpl: ProfileService { + + private let networkClient: NetworkClient + private let likesStorage = LikesStorageImpl.shared + + init(networkClient: NetworkClient) { + self.networkClient = networkClient + } + + func loadProfile(completion: @escaping ProfileCompletion) { + let request = ProfileRequest() + networkClient.send(request: request, type: Profile.self) { result in + completion(result) + } + } + + func updateProfile( + name: String, + description: String, + website: String, + avatar: String, + completion: @escaping ProfileCompletion + ) { + let dto = ProfileDtoObject(name: name, description: description, website: website, avatar: avatar, likes: [","]) + let request = ProfilePutRequest(dto: dto) + + networkClient.send(request: request, type: Profile.self) { result in + switch result { + case .success(let updatedProfile): + completion(.success(updatedProfile)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func updateLikes( + likes: [String], + completion: @escaping ProfileCompletion + ) { + loadProfile { result in + switch result { + case .success(let currentProfile): + let dto = ProfileDtoObject( + name: currentProfile.name ?? "", + description: currentProfile.description ?? "", + website: currentProfile.website ?? "", + avatar: currentProfile.avatar ?? "", + likes: likes + ) + + let request = ProfilePutRequest(dto: dto) + self.networkClient.send(request: request, type: Profile.self) { result in + completion(result) + } + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/ProfileTableViewCell.swift b/FakeNFT/Scenes /ProfileViewController/ProfileTableViewCell.swift new file mode 100644 index 0000000000..478fecd5ca --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/ProfileTableViewCell.swift @@ -0,0 +1,237 @@ +// +// ProfileTableViewCell.swift +// FakeNFT +// +// Created by Давид Бекоев on 23.03.2025. +// + +import UIKit + +final class ProfileView: UIView { + + private let likesStorage = LikesStorageImpl.shared + private var nftsCount: Int = 3 + private var likesCount: Int = 0 + var websiteLabelTapped: ((String) -> Void)? + var favoritesTapped: (() -> Void)? + var aboutDeveloper: ((String) -> Void)? + var myNFTTapped: (() -> Void)? + + + + + // MARK: - UI Elements + + private lazy var profileAvatar: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(systemName: "person.circle.fill") + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 35 + imageView.clipsToBounds = true + return imageView + }() + + + private lazy var userNameLabel: UILabel = { + let label = UILabel() + label.text = "Mock Name" + label.textColor = UIColor(named: "YBlackColor") + label.font = UIFont.systemFont(ofSize: 22, weight: .bold) + return label + }() + + private lazy var userWebSiteLabel: UILabel = { + let label = UILabel() + label.text = "practicum.yandex.ru" + label.textColor = .systemBlue + label.font = UIFont.systemFont(ofSize: 15) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapOnWebsiteLabel)) + label.isUserInteractionEnabled = true + label.addGestureRecognizer(tapGesture) + + return label + }() + + private lazy var profileInfoLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("NoInformation", comment: "") + label.textColor = UIColor(named: "YBlackColor") + label.font = UIFont.systemFont(ofSize: 13, weight: .regular) + label.numberOfLines = 5 + label.lineBreakMode = .byWordWrapping + return label + }() + + private lazy var profileTableView: UITableView = { + let tableView = UITableView() + tableView.delegate = self + tableView.dataSource = self + tableView.isScrollEnabled = false + tableView.rowHeight = 54 + tableView.separatorStyle = .none + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "ProfileCell") + return tableView + }() + + private lazy var profileContainerView = UIView() + + // MARK: - Initialization + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + + addSubview(profileContainerView) + profileContainerView.addSubview(profileAvatar) + profileContainerView.addSubview(userNameLabel) + profileContainerView.addSubview(profileInfoLabel) + profileContainerView.addSubview(userWebSiteLabel) + addSubview(profileTableView) + + profileContainerView.translatesAutoresizingMaskIntoConstraints = false + profileAvatar.translatesAutoresizingMaskIntoConstraints = false + userNameLabel.translatesAutoresizingMaskIntoConstraints = false + profileInfoLabel.translatesAutoresizingMaskIntoConstraints = false + userWebSiteLabel.translatesAutoresizingMaskIntoConstraints = false + profileTableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + profileContainerView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: Constraints.containerTop), + profileContainerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constraints.horizontalPadding), + profileContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constraints.horizontalPadding), + + profileAvatar.topAnchor.constraint(equalTo: profileContainerView.topAnchor), + profileAvatar.leadingAnchor.constraint(equalTo: profileContainerView.leadingAnchor), + profileAvatar.widthAnchor.constraint(equalToConstant: Constraints.avatarSize), + profileAvatar.heightAnchor.constraint(equalToConstant: Constraints.avatarSize), + + + userNameLabel.centerYAnchor.constraint(equalTo: profileAvatar.centerYAnchor), + userNameLabel.leadingAnchor.constraint(equalTo: profileAvatar.trailingAnchor, constant: 16), + + + profileInfoLabel.topAnchor.constraint(equalTo: profileAvatar.bottomAnchor, constant: Constraints.infoTopSpacing), + profileInfoLabel.leadingAnchor.constraint(equalTo: profileContainerView.leadingAnchor), + profileInfoLabel.trailingAnchor.constraint(equalTo: profileContainerView.trailingAnchor), + + userWebSiteLabel.topAnchor.constraint(equalTo: profileInfoLabel.bottomAnchor, constant: Constraints.websiteTopSpacing), + userWebSiteLabel.leadingAnchor.constraint(equalTo: profileContainerView.leadingAnchor), + userWebSiteLabel.trailingAnchor.constraint(equalTo: profileContainerView.trailingAnchor), + userWebSiteLabel.bottomAnchor.constraint(equalTo: profileContainerView.bottomAnchor), + + profileTableView.topAnchor.constraint(equalTo: profileContainerView.bottomAnchor, constant: Constraints.tableTopSpacing), + profileTableView.leadingAnchor.constraint(equalTo: leadingAnchor), + profileTableView.trailingAnchor.constraint(equalTo: trailingAnchor), + profileTableView.heightAnchor.constraint(equalToConstant: Constraints.tableHeight) + ]) + } + + @objc private func didTapOnWebsiteLabel() { + if let text = userWebSiteLabel.text { + websiteLabelTapped?(text) + } + } + + func updateUI(with profile: Profile) { + userNameLabel.text = profile.name + + if let avatarURLString = profile.avatar, let url = URL(string: avatarURLString) { + profileAvatar.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle")) + } else { + profileAvatar.image = UIImage(systemName: "person.crop.circle") + } + + if let website = profile.website { + let cleanedWebsite = website.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: "") + userWebSiteLabel.text = cleanedWebsite + } else { + userWebSiteLabel.isHidden = true + } + + profileInfoLabel.text = profile.description ?? NSLocalizedString("NoInformation", comment: "") + + self.nftsCount = profile.nfts.count + self.likesCount = profile.likes.count + + profileTableView.reloadData() + + } + + func updateLikesCountAndUI() { + let likes = likesStorage.getAllLikes() + likesCount = likes.count + profileTableView.reloadData() + } +} + +// MARK: - UITableViewDataSource + +extension ProfileView: UITableViewDelegate, UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 3 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "ProfileCell", for: indexPath) + + switch indexPath.row { + case 0: + cell.textLabel?.text = NSLocalizedString("MyNFT", comment: "") + " (\(nftsCount))" + case 1: + cell.textLabel?.text = NSLocalizedString("Favorites", comment: "") + " (\(likesCount))" + case 2: + cell.textLabel?.text = NSLocalizedString("AboutDeveloper", comment: "") + default: + break + } + + cell.textLabel?.font = UIFont.systemFont(ofSize: 17, weight: .bold) + cell.textLabel?.textColor = UIColor(named: "YBlackColor") + + let chevronImage = UIImage(systemName: "chevron.forward", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular, scale: .medium))?.withRenderingMode(.alwaysTemplate) + let chevronImageView = UIImageView(image: chevronImage) + + cell.accessoryView = chevronImageView + cell.tintColor = UIColor(named: "YBlackColor") + cell.selectionStyle = .none + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch indexPath.row { + case 0: + if let myNFTTapped = myNFTTapped { + myNFTTapped() + } + case 1: + if let favoritesTapped = favoritesTapped { + favoritesTapped() + } case 2: + if let aboutDeveloper = aboutDeveloper { + aboutDeveloper("practicum.yandex.ru") + } + default: + break + } + } +} + +private extension ProfileView { + enum Constraints { + static let containerTop: CGFloat = 20 + static let horizontalPadding: CGFloat = 16 + static let avatarSize: CGFloat = 70 + static let infoTopSpacing: CGFloat = 20 + static let websiteTopSpacing: CGFloat = 12 + static let tableTopSpacing: CGFloat = 40 + static let tableHeight: CGFloat = 54 * 3 + } +} diff --git a/FakeNFT/Scenes /ProfileViewController/ProfileViewController.swift b/FakeNFT/Scenes /ProfileViewController/ProfileViewController.swift new file mode 100644 index 0000000000..6dc0062cb1 --- /dev/null +++ b/FakeNFT/Scenes /ProfileViewController/ProfileViewController.swift @@ -0,0 +1,339 @@ +// +// ProfileViewController.swift +// FakeNFT +// +// Created by Давид Бекоев on 23.03.2025. +// + +import Foundation +import UIKit +import WebKit +import Kingfisher +import ProgressHUD + +enum NFTScreenType { + case nftScreen + case favoritesScreen +} + +final class ProfileViewController: UIViewController { + + private let servicesAssembly: ServicesAssembly + private let profileView = ProfileView() + private var profile: Profile? + private var myNFTs: [String]? + private let myNFTViewController = MyNFTViewController() + private var blockingView: UIView? + private var likes: [String]? + private let likesStorage = LikesStorageImpl.shared + + + // MARK: - UI Elements + + + private lazy var editButton: UIButton = { + let button = UIButton() + let imageButton = UIImage(named: "Edit") + button.setImage(imageButton, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(editProfileTapped), for: .touchUpInside) + button.widthAnchor.constraint(equalToConstant: 44).isActive = true + button.heightAnchor.constraint(equalToConstant: 44).isActive = true + + return button + }() + + // + + private lazy var webView: WKWebView = { + let webView = WKWebView() + return webView + }() + + // MARK: - Initialization + init(servicesAssembly: ServicesAssembly) { + self.servicesAssembly = servicesAssembly + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + likes = likesStorage.getAllLikes() + view = profileView + setupEditButton() + loadProfile() + + profileView.myNFTTapped = { [weak self] in + guard let self = self else { return } + if let nftIds = self.myNFTs, !nftIds.isEmpty { + self.loadNFTs(with: nftIds, for: .nftScreen) + } else { + self.showNFTScreen(with: []) + } + } + + profileView.favoritesTapped = { [weak self] in + guard let self = self else { return } + if let nftIds = self.likes, !nftIds.isEmpty { + self.loadNFTs(with: nftIds, for: .favoritesScreen) + } else { + self.showFavoritesScreen(with: []) + } + } + + profileView.websiteLabelTapped = { [weak self] address in + self?.didTapOnWebsiteLabel(with: address) + } + profileView.aboutDeveloper = { [weak self] address in + self?.didTapOnWebsiteLabel(with: address) + } + } + + + // MARK: - Setup Methods + private func setupEditButton() { + + view.addSubview(editButton) + + NSLayoutConstraint.activate([ + editButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -9), + editButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + ]) + } + + + // MARK: - Private Methods + + private func loadProfile() { + ProgressHUD.show() + servicesAssembly.profileService.loadProfile { [weak self] result in + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + ProgressHUD.dismiss() + switch result { + case .success(let loadedProfile): + self.profile = loadedProfile + self.profileView.updateUI(with: loadedProfile) + self.myNFTs = loadedProfile.nfts + likesStorage.syncLikes(with: loadedProfile.likes) + case .failure(let error): + self.showErrorAlert(with: error) + } + } + } + } + + private func showErrorAlert(with error: Error) { + let alert = UIAlertController( + title: NSLocalizedString("Error.title", comment: ""), + message: NSLocalizedString("FailedToLoadProfile", comment: ""), + preferredStyle: .alert + ) + + let retryAction = UIAlertAction( + title: NSLocalizedString("TryAgain", comment: ""), + style: .default + ) { [weak self] _ in + self?.loadProfile() + } + + let cancelAction = UIAlertAction( + title: NSLocalizedString("Cancel", comment: ""), + style: .cancel, + handler: nil + ) + + alert.addAction(retryAction) + alert.addAction(cancelAction) + + present(alert, animated: true, completion: nil) + } + + private func loadNFTs(with ids: [String], for screenType: NFTScreenType) { + var loadedNFTs: [MyNFT] = [] + let dispatchGroup = DispatchGroup() + + ProgressHUD.show() + disableUserInteraction() + + for id in ids { + dispatchGroup.enter() + + servicesAssembly.myNftService.loadNft(id: id) { [weak self] result in + switch result { + case .success(let nft): + loadedNFTs.append(nft) + case .failure(let error): + self?.showErrorAlert(with: error) + } + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { [weak self] in + guard let self = self else { return } + self.enableUserInteraction() + ProgressHUD.dismiss() + switch screenType { + case .nftScreen: + self.showNFTScreen(with: loadedNFTs) + case .favoritesScreen: + self.showFavoritesScreen(with: loadedNFTs) + } + } + } + + private func showNFTScreen(with nfts: [MyNFT]) { + guard let navigationController = self.navigationController else { return } + let myNFTViewController = MyNFTViewController() + myNFTViewController.nfts = nfts + myNFTViewController.hidesBottomBarWhenPushed = true + myNFTViewController.saveLikes = { [weak self] in + guard let self = self else { return } + + let likes = likesStorage.getAllLikes() + + self.updateLikesOnServer(likes: likes) { result in + switch result { + case .success: + self.profileView.updateLikesCountAndUI() + case .failure(let error): + print("Ошибка при отправке лайков на сервер: \(error)") + } + } + } + navigationController.pushViewController(myNFTViewController, animated: true) + } + + private func showFavoritesScreen(with nfts: [MyNFT]) { + guard let navigationController = self.navigationController else { return } + let favoritesNftViewController = FavoritesNftViewController() + + favoritesNftViewController.favoriteNfts = nfts + favoritesNftViewController.hidesBottomBarWhenPushed = true + + favoritesNftViewController.saveLikes = { [weak self] in + guard let self = self else { return } + + self.likes = self.likesStorage.getAllLikes() + self.updateLikesOnServer(likes: self.likes ?? []) { result in + switch result { + case .success: + self.profileView.updateLikesCountAndUI() + case .failure(let error): + print("Ошибка при отправке лайков на сервер: \(error)") + } + } + } + + navigationController.pushViewController(favoritesNftViewController, animated: true) + } + + private func updateLikesOnServer(likes: [String], completion: @escaping (Result) -> Void) { + let likes = likesStorage.getAllLikes() + servicesAssembly.profileService.updateLikes(likes: likes) { [weak self] result in + switch result { + case .success: + self?.likes = likes + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - Actions + + @objc private func editProfileTapped() { + guard let profile = profile else { return } + editProfile(with: profile) + } + + private func editProfile(with profile: Profile) { + let editProfileVC = EditProfileViewController(profile: profile) + editProfileVC.delegate = self + + editProfileVC.modalPresentationStyle = .formSheet + present(editProfileVC, animated: true, completion: nil) + } + + private func didTapOnWebsiteLabel(with urlString: String) { + var validURLString = urlString + + + if !urlString.hasPrefix("https://") { + validURLString = "https://\(urlString)" + } + + guard let url = URL(string: validURLString) else { return } + + let request = URLRequest(url: url) + webView.load(request) + + let webViewController = UIViewController() + webViewController.view.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: webViewController.view.topAnchor), + webView.bottomAnchor.constraint(equalTo: webViewController.view.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: webViewController.view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: webViewController.view.trailingAnchor) + ]) + webViewController.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(webViewController, animated: true) + } + + private func disableUserInteraction() { + if blockingView == nil { + let view = UIView(frame: UIScreen.main.bounds) + view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + view.isUserInteractionEnabled = true + blockingView = view + } + + if let blockingView = blockingView { + UIApplication.shared.windows.first?.addSubview(blockingView) + } + } + + private func enableUserInteraction() { + blockingView?.removeFromSuperview() + blockingView = nil + } +} + +// MARK: - EditProfileDelegate +extension ProfileViewController: EditProfileDelegate { + + func didUpdateProfile(_ profile: Profile) { + self.profile = profile + profileView.updateUI(with: profile) + + let name = profile.name ?? "" + let description = profile.description ?? "" + let website = profile.website ?? "" + let avatar = profile.avatar ?? "" + + + servicesAssembly.profileService.updateProfile( + name: name, + description: description, + website: website, + avatar: avatar + ) { result in + switch result { + case .success(let updatedProfile): + print("Profile successfully updated: \(updatedProfile)") + case .failure(let error): + print("Error updating profile: \(error)") + } + } + } +} + diff --git a/FakeNFT/Scenes /TabBarController/TabBarController.swift b/FakeNFT/Scenes /TabBarController/TabBarController.swift index 99931a214a..505105b950 100644 --- a/FakeNFT/Scenes /TabBarController/TabBarController.swift +++ b/FakeNFT/Scenes /TabBarController/TabBarController.swift @@ -1,25 +1,39 @@ import UIKit final class TabBarController: UITabBarController { - - var servicesAssembly: ServicesAssembly! - + let servicesAssembly = ServicesAssembly( + networkClient: DefaultNetworkClient(), + nftStorage: NftStorageImpl(), + myNftStorage: MyNftStorageImpl() + ) + private let catalogTabBarItem = UITabBarItem( title: NSLocalizedString("Tab.catalog", comment: ""), image: UIImage(systemName: "square.stack.3d.up.fill"), tag: 0 ) + + private let profileTabBarItem = UITabBarItem( + title: "Профиль", + image: UIImage(named: "Profile"), + tag: 0 + ) override func viewDidLoad() { super.viewDidLoad() - + + let profileViewController = ProfileViewController(servicesAssembly: servicesAssembly) + profileViewController.tabBarItem = profileTabBarItem + let profileNavController = UINavigationController(rootViewController: profileViewController) + let catalogController = TestCatalogViewController( servicesAssembly: servicesAssembly ) catalogController.tabBarItem = catalogTabBarItem - viewControllers = [catalogController] + viewControllers = [catalogController,profileNavController] view.backgroundColor = .systemBackground } } + diff --git a/FakeNFT/Services/Requests/RequestConstants.swift b/FakeNFT/Services/Requests/RequestConstants.swift index 9d3529893b..103f324eaf 100644 --- a/FakeNFT/Services/Requests/RequestConstants.swift +++ b/FakeNFT/Services/Requests/RequestConstants.swift @@ -1,5 +1,4 @@ enum RequestConstants { static let baseURL = "https://d5dn3j2ouj72b0ejucbl.apigw.yandexcloud.net" - #warning("Instert your token here") - static let token = "" + static let token = "59db9adc-fa26-4e11-9e0d-0cd1ccdb3512" } diff --git a/FakeNFT/Services/ServicesAssemly.swift b/FakeNFT/Services/ServicesAssemly.swift index 033f8bdf61..76377d9157 100644 --- a/FakeNFT/Services/ServicesAssemly.swift +++ b/FakeNFT/Services/ServicesAssemly.swift @@ -2,13 +2,16 @@ final class ServicesAssembly { private let networkClient: NetworkClient private let nftStorage: NftStorage + private let myNftStorage: MyNftStorage init( networkClient: NetworkClient, - nftStorage: NftStorage + nftStorage: NftStorage, + myNftStorage: MyNftStorage ) { self.networkClient = networkClient self.nftStorage = nftStorage + self.myNftStorage = myNftStorage } var nftService: NftService { @@ -17,4 +20,14 @@ final class ServicesAssembly { storage: nftStorage ) } + var profileService: ProfileService { + ProfileServiceImpl(networkClient: networkClient) + } + + var myNftService: MyNftService { + MyNftServiceImpl( + networkClient: networkClient, + storage: myNftStorage + ) + } } diff --git "a/\320\224\320\265\320\272\320\276\320\274\320\277\320\276\320\267\320\270\321\206\320\270\321\217\321\215\320\277\320\270\320\272\320\260\320\237\321\200\320\276\321\204\320\270\320\273\321\214-5.md" "b/\320\224\320\265\320\272\320\276\320\274\320\277\320\276\320\267\320\270\321\206\320\270\321\217\321\215\320\277\320\270\320\272\320\260\320\237\321\200\320\276\321\204\320\270\320\273\321\214-5.md" new file mode 100644 index 0000000000..0488096102 --- /dev/null +++ "b/\320\224\320\265\320\272\320\276\320\274\320\277\320\276\320\267\320\270\321\206\320\270\321\217\321\215\320\277\320\270\320\272\320\260\320\237\321\200\320\276\321\204\320\270\320\273\321\214-5.md" @@ -0,0 +1,49 @@ +Бекоев Давид Леванович\ +Когорта: 23\ +Группа: 2\ +Эпик: Профиль\ +[Ссылка на доску](https://github.com/users/ilyanikitash/projects/2/views/3) (обертка для биндинга данных) (est: 20 мин; fact: 30 мин) +- Биндинг данных (est: 40 мин; fact: 30 мин) +- ProfileRequest (est: 50 мин; fact: 1,30 часов) +- WebViewViewController (переход с ячейки "О разработчике" + переход по линку на сайт пользователя) (est: 3 часа; fact: 3.30 часов) +- ProfileServiceImpl (данные с бэка) (est: 1.30 час; fact: 2 часов) + + +## Модуль 2: + #### ProfileEditing Screen +- Protocol EditProfileDelegate (est: 45 мин; fact: 1 час) +- Подписать модель Profile под протокол Dto (est: 1 час; fact: 2 часов) +- Верстка EditProfileViewController (est: 3 часа; fact: 3.20 часов) +- Расширение протокола ProfileService (PUT request) (est: 1.30 час; fact: 2 часов) +- EditProfileView (est: 2 часа; fact: 2.30 часов) +- Биндинг данных (est: 40 мин; fact: x часов) +- ProfilePutRequest (est: 50 мин; fact: 1.10 часов) +- ProfileServiceImpl (est: 1.20 час; fact: 1 часов) + + +## Module 3: +#### MyNft Screen +- Верстка ячейки NftCell (est: 1.20 час; fact: 2 часов) +- MyNftServiceImpl (est: 2.20 часа; fact: 2.30 часов) +- MyNftView (est: 2 час; fact: 2.30 часов) +- Верстка MyNftViewController (est: 3 часа; fact: 4 часов) +- Внедрение логики сортировки на экране MyNft Screen (est: 3 часа; fact: 2.30 часов) +- Биндинг данных (est: 30 мин; fact: 30 мин + +#### Favourites Screen +- Верстка ячейки FavouritesNftCell (est: 1.30 час; fact: 2 часов) +- FavouritesNftViewModel (est: 2 часа; fact: 2.20 часов) +- Верстка FavouriteNftViewController (est: 3 часа; fact: 3.10 часа) +- Биндинг данных (est: 30 мин; fact: 30 мин) +