diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ae099a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Xcode +build/ +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +xcuserdata/ +*.xcscmblueprint +*.xccheckout +DerivedData/ +*.moved-aside +*.xcuserstate +*.xcworkspace + +BuildData/ +BuildData/ diff --git a/Navigation.xcodeproj/project.pbxproj b/Navigation.xcodeproj/project.pbxproj index d0a5998..09359dd 100644 --- a/Navigation.xcodeproj/project.pbxproj +++ b/Navigation.xcodeproj/project.pbxproj @@ -6,7 +6,27 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 7D0525362ECCD5760030C300 /* StorageService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7D61C6A02E8936BA0085086C /* StorageService.framework */; }; + 7D0525372ECCD5760030C300 /* StorageService.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7D61C6A02E8936BA0085086C /* StorageService.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 7D0CBDFA2EB9ECA3000437D9 /* iOSIntPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 7D0CBDF92EB9ECA3000437D9 /* iOSIntPackage */; }; + 7D1EF1EC2E94366C00E9C368 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7D1EF1EB2E94366C00E9C368 /* SnapKit */; }; + 7DB850E82F16896300146CB5 /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = 7DB850E72F16896300146CB5 /* FirebaseAI */; }; + 7DB850EA2F16896300146CB5 /* FirebaseAILogic in Frameworks */ = {isa = PBXBuildFile; productRef = 7DB850E92F16896300146CB5 /* FirebaseAILogic */; }; + 7DB850EC2F16896300146CB5 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 7DB850EB2F16896300146CB5 /* FirebaseAnalytics */; }; + 7DB850EE2F16896300146CB5 /* FirebaseAnalyticsCore in Frameworks */ = {isa = PBXBuildFile; productRef = 7DB850ED2F16896300146CB5 /* FirebaseAnalyticsCore */; }; + 7DB850F02F16896300146CB5 /* FirebaseAnalyticsIdentitySupport in Frameworks */ = {isa = PBXBuildFile; productRef = 7DB850EF2F16896300146CB5 /* FirebaseAnalyticsIdentitySupport */; }; + 7DB8527F2F16D18700146CB5 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 7DB8527E2F16D18700146CB5 /* KeychainAccess */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ + 7D0525382ECCD5760030C300 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7D1E38522E280D01003A222D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7D61C69F2E8936BA0085086C; + remoteInfo = StorageService; + }; 7D1E38712E280D02003A222D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 7D1E38522E280D01003A222D /* Project object */; @@ -23,10 +43,25 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 7D05253A2ECCD5760030C300 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 7D0525372ECCD5760030C300 /* StorageService.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 7D1E385A2E280D01003A222D /* Navigation.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Navigation.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7D1E38702E280D02003A222D /* NavigationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NavigationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7D1E387A2E280D02003A222D /* NavigationUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NavigationUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7D61C6A02E8936BA0085086C /* StorageService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StorageService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -34,6 +69,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, + Services/CoreData/Untitled.swift, ); target = 7D1E38592E280D01003A222D /* Navigation */; }; @@ -48,6 +84,16 @@ path = Navigation; sourceTree = ""; }; + 7D61C6A12E8936BA0085086C /* StorageService */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = StorageService; + sourceTree = ""; + }; + 7D9B1F3A2F1C4E5A00A1B2C3 /* NavigationTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = NavigationTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -55,6 +101,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7DB850EC2F16896300146CB5 /* FirebaseAnalytics in Frameworks */, + 7DB850EA2F16896300146CB5 /* FirebaseAILogic in Frameworks */, + 7DB850F02F16896300146CB5 /* FirebaseAnalyticsIdentitySupport in Frameworks */, + 7DB850E82F16896300146CB5 /* FirebaseAI in Frameworks */, + 7DB8527F2F16D18700146CB5 /* KeychainAccess in Frameworks */, + 7D0525362ECCD5760030C300 /* StorageService.framework in Frameworks */, + 7D1EF1EC2E94366C00E9C368 /* SnapKit in Frameworks */, + 7D0CBDFA2EB9ECA3000437D9 /* iOSIntPackage in Frameworks */, + 7DB850EE2F16896300146CB5 /* FirebaseAnalyticsCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -72,6 +127,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7D61C69D2E8936BA0085086C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -79,6 +141,8 @@ isa = PBXGroup; children = ( 7D1E385C2E280D01003A222D /* Navigation */, + 7D9B1F3A2F1C4E5A00A1B2C3 /* NavigationTests */, + 7D61C6A12E8936BA0085086C /* StorageService */, 7D1E385B2E280D01003A222D /* Products */, ); sourceTree = ""; @@ -89,12 +153,23 @@ 7D1E385A2E280D01003A222D /* Navigation.app */, 7D1E38702E280D02003A222D /* NavigationTests.xctest */, 7D1E387A2E280D02003A222D /* NavigationUITests.xctest */, + 7D61C6A02E8936BA0085086C /* StorageService.framework */, ); name = Products; sourceTree = ""; }; /* End PBXGroup section */ +/* Begin PBXHeadersBuildPhase section */ + 7D61C69B2E8936BA0085086C /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + /* Begin PBXNativeTarget section */ 7D1E38592E280D01003A222D /* Navigation */ = { isa = PBXNativeTarget; @@ -103,17 +178,17 @@ 7D1E38562E280D01003A222D /* Sources */, 7D1E38572E280D01003A222D /* Frameworks */, 7D1E38582E280D01003A222D /* Resources */, + 7D05253A2ECCD5760030C300 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + 7D0525392ECCD5760030C300 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 7D1E385C2E280D01003A222D /* Navigation */, ); name = Navigation; - packageProductDependencies = ( - ); productName = Navigation; productReference = 7D1E385A2E280D01003A222D /* Navigation.app */; productType = "com.apple.product-type.application"; @@ -131,9 +206,10 @@ dependencies = ( 7D1E38722E280D02003A222D /* PBXTargetDependency */, ); - name = NavigationTests; - packageProductDependencies = ( + fileSystemSynchronizedGroups = ( + 7D9B1F3A2F1C4E5A00A1B2C3 /* NavigationTests */, ); + name = NavigationTests; productName = NavigationTests; productReference = 7D1E38702E280D02003A222D /* NavigationTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -152,12 +228,31 @@ 7D1E387C2E280D02003A222D /* PBXTargetDependency */, ); name = NavigationUITests; - packageProductDependencies = ( - ); productName = NavigationUITests; productReference = 7D1E387A2E280D02003A222D /* NavigationUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 7D61C69F2E8936BA0085086C /* StorageService */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7D61C6A82E8936BA0085086C /* Build configuration list for PBXNativeTarget "StorageService" */; + buildPhases = ( + 7D61C69B2E8936BA0085086C /* Headers */, + 7D61C69C2E8936BA0085086C /* Sources */, + 7D61C69D2E8936BA0085086C /* Frameworks */, + 7D61C69E2E8936BA0085086C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7D61C6A12E8936BA0085086C /* StorageService */, + ); + name = StorageService; + productName = StorageService; + productReference = 7D61C6A02E8936BA0085086C /* StorageService.framework */; + productType = "com.apple.product-type.framework"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -165,8 +260,8 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1620; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; TargetAttributes = { 7D1E38592E280D01003A222D = { CreatedOnToolsVersion = 16.2; @@ -179,6 +274,9 @@ CreatedOnToolsVersion = 16.2; TestTargetID = 7D1E38592E280D01003A222D; }; + 7D61C69F2E8936BA0085086C = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 7D1E38552E280D01003A222D /* Build configuration list for PBXProject "Navigation" */; @@ -187,9 +285,16 @@ knownRegions = ( en, Base, + ru, ); mainGroup = 7D1E38512E280D01003A222D; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 7D1EF1EA2E94366C00E9C368 /* XCRemoteSwiftPackageReference "SnapKit" */, + 7D40FA692EC39F3000DD8AC4 /* XCRemoteSwiftPackageReference "iOSIntPackage" */, + 7DB850E62F16896300146CB5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + 7DB8527D2F16D18700146CB5 /* XCRemoteSwiftPackageReference "KeychainAccess" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 7D1E385B2E280D01003A222D /* Products */; projectDirPath = ""; @@ -198,6 +303,7 @@ 7D1E38592E280D01003A222D /* Navigation */, 7D1E386F2E280D02003A222D /* NavigationTests */, 7D1E38792E280D02003A222D /* NavigationUITests */, + 7D61C69F2E8936BA0085086C /* StorageService */, ); }; /* End PBXProject section */ @@ -224,6 +330,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7D61C69E2E8936BA0085086C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -248,9 +361,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7D61C69C2E8936BA0085086C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 7D0525392ECCD5760030C300 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7D61C69F2E8936BA0085086C /* StorageService */; + targetProxy = 7D0525382ECCD5760030C300 /* PBXContainerItemProxy */; + }; 7D1E38722E280D02003A222D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 7D1E38592E280D01003A222D /* Navigation */; @@ -271,13 +396,16 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7Y9FQ552PV; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Navigation/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = ""; + INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -298,13 +426,16 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7Y9FQ552PV; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Navigation/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = ""; + INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -355,7 +486,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -376,6 +507,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -418,7 +550,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -432,6 +564,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -505,6 +638,84 @@ }; name = Release; }; + 7D61C6A92E8936BA0085086C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = Wowgorno.StorageService; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 7D61C6AA2E8936BA0085086C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = Wowgorno.StorageService; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -544,7 +755,102 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7D61C6A82E8936BA0085086C /* Build configuration list for PBXNativeTarget "StorageService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7D61C6A92E8936BA0085086C /* Debug */, + 7D61C6AA2E8936BA0085086C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 7D0CBDF82EB9ECA3000437D9 /* XCRemoteSwiftPackageReference "iOSIntPackage" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TrueMax/iOSIntPackage"; + requirement = { + branch = imagepublisher; + kind = branch; + }; + }; + 7D1EF1EA2E94366C00E9C368 /* XCRemoteSwiftPackageReference "SnapKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SnapKit/SnapKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.7.1; + }; + }; + 7D40FA692EC39F3000DD8AC4 /* XCRemoteSwiftPackageReference "iOSIntPackage" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TrueMax/iOSIntPackage"; + requirement = { + branch = imagepublisher; + kind = branch; + }; + }; + 7DB850E62F16896300146CB5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.7.0; + }; + }; + 7DB8527D2F16D18700146CB5 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7D0CBDF92EB9ECA3000437D9 /* iOSIntPackage */ = { + isa = XCSwiftPackageProductDependency; + package = 7D0CBDF82EB9ECA3000437D9 /* XCRemoteSwiftPackageReference "iOSIntPackage" */; + productName = iOSIntPackage; + }; + 7D1EF1EB2E94366C00E9C368 /* SnapKit */ = { + isa = XCSwiftPackageProductDependency; + package = 7D1EF1EA2E94366C00E9C368 /* XCRemoteSwiftPackageReference "SnapKit" */; + productName = SnapKit; + }; + 7DB850E72F16896300146CB5 /* FirebaseAI */ = { + isa = XCSwiftPackageProductDependency; + package = 7DB850E62F16896300146CB5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAI; + }; + 7DB850E92F16896300146CB5 /* FirebaseAILogic */ = { + isa = XCSwiftPackageProductDependency; + package = 7DB850E62F16896300146CB5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAILogic; + }; + 7DB850EB2F16896300146CB5 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 7DB850E62F16896300146CB5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 7DB850ED2F16896300146CB5 /* FirebaseAnalyticsCore */ = { + isa = XCSwiftPackageProductDependency; + package = 7DB850E62F16896300146CB5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsCore; + }; + 7DB850EF2F16896300146CB5 /* FirebaseAnalyticsIdentitySupport */ = { + isa = XCSwiftPackageProductDependency; + package = 7DB850E62F16896300146CB5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsIdentitySupport; + }; + 7DB8527E2F16D18700146CB5 /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = 7DB8527D2F16D18700146CB5 /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 7D1E38522E280D01003A222D /* Project object */; } diff --git a/Navigation.xcodeproj/xcshareddata/xcschemes/Navigation.xcscheme b/Navigation.xcodeproj/xcshareddata/xcschemes/Navigation.xcscheme new file mode 100644 index 0000000..888dfa4 --- /dev/null +++ b/Navigation.xcodeproj/xcshareddata/xcschemes/Navigation.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Navigation.xcodeproj/xcshareddata/xcschemes/Release.xcscheme b/Navigation.xcodeproj/xcshareddata/xcschemes/Release.xcscheme new file mode 100644 index 0000000..14fd5ca --- /dev/null +++ b/Navigation.xcodeproj/xcshareddata/xcschemes/Release.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Navigation.xcodeproj/xcuserdata/wowgorno.xcuserdatad/xcschemes/xcschememanagement.plist b/Navigation.xcodeproj/xcuserdata/wowgorno.xcuserdatad/xcschemes/xcschememanagement.plist index e179c52..df474c3 100644 --- a/Navigation.xcodeproj/xcuserdata/wowgorno.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Navigation.xcodeproj/xcuserdata/wowgorno.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,49 @@ orderHint 0 + Release.xcscheme_^#shared#^_ + + orderHint + 1 + + SnapKitPlayground (Playground) 1.xcscheme + + orderHint + 4 + + SnapKitPlayground (Playground).xcscheme + + orderHint + 2 + + StirageService.xcscheme_^#shared#^_ + + orderHint + 5 + + StorageService.xcscheme_^#shared#^_ + + orderHint + 2 + + + SuppressBuildableAutocreation + + 7D1E38592E280D01003A222D + + primary + + + 7D1E386F2E280D02003A222D + + primary + + + 7D1E38792E280D02003A222D + + primary + + diff --git a/Navigation/AppConfiguration.swift b/Navigation/AppConfiguration.swift new file mode 100644 index 0000000..435591b --- /dev/null +++ b/Navigation/AppConfiguration.swift @@ -0,0 +1,12 @@ +// +// AppConfiguration.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12/16/25. +// + +enum AppConfiguration { + case people(String) + case starship(String) + case planet(String) +} diff --git a/Navigation/AppCoordinator.swift b/Navigation/AppCoordinator.swift new file mode 100644 index 0000000..35c7503 --- /dev/null +++ b/Navigation/AppCoordinator.swift @@ -0,0 +1,66 @@ +// +// AppCoordinator.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/29/25. +// +import UIKit + +/// Арр Координатор +final class AppCoordinator: Coordinator { + + private let window: UIWindow + private var tabBarCoordinator: TabBarCoordinator? + private var loginCoordinator: LoginCoordinator? + + init(window: UIWindow) { + self.window = window + NotificationCenter.default.addObserver( + self, + selector: #selector(handleLogoutRequested), + name: .appDidRequestLogout, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func start() { + if FirebaseSessionStorage.shared.isAuthorized { + showMainApp() + } else { + showLogin() + } + } + + private func showLogin() { + let navigationController = UINavigationController() + window.rootViewController = navigationController + window.makeKeyAndVisible() + + let coordinator = LoginCoordinator(navigationController: navigationController) + coordinator.onFinish = { [weak self] _ in + self?.showMainApp() + } + loginCoordinator = coordinator + coordinator.start() + } + + private func showMainApp() { + let tabBarCoordinator = TabBarCoordinator() + tabBarCoordinator.start() + self.tabBarCoordinator = tabBarCoordinator + self.loginCoordinator = nil + + window.rootViewController = tabBarCoordinator.tabBarController + window.makeKeyAndVisible() + } + + @objc private func handleLogoutRequested() { + FirebaseSessionStorage.shared.clear() + tabBarCoordinator = nil + showLogin() + } +} diff --git a/Navigation/AppDelegate.swift b/Navigation/AppDelegate.swift index 977ac85..38d86bc 100644 --- a/Navigation/AppDelegate.swift +++ b/Navigation/AppDelegate.swift @@ -4,33 +4,38 @@ // // Created by MAXIM GORNOSTAEV on 16.07.2025. // - import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { + private let localNotificationsService: LocalNotificationsServiceProtocol = LocalNotificationsService() + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + localNotificationsService.registerForLatestUpdatesIfPossible() + UserDefaults.standard.register(defaults: [ + "sortAscending": true, + "themeMode": AppThemeMode.system.rawValue + ]) - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. return true } // MARK: UISceneSession Lifecycle - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) } - - } - diff --git a/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png b/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png new file mode 100644 index 0000000..3b0dbc3 Binary files /dev/null and b/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png differ diff --git a/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png b/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png new file mode 100644 index 0000000..3b0dbc3 Binary files /dev/null and b/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png differ diff --git a/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..3b0dbc3 Binary files /dev/null and b/Navigation/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Navigation/Assets.xcassets/AppIcon.appiconset/Contents.json b/Navigation/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..4f4b8fb 100644 --- a/Navigation/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Navigation/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "AppIcon-dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "AppIcon-tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Navigation/Assets.xcassets/BluePixel.imageset/Contents.json b/Navigation/Assets.xcassets/BluePixel.imageset/Contents.json new file mode 100644 index 0000000..5b72055 --- /dev/null +++ b/Navigation/Assets.xcassets/BluePixel.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "blue_pixel.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/BluePixel.imageset/blue_pixel.png b/Navigation/Assets.xcassets/BluePixel.imageset/blue_pixel.png new file mode 100644 index 0000000..a2fcdb3 Binary files /dev/null and b/Navigation/Assets.xcassets/BluePixel.imageset/blue_pixel.png differ diff --git a/Navigation/Assets.xcassets/VKBlue .colorset/Contents.json b/Navigation/Assets.xcassets/VKBlue .colorset/Contents.json new file mode 100644 index 0000000..66b3c10 --- /dev/null +++ b/Navigation/Assets.xcassets/VKBlue .colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0x85", + "red" : "0x48" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0x85", + "red" : "0x48" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0x85", + "red" : "0x48" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/VKlogo.imageset/Contents.json b/Navigation/Assets.xcassets/VKlogo.imageset/Contents.json new file mode 100644 index 0000000..5f670ca --- /dev/null +++ b/Navigation/Assets.xcassets/VKlogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/VKlogo.imageset/logo.png b/Navigation/Assets.xcassets/VKlogo.imageset/logo.png new file mode 100644 index 0000000..3b0dbc3 Binary files /dev/null and b/Navigation/Assets.xcassets/VKlogo.imageset/logo.png differ diff --git a/Navigation/Assets.xcassets/avatar.imageset/144.png b/Navigation/Assets.xcassets/avatar.imageset/144.png new file mode 100644 index 0000000..0518a2c Binary files /dev/null and b/Navigation/Assets.xcassets/avatar.imageset/144.png differ diff --git a/Navigation/Assets.xcassets/avatar.imageset/48.png b/Navigation/Assets.xcassets/avatar.imageset/48.png new file mode 100644 index 0000000..c5d16a9 Binary files /dev/null and b/Navigation/Assets.xcassets/avatar.imageset/48.png differ diff --git a/Navigation/Assets.xcassets/avatar.imageset/88.png b/Navigation/Assets.xcassets/avatar.imageset/88.png new file mode 100644 index 0000000..630e9e8 Binary files /dev/null and b/Navigation/Assets.xcassets/avatar.imageset/88.png differ diff --git a/Navigation/Assets.xcassets/avatar.imageset/Contents.json b/Navigation/Assets.xcassets/avatar.imageset/Contents.json new file mode 100644 index 0000000..e615418 --- /dev/null +++ b/Navigation/Assets.xcassets/avatar.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "48.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "88.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "144.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/hulk.imageset/0a6da817df9b9b222f939b51ebcd3156.JPG b/Navigation/Assets.xcassets/hulk.imageset/0a6da817df9b9b222f939b51ebcd3156.JPG new file mode 100644 index 0000000..5c3d092 Binary files /dev/null and b/Navigation/Assets.xcassets/hulk.imageset/0a6da817df9b9b222f939b51ebcd3156.JPG differ diff --git a/Navigation/Assets.xcassets/hulk.imageset/Contents.json b/Navigation/Assets.xcassets/hulk.imageset/Contents.json new file mode 100644 index 0000000..e4328f0 --- /dev/null +++ b/Navigation/Assets.xcassets/hulk.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "0a6da817df9b9b222f939b51ebcd3156.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/my_photo.imageset/1024 1.png b/Navigation/Assets.xcassets/my_photo.imageset/1024 1.png new file mode 100644 index 0000000..d5c9245 Binary files /dev/null and b/Navigation/Assets.xcassets/my_photo.imageset/1024 1.png differ diff --git a/Navigation/Assets.xcassets/my_photo.imageset/1024 2.png b/Navigation/Assets.xcassets/my_photo.imageset/1024 2.png new file mode 100644 index 0000000..d5c9245 Binary files /dev/null and b/Navigation/Assets.xcassets/my_photo.imageset/1024 2.png differ diff --git a/Navigation/Assets.xcassets/my_photo.imageset/1024.png b/Navigation/Assets.xcassets/my_photo.imageset/1024.png new file mode 100644 index 0000000..d5c9245 Binary files /dev/null and b/Navigation/Assets.xcassets/my_photo.imageset/1024.png differ diff --git a/Navigation/Assets.xcassets/my_photo.imageset/Contents.json b/Navigation/Assets.xcassets/my_photo.imageset/Contents.json new file mode 100644 index 0000000..c5cb369 --- /dev/null +++ b/Navigation/Assets.xcassets/my_photo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "1024 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "1024 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/pp.imageset/Contents.json b/Navigation/Assets.xcassets/pp.imageset/Contents.json new file mode 100644 index 0000000..ce6ad28 --- /dev/null +++ b/Navigation/Assets.xcassets/pp.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "camphoto_1817792895.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/pp.imageset/camphoto_1817792895.JPG b/Navigation/Assets.xcassets/pp.imageset/camphoto_1817792895.JPG new file mode 100644 index 0000000..82a7b51 Binary files /dev/null and b/Navigation/Assets.xcassets/pp.imageset/camphoto_1817792895.JPG differ diff --git a/Navigation/Assets.xcassets/skala.imageset/Contents.json b/Navigation/Assets.xcassets/skala.imageset/Contents.json new file mode 100644 index 0000000..2438f84 --- /dev/null +++ b/Navigation/Assets.xcassets/skala.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "e5f219a364de4dab437301a5098f48d9.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Navigation/Assets.xcassets/skala.imageset/e5f219a364de4dab437301a5098f48d9.JPG b/Navigation/Assets.xcassets/skala.imageset/e5f219a364de4dab437301a5098f48d9.JPG new file mode 100644 index 0000000..3c0e648 Binary files /dev/null and b/Navigation/Assets.xcassets/skala.imageset/e5f219a364de4dab437301a5098f48d9.JPG differ diff --git a/Navigation/Base.lproj/LaunchScreen.storyboard b/Navigation/Base.lproj/LaunchScreen.storyboard index 865e932..003ec1c 100644 --- a/Navigation/Base.lproj/LaunchScreen.storyboard +++ b/Navigation/Base.lproj/LaunchScreen.storyboard @@ -1,7 +1,8 @@ - - + + + - + @@ -11,10 +12,10 @@ - + - + diff --git a/Navigation/Checker.swift b/Navigation/Checker.swift new file mode 100644 index 0000000..ca22f63 --- /dev/null +++ b/Navigation/Checker.swift @@ -0,0 +1,17 @@ +// +// Checker.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/19/25. +// +final class Checker { + static let shared = Checker() + private let email = "wowgorno@example.com" + private let password = "123456" + private init() {} + + func check(email: String, password: String) -> Bool { + print("[DEBUG] Checker comparing. expected: (\(self.email),\(self.password)) got: (\(email),\(password))") + return email == self.email && password == self.password + } +} diff --git a/Navigation/Coordinator.swift b/Navigation/Coordinator.swift new file mode 100644 index 0000000..0ce8965 --- /dev/null +++ b/Navigation/Coordinator.swift @@ -0,0 +1,12 @@ +// +// Coordinator.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/29/25. +// + +import UIKit + +protocol Coordinator: AnyObject { + func start() +} diff --git a/Navigation/Coordinators/ChatsCoordinator.swift b/Navigation/Coordinators/ChatsCoordinator.swift new file mode 100644 index 0000000..e989b4c --- /dev/null +++ b/Navigation/Coordinators/ChatsCoordinator.swift @@ -0,0 +1,14 @@ +import UIKit + +final class ChatsCoordinator: Coordinator { + let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let vc = ChatsViewController() + navigationController.setViewControllers([vc], animated: false) + } +} diff --git a/Navigation/Coordinators/ClipsCoordinator.swift b/Navigation/Coordinators/ClipsCoordinator.swift new file mode 100644 index 0000000..55e8c33 --- /dev/null +++ b/Navigation/Coordinators/ClipsCoordinator.swift @@ -0,0 +1,14 @@ +import UIKit + +final class ClipsCoordinator: Coordinator { + let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let vc = ClipsViewController() + navigationController.setViewControllers([vc], animated: false) + } +} diff --git a/Navigation/Coordinators/HomeCoordinator.swift b/Navigation/Coordinators/HomeCoordinator.swift new file mode 100644 index 0000000..c0a970f --- /dev/null +++ b/Navigation/Coordinators/HomeCoordinator.swift @@ -0,0 +1,37 @@ +import UIKit + +final class HomeCoordinator: Coordinator { + let navigationController: UINavigationController + private let userService: UserService + + init( + navigationController: UINavigationController, + userService: UserService = CurrentUserService() + ) { + self.navigationController = navigationController + self.userService = userService + } + + func start() { + let feedViewModel = SocialFeedViewModel( + service: CatFeedService(), + cacheRepository: CoreDataFeedCacheRepository() + ) + let vc = HomeViewController(remoteFeedViewModel: feedViewModel) + let login = FirebaseSessionStorage.shared.user?.email ?? "Wowgorno" + let user = userService.getUser(login: login) + vc.configureAvatar(user?.avatar) + vc.onOpenProfile = { [weak self] in + guard let self else { return } + let profileLogin = FirebaseSessionStorage.shared.user?.email ?? "Wowgorno" + let currentUser = self.userService.getUser(login: profileLogin) + let vm = ProfileViewModel(user: currentUser) + let profileVC = ProfileViewController( + viewModel: vm, + screenMode: .myProfile + ) + self.navigationController.pushViewController(profileVC, animated: true) + } + navigationController.setViewControllers([vc], animated: false) + } +} diff --git a/Navigation/Coordinators/MenuCoordinator.swift b/Navigation/Coordinators/MenuCoordinator.swift new file mode 100644 index 0000000..1a835b4 --- /dev/null +++ b/Navigation/Coordinators/MenuCoordinator.swift @@ -0,0 +1,83 @@ +import UIKit + +final class MenuCoordinator: Coordinator { + let navigationController: UINavigationController + private var passwordCoordinator: PasswordCoordinator? + private let userService: UserService + + init( + navigationController: UINavigationController, + userService: UserService = CurrentUserService() + ) { + self.navigationController = navigationController + self.userService = userService + } + + func start() { + let vc = MenuViewController() + vc.onAction = { [weak self] action in + self?.handle(action: action) + } + navigationController.setViewControllers([vc], animated: false) + } + + private func handle(action: MenuViewController.MenuAction) { + switch action { + case .profile: + let login = FirebaseSessionStorage.shared.user?.email ?? "Wowgorno" + let user = userService.getUser(login: login) + let vm = ProfileViewModel(user: user) + let vc = ProfileViewController(viewModel: vm) + navigationController.pushViewController(vc, animated: true) + + case .favorites: + let vc = FavoritesViewController() + vc.onOpenFiles = { [weak self] in self?.showFiles() } + vc.onOpenSettings = { [weak self] in self?.showSettings() } + navigationController.pushViewController(vc, animated: true) + + case .files: + showFiles() + + case .settings: + showSettings() + + case .posts: + let vc = PostsViewController() + navigationController.pushViewController(vc, animated: true) + + case .info: + let vc = InfoViewController() + navigationController.pushViewController(vc, animated: true) + } + } + + private func showFiles() { + let vc = FilesViewController() + navigationController.pushViewController(vc, animated: true) + } + + private func showSettings() { + let vc = SettingsViewController() + vc.onChangePassword = { [weak self] in + self?.showPasswordFlow() + } + vc.onLogout = { [weak self] in + self?.navigationController.popToRootViewController(animated: false) + NotificationCenter.default.post(name: .appDidRequestLogout, object: nil) + } + navigationController.pushViewController(vc, animated: true) + } + + private func showPasswordFlow() { + let coordinator = PasswordCoordinator(navigationController: navigationController) + passwordCoordinator = coordinator + + coordinator.onFinish = { [weak self] in + self?.passwordCoordinator = nil + self?.navigationController.popViewController(animated: true) + } + + coordinator.start() + } +} diff --git a/Navigation/Coordinators/PasswordCoordinator.swift b/Navigation/Coordinators/PasswordCoordinator.swift new file mode 100644 index 0000000..a3c7cb9 --- /dev/null +++ b/Navigation/Coordinators/PasswordCoordinator.swift @@ -0,0 +1,26 @@ +// +// PasswordCoordinator.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/13/26. +// + +import UIKit + +final class PasswordCoordinator: Coordinator { + + private let navigationController: UINavigationController + var onFinish: (() -> Void)? + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let vc = PasswordViewController() + vc.onSuccess = { [weak self] in + self?.onFinish?() + } + navigationController.pushViewController(vc, animated: true) + } +} diff --git a/Navigation/Coordinators/SearchCoordinator.swift b/Navigation/Coordinators/SearchCoordinator.swift new file mode 100644 index 0000000..dee8af1 --- /dev/null +++ b/Navigation/Coordinators/SearchCoordinator.swift @@ -0,0 +1,14 @@ +import UIKit + +final class SearchCoordinator: Coordinator { + let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let vc = SearchViewController() + navigationController.setViewControllers([vc], animated: false) + } +} diff --git a/Navigation/FavoritesViewController.swift b/Navigation/FavoritesViewController.swift new file mode 100644 index 0000000..e328860 --- /dev/null +++ b/Navigation/FavoritesViewController.swift @@ -0,0 +1,198 @@ +// +// FavoritesViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/23/26. +// + +import UIKit + +final class FavoritesViewController: UIViewController { + + var onOpenFiles: (() -> Void)? + var onOpenSettings: (() -> Void)? + + private let tableView = UITableView() + private var posts: [Post] = [] + + private let favoritesRepository = FavoritesRepository.shared + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + title = L10n.tr("favorites.title") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + + setupTableView() + setupNavigationBar() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + loadAll() + } + + // MARK: - Setup + + private func setupTableView() { + view.addSubview(tableView) + tableView.frame = view.bounds + + tableView.register(PostCell.self, forCellReuseIdentifier: "PostCell") + tableView.dataSource = self + tableView.delegate = self + } + + private func setupNavigationBar() { + let filesButton = UIBarButtonItem( + image: UIImage(systemName: "folder"), + style: .plain, + target: self, + action: #selector(openFiles) + ) + + let settingsButton = UIBarButtonItem( + image: UIImage(systemName: "gearshape"), + style: .plain, + target: self, + action: #selector(openSettings) + ) + + let searchButton = UIBarButtonItem( + image: UIImage(systemName: "magnifyingglass"), + style: .plain, + target: self, + action: #selector(searchByAuthor) + ) + + let clearButton = UIBarButtonItem( + image: UIImage(systemName: "xmark.circle"), + style: .plain, + target: self, + action: #selector(clearFilter) + ) + + navigationItem.leftBarButtonItems = [settingsButton, filesButton] + navigationItem.rightBarButtonItems = [clearButton, searchButton] + } + + // MARK: - Data + + private func loadAll() { + posts = favoritesRepository.fetchAll() + tableView.reloadData() + } + + // MARK: - Actions + + @objc private func searchByAuthor() { + let alert = UIAlertController( + title: L10n.tr("favorites.search.title"), + message: L10n.tr("favorites.search.message"), + preferredStyle: .alert + ) + + alert.addTextField { textField in + textField.placeholder = L10n.tr("favorites.search.placeholder") + } + + let apply = UIAlertAction(title: L10n.tr("common.apply"), style: .default) { _ in + let author = alert.textFields?.first?.text ?? "" + + if author.isEmpty { + self.loadAll() + } else { + self.posts = self.favoritesRepository + .fetchAll() + .filter { $0.author.lowercased().contains(author.lowercased()) } + + self.tableView.reloadData() + } + } + + let cancel = UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel) + + alert.addAction(apply) + alert.addAction(cancel) + + present(alert, animated: true) + } + + @objc private func clearFilter() { + loadAll() + } + + @objc private func openFiles() { + onOpenFiles?() + } + + @objc private func openSettings() { + onOpenSettings?() + } +} + +// MARK: - UITableViewDataSource + +extension FavoritesViewController: UITableViewDataSource { + + func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + posts.count + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + let cell = tableView.dequeueReusableCell( + withIdentifier: "PostCell", + for: indexPath + ) as! PostCell + + let post = posts[indexPath.row] + + cell.configure(post: post, isFavorite: true) + + cell.onLikeTap = { [weak self] in + guard let self else { return } + + _ = self.favoritesRepository.toggle(post: post) + self.loadAll() + } + + return cell + } +} + +// MARK: - UITableViewDelegate + +extension FavoritesViewController: UITableViewDelegate { + + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + + let delete = UIContextualAction( + style: .destructive, + title: L10n.tr("common.delete") + ) { [weak self] _, _, completion in + guard let self else { return } + + let post = self.posts[indexPath.row] + _ = self.favoritesRepository.toggle(post: post) + + self.posts.remove(at: indexPath.row) + tableView.deleteRows(at: [indexPath], with: .automatic) + + completion(true) + } + + return UISwipeActionsConfiguration(actions: [delete]) + } +} diff --git a/Navigation/FeedViewController.swift b/Navigation/FeedViewController.swift index bd5d267..345495f 100644 --- a/Navigation/FeedViewController.swift +++ b/Navigation/FeedViewController.swift @@ -7,29 +7,107 @@ import UIKit -class FeedViewController: UIViewController { - let post = Post(title: "Hello from Feed") +final class FeedViewController: UIViewController { + + var onOpenPost: ((Post) -> Void)? + + // MARK: - Model + private let viewModel = FeedViewModel() + + // MARK: - UI + + private let guessField: UITextField = { + let field = UITextField() + field.placeholder = L10n.tr("feed.enter_word") + field.backgroundColor = StyleGuide.Colors.backgroundSecondary + field.layer.borderColor = StyleGuide.Colors.borderStrong.cgColor + field.layer.borderWidth = 1 + field.layer.cornerRadius = 10 + field.setLeftPaddingPoints(10) + field.translatesAutoresizingMaskIntoConstraints = false + return field + }() + + private lazy var checkGuessButton = CustomButton( + title: L10n.tr("feed.check"), + backgroundColor: StyleGuide.Colors.accent + ) { [weak self] in + self?.checkWord() + } + + private let resultLabel: UILabel = { + let label = UILabel() + label.font = StyleGuide.Fonts.body(18, weight: .medium) + label.textAlignment = .center + label.textColor = StyleGuide.Colors.textPrimary + label.text = L10n.tr("feed.enter_word") + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground - let button = UIButton(type: .system) - button.setTitle("Open Post", for: .normal) - button.addTarget(self, action: #selector(openPost), for: .touchUpInside) + title = L10n.tr("feed.title") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + + setupUI() + } + + // MARK: - UI Setup + private func setupUI() { + view.addSubview(guessField) + view.addSubview(checkGuessButton) + view.addSubview(resultLabel) - button.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(button) NSLayoutConstraint.activate([ - button.centerXAnchor.constraint(equalTo: view.centerXAnchor), - button.centerYAnchor.constraint(equalTo: view.centerYAnchor) + guessField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40), + guessField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + guessField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + guessField.heightAnchor.constraint(equalToConstant: 50), + + checkGuessButton.topAnchor.constraint(equalTo: guessField.bottomAnchor, constant: 16), + checkGuessButton.leadingAnchor.constraint(equalTo: guessField.leadingAnchor), + checkGuessButton.trailingAnchor.constraint(equalTo: guessField.trailingAnchor), + checkGuessButton.heightAnchor.constraint(equalToConstant: 50), + + resultLabel.topAnchor.constraint(equalTo: checkGuessButton.bottomAnchor, constant: 20), + resultLabel.leadingAnchor.constraint(equalTo: guessField.leadingAnchor), + resultLabel.trailingAnchor.constraint(equalTo: guessField.trailingAnchor), + resultLabel.heightAnchor.constraint(equalToConstant: 30) ]) } - @objc func openPost() { - let postVC = PostViewController() - postVC.post = post - navigationController?.pushViewController(postVC, animated: true) + // MARK: - Logic + private func checkWord() { + viewModel.check(word: guessField.text) + + switch viewModel.state { + case .idle: + resultLabel.text = L10n.tr("feed.enter_word") + resultLabel.textColor = StyleGuide.Colors.textPrimary + case .emptyInput: + resultLabel.text = L10n.tr("feed.enter_word_required") + resultLabel.textColor = StyleGuide.Colors.danger + case .checked(let isCorrect): + if isCorrect { + resultLabel.text = L10n.tr("feed.correct") + resultLabel.textColor = StyleGuide.Colors.success + } else { + resultLabel.text = L10n.tr("feed.incorrect") + resultLabel.textColor = StyleGuide.Colors.danger + } + } } } +// MARK: - Padding Helper +private extension UITextField { + func setLeftPaddingPoints(_ amount: CGFloat) { + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: amount, height: self.frame.height)) + leftView = paddingView + leftViewMode = .always + } +} diff --git a/Navigation/FeedViewModel.swift b/Navigation/FeedViewModel.swift new file mode 100644 index 0000000..5d20776 --- /dev/null +++ b/Navigation/FeedViewModel.swift @@ -0,0 +1,49 @@ +// +// FeedViewModel.swift +// Navigation +// +// Created by Codex on 19.02.2026. +// + +import Foundation + +protocol WordValidationServiceProtocol { + func check(word: String) -> Bool +} + +struct WordValidationService: WordValidationServiceProtocol { + // Демонстрационное слово для экрана проверки. + private let correctWord = "кот" + + func check(word: String) -> Bool { + word.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(correctWord) == .orderedSame + } +} + +final class FeedViewModel { + + enum State: Equatable { + case idle + case emptyInput + case checked(isCorrect: Bool) + } + + private let model: WordValidationServiceProtocol + private(set) var state: State = .idle + + init(model: WordValidationServiceProtocol = WordValidationService()) { + self.model = model + } + + func check(word: String?) { + let trimmed = word?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { + state = .emptyInput + return + } + + let isCorrect = model.check(word: trimmed) + state = .checked(isCorrect: isCorrect) + } +} diff --git a/Navigation/Film.swift b/Navigation/Film.swift new file mode 100644 index 0000000..e931512 --- /dev/null +++ b/Navigation/Film.swift @@ -0,0 +1,10 @@ +// +// Film.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12/17/25. +// + +struct Film { + let title: String +} diff --git a/Navigation/GoogleService-Info.plist b/Navigation/GoogleService-Info.plist new file mode 100644 index 0000000..ca3f459 --- /dev/null +++ b/Navigation/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCzi-gN1otoN_EpwzHyKjqKvrTk5CA5bEs + GCM_SENDER_ID + 575120485260 + PLIST_VERSION + 1 + BUNDLE_ID + com.wowgorno.Navigation + PROJECT_ID + navigation-ios62 + STORAGE_BUCKET + navigation-ios62.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:575120485260:ios:e9225e3b719c5340de44a9 + + \ No newline at end of file diff --git a/Navigation/Info.plist b/Navigation/Info.plist index 0eb786d..8f1466a 100644 --- a/Navigation/Info.plist +++ b/Navigation/Info.plist @@ -19,5 +19,7 @@ + CAT_API_KEY + live_SBZByRa3TbUK9J9kgogrZYveyY8XlO6GInIQbMRY5XWdew4jWmc8Rws1gaqth8i3 diff --git a/Navigation/InfoViewController.swift b/Navigation/InfoViewController.swift index c9c44ff..c4b15ff 100644 --- a/Navigation/InfoViewController.swift +++ b/Navigation/InfoViewController.swift @@ -7,31 +7,138 @@ import UIKit -class InfoViewController: UIViewController { +final class InfoViewController: UIViewController { + + private let filmTitleLabel = UILabel() + private let planetPeriodLabel = UILabel() + override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .white + print("INFO VC LOADED") + + view.backgroundColor = StyleGuide.Colors.backgroundSecondary + + setupLabels() + setupButton() + + fetchFilm() + fetchPlanet() + } + + // MARK: - UI + + private func setupLabels() { + filmTitleLabel.translatesAutoresizingMaskIntoConstraints = false + planetPeriodLabel.translatesAutoresizingMaskIntoConstraints = false + + filmTitleLabel.font = StyleGuide.Fonts.title(20) + filmTitleLabel.textColor = StyleGuide.Colors.textPrimary + filmTitleLabel.textAlignment = .center + filmTitleLabel.numberOfLines = 0 + filmTitleLabel.text = L10n.tr("info.loading_film") + + planetPeriodLabel.font = StyleGuide.Fonts.body(16, weight: .medium) + planetPeriodLabel.textColor = StyleGuide.Colors.textSecondary + planetPeriodLabel.textAlignment = .center + planetPeriodLabel.numberOfLines = 0 + planetPeriodLabel.text = L10n.tr("info.loading_planet") + + view.addSubview(filmTitleLabel) + view.addSubview(planetPeriodLabel) + + NSLayoutConstraint.activate([ + filmTitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + filmTitleLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -60), + filmTitleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + filmTitleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + planetPeriodLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + planetPeriodLabel.topAnchor.constraint(equalTo: filmTitleLabel.bottomAnchor, constant: 20), + planetPeriodLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + planetPeriodLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + ]) + } + + private func setupButton() { let button = UIButton(type: .system) - button.setTitle("Show Alert", for: .normal) + button.setTitle(L10n.tr("info.show_alert"), for: .normal) button.addTarget(self, action: #selector(showAlert), for: .touchUpInside) button.translatesAutoresizingMaskIntoConstraints = false view.addSubview(button) + NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: view.centerXAnchor), - button.centerYAnchor.constraint(equalTo: view.centerYAnchor) + button.topAnchor.constraint(equalTo: planetPeriodLabel.bottomAnchor, constant: 20) ]) } - @objc func showAlert() { - let alert = UIAlertController(title: "Info", message: "This is an alert", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in - print("OK tapped") - }) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in - print("Cancel tapped") - }) + // MARK: - Network (Task 1) + + private func fetchFilm() { + let urlString = "https://swapi.dev/api/films/1" + guard let url = URL(string: urlString) else { return } + + URLSession.shared.dataTask(with: url) { data, _, error in + if let error = error { + print("Film error:", error.localizedDescription) + return + } + + guard let data = data else { return } + + do { + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let title = json?["title"] as? String + + DispatchQueue.main.async { + self.filmTitleLabel.text = title + } + } catch { + print("Film parsing error:", error.localizedDescription) + } + }.resume() + } + + // MARK: - Network (Task 2) + + private func fetchPlanet() { + let urlString = "https://swapi.dev/api/planets/1" // Татуин + guard let url = URL(string: urlString) else { return } + + URLSession.shared.dataTask(with: url) { data, _, error in + if let error = error { + print("Planet error:", error.localizedDescription) + return + } + + guard let data = data else { return } + + do { + let planet = try JSONDecoder().decode(Planet.self, from: data) + + DispatchQueue.main.async { + self.planetPeriodLabel.text = L10n.format("info.tatooine_period", planet.orbitalPeriod) + } + } catch { + print("Planet decoding error:", error.localizedDescription) + } + }.resume() + } + + // MARK: - Alert + + @objc private func showAlert() { + let alert = UIAlertController( + title: L10n.tr("info.alert.title"), + message: L10n.tr("info.alert.message"), + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: L10n.tr("common.ok"), style: .default)) + alert.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + present(alert, animated: true) } } diff --git a/Navigation/Localization/L10n.swift b/Navigation/Localization/L10n.swift new file mode 100644 index 0000000..f67cb2c --- /dev/null +++ b/Navigation/Localization/L10n.swift @@ -0,0 +1,11 @@ +import Foundation + +enum L10n { + static func tr(_ key: String) -> String { + NSLocalizedString(key, comment: "") + } + + static func format(_ key: String, _ args: CVarArg...) -> String { + String(format: tr(key), locale: .current, arguments: args) + } +} diff --git a/Navigation/LogInViewController.swift b/Navigation/LogInViewController.swift new file mode 100644 index 0000000..57e1d3a --- /dev/null +++ b/Navigation/LogInViewController.swift @@ -0,0 +1,228 @@ +// +// LogInViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 04.08.2025. +// + +import UIKit + +final class LogInViewController: UIViewController { + + // MARK: - Delegate + var loginDelegate: LoginViewControllerDelegate? + + // MARK: - UI + private let scrollView = UIScrollView() + private let contentView = UIView() + private let viewModel = LoginViewModel() + + private let logoImageView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "VKLogo")) + iv.contentMode = .scaleAspectFit + return iv + }() + + private let loginField: UITextField = { + let tf = UITextField() + tf.placeholder = L10n.tr("login.email") + tf.backgroundColor = StyleGuide.Colors.backgroundSecondary + tf.layer.cornerRadius = 10 + tf.layer.borderWidth = 0.5 + tf.layer.borderColor = StyleGuide.Colors.borderStrong.cgColor + tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 40)) + tf.leftViewMode = .always + return tf + }() + + private let passwordField: UITextField = { + let tf = UITextField() + tf.placeholder = L10n.tr("login.password") + tf.isSecureTextEntry = true + tf.backgroundColor = StyleGuide.Colors.backgroundSecondary + tf.layer.cornerRadius = 10 + tf.layer.borderWidth = 0.5 + tf.layer.borderColor = StyleGuide.Colors.borderStrong.cgColor + tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 40)) + tf.leftViewMode = .always + return tf + }() + + private lazy var loginButton = CustomButton( + title: L10n.tr("login.submit"), + backgroundColor: StyleGuide.Colors.accent + ) { [weak self] in + self?.tryLogin() + } + + private lazy var signUpButton = CustomButton( + title: L10n.tr("login.register"), + titleColor: StyleGuide.Colors.accent, + backgroundColor: StyleGuide.Colors.backgroundSecondary + ) { [weak self] in + self?.trySignUp() + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupUI() + loginButton.addTarget(self, action: #selector(loginButtonPressed), for: .touchUpInside) + signUpButton.addTarget(self, action: #selector(signUpButtonPressed), for: .touchUpInside) + setupKeyboardObservers() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + loginField.becomeFirstResponder() + } + + // MARK: - Login Logic (ВАЖНО) + private func tryLogin() { + print("🔥 tryLogin called") + + viewModel.submit(email: loginField.text, password: passwordField.text) + + switch viewModel.state { + case .idle: + return + case .errorEmpty: + showAlert(L10n.tr("common.error"), L10n.tr("login.error.empty_credentials")) + case .ready(let email, let password): + guard loginDelegate != nil else { + showAlert(L10n.tr("common.error"), L10n.tr("login.error.internal")) + return + } + loginDelegate?.checkCredentials(email: email, password: password) + } + } + + @objc private func loginButtonPressed() { + tryLogin() + } + + private func trySignUp() { + viewModel.submit(email: loginField.text, password: passwordField.text) + + switch viewModel.state { + case .idle: + return + case .errorEmpty: + showAlert(L10n.tr("common.error"), L10n.tr("login.error.empty_credentials")) + case .ready(let email, let password): + guard let loginDelegate else { + showAlert(L10n.tr("common.error"), L10n.tr("login.error.internal")) + return + } + + loginDelegate.signUp(email: email, password: password) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success: + break + case .failure: + self?.showAlert(L10n.tr("common.error"), L10n.tr("login.error.signup_failed")) + } + } + } + } + } + + @objc private func signUpButtonPressed() { + trySignUp() + } + + // MARK: - UI Setup + private func setupUI() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + + [logoImageView, loginField, passwordField, loginButton, signUpButton] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + [logoImageView, loginField, passwordField, loginButton, signUpButton] + .forEach { contentView.addSubview($0) } + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + + logoImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 140), + logoImageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + logoImageView.widthAnchor.constraint(equalToConstant: 100), + logoImageView.heightAnchor.constraint(equalToConstant: 100), + + loginField.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 100), + loginField.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + loginField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + loginField.heightAnchor.constraint(equalToConstant: 50), + + passwordField.topAnchor.constraint(equalTo: loginField.bottomAnchor), + passwordField.leadingAnchor.constraint(equalTo: loginField.leadingAnchor), + passwordField.trailingAnchor.constraint(equalTo: loginField.trailingAnchor), + passwordField.heightAnchor.constraint(equalToConstant: 50), + + loginButton.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: 20), + loginButton.leadingAnchor.constraint(equalTo: loginField.leadingAnchor), + loginButton.trailingAnchor.constraint(equalTo: loginField.trailingAnchor), + loginButton.heightAnchor.constraint(equalToConstant: 50), + + signUpButton.topAnchor.constraint(equalTo: loginButton.bottomAnchor, constant: 12), + signUpButton.leadingAnchor.constraint(equalTo: loginField.leadingAnchor), + signUpButton.trailingAnchor.constraint(equalTo: loginField.trailingAnchor), + signUpButton.heightAnchor.constraint(equalToConstant: 50), + signUpButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -40) + ]) + } + + // MARK: - Keyboard + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc private func keyboardShow(_ n: Notification) { + if let frame = n.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + scrollView.contentInset.bottom = frame.height + 20 + } + } + + @objc private func keyboardHide() { + scrollView.contentInset = .zero + } + + // MARK: - Alert + private func showAlert(_ title: String, _ message: String) { + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: L10n.tr("common.ok"), style: .default)) + present(alert, animated: true) + } +} diff --git a/Navigation/LoginCoordinator.swift b/Navigation/LoginCoordinator.swift new file mode 100644 index 0000000..54b3082 --- /dev/null +++ b/Navigation/LoginCoordinator.swift @@ -0,0 +1,34 @@ +// +// LoginKoordinator.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/30/25. +// + +import UIKit + +final class LoginCoordinator: Coordinator { + + private let navigationController: UINavigationController + private var inspector: LoginInspector? + + var onFinish: ((User) -> Void)? + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let loginVC = LogInViewController() + + let checkerService = CheckerService() + let inspector = LoginInspector(checkerService: checkerService) + inspector.onLoginSuccess = { [weak self] user in + self?.onFinish?(user) + } + self.inspector = inspector + + loginVC.loginDelegate = inspector + navigationController.pushViewController(loginVC, animated: true) + } +} diff --git a/Navigation/LoginFactory.swift b/Navigation/LoginFactory.swift new file mode 100644 index 0000000..e884703 --- /dev/null +++ b/Navigation/LoginFactory.swift @@ -0,0 +1,12 @@ +// +// LoginFactory.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/19/25. +// + +import Foundation + +protocol LoginFactory { + func makeLoginInspector() -> LoginInspector +} diff --git a/Navigation/LoginInspector.swift b/Navigation/LoginInspector.swift new file mode 100644 index 0000000..90fb6c8 --- /dev/null +++ b/Navigation/LoginInspector.swift @@ -0,0 +1,64 @@ +// +// LoginInspector.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/19/25. +// + +import UIKit +final class LoginInspector: LoginViewControllerDelegate { + + var onLoginSuccess: ((User) -> Void)? + + private let checkerService: CheckerServiceProtocol + + init(checkerService: CheckerServiceProtocol) { + self.checkerService = checkerService + } + + func checkCredentials(email: String, password: String) { + print("checkCredentials:", email) + checkerService.checkCredentials(email: email, password: password) { [weak self] result in + switch result { + case .success: + self?.didLogin(email: email) + case .failure(let error): + print("Login error:", error.localizedDescription) + // Если аккаунта еще нет, пробуем зарегистрировать и сразу выполнить вход. + self?.checkerService.signUp(email: email, password: password) { signUpResult in + switch signUpResult { + case .success: + self?.didLogin(email: email) + case .failure(let signUpError): + print("SignUp after login error:", signUpError.localizedDescription) + } + } + } + } + } + + func signUp(email: String, password: String, completion: @escaping (Result) -> Void) { + checkerService.signUp(email: email, password: password) { result in + switch result { + case .success: + self.didLogin(email: email) + completion(.success(())) + case .failure(let error): + print("SignUp error:", error.localizedDescription) + completion(.failure(error)) + } + } + } + + private func didLogin(email: String) { + let sessionUser = FirebaseSessionStorage.shared.user + let user = User( + login: sessionUser?.email ?? email, + fullName: sessionUser?.displayName ?? "Firebase User", + avatar: UIImage(named: "my_photo") ?? UIImage(), + status: L10n.tr("profile.status.firebase") + ) + + onLoginSuccess?(user) + } +} diff --git a/Navigation/LoginViewControllerDelegate.swift b/Navigation/LoginViewControllerDelegate.swift new file mode 100644 index 0000000..fac9054 --- /dev/null +++ b/Navigation/LoginViewControllerDelegate.swift @@ -0,0 +1,13 @@ +// +// LoginViewControllerDelegate.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/19/25. +// + +import Foundation + +protocol LoginViewControllerDelegate: AnyObject { + func checkCredentials(email: String, password: String) + func signUp(email: String, password: String, completion: @escaping (Result) -> Void) +} diff --git a/Navigation/LoginViewModel.swift b/Navigation/LoginViewModel.swift new file mode 100644 index 0000000..6844d9b --- /dev/null +++ b/Navigation/LoginViewModel.swift @@ -0,0 +1,31 @@ +// +// LoginViewModel.swift +// Navigation +// +// Created by Codex on 19.02.2026. +// + +import Foundation + +final class LoginViewModel { + + enum State: Equatable { + case idle + case errorEmpty + case ready(email: String, password: String) + } + + private(set) var state: State = .idle + + func submit(email: String?, password: String?) { + let trimmedEmail = email?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard !trimmedEmail.isEmpty, !trimmedPassword.isEmpty else { + state = .errorEmpty + return + } + + state = .ready(email: trimmedEmail, password: trimmedPassword) + } +} diff --git a/Navigation/Models/Feed/FeedGenre.swift b/Navigation/Models/Feed/FeedGenre.swift new file mode 100644 index 0000000..a2c5961 --- /dev/null +++ b/Navigation/Models/Feed/FeedGenre.swift @@ -0,0 +1,93 @@ +import Foundation + +enum FeedGenre: String, CaseIterable { + case humor + case animals + case cinema + case travel + + var title: String { + switch self { + case .humor: return L10n.tr("home.genre.humor") + case .animals: return L10n.tr("home.genre.animals") + case .cinema: return L10n.tr("home.genre.cinema") + case .travel: return L10n.tr("home.genre.travel") + } + } + + var catAPICategoryID: Int? { + switch self { + case .humor: return 4 + case .animals: return 5 + case .cinema: return 1 + case .travel: return 7 + } + } + + var authorTitles: [String] { + switch self { + case .humor: + return ["Котокомедия", "Мур-юмор", "Смешной хвост"] + case .animals: + return ["Лапки дня", "Кото-друзья", "Пушистая лента"] + case .cinema: + return ["Кото-кино", "Мур-премьера", "Кинокот"] + case .travel: + return ["Котопутешествия", "Лапы в пути", "Хвостатый турист"] + } + } + + var captionPrefix: String { + switch self { + case .humor: + return "Настроение: кото-юмор." + case .animals: + return "Настроение: спокойные пушистики." + case .cinema: + return "Настроение: кадр как из фильма." + case .travel: + return "Настроение: путешествие с хвостом." + } + } + + var russianCaptions: [String] { + switch self { + case .humor: + return [ + "Когда пришел в зал на 30 минут, а остался на два часа.", + "План на день: быть серьезным. Реальность: мемы и кофе.", + "Настроение: продуктивность после третьего напоминания.", + "Рабочий чат молчит — значит, все уже в дедлайне.", + "Сегодня без драмы: только юмор и хорошие новости." + ] + case .animals: + return [ + "Этот взгляд лучше любого утреннего будильника.", + "Пушистый контролер качества проверил контент.", + "Когда кот решил, что он главный редактор ленты.", + "Уровень мотивации: как у собаки перед прогулкой.", + "Самый позитивный пост дня официально найден." + ] + case .cinema: + return [ + "Кадр дня: как будто сцена из комедии.", + "Если бы этот момент был фильмом, это был бы хит.", + "Сюжетный поворот, который никто не ожидал.", + "Ставим лайк за отличную постановку кадра.", + "Кинонастроение включено: попкорн не обязателен." + ] + case .travel: + return [ + "Путешествие началось с плана и закончилось приключением.", + "Лучшие маршруты — те, где есть место спонтанности.", + "Открытка дня: красиво, легко и с улыбкой.", + "Этот вид точно стоил раннего подъема.", + "Немного дороги, немного юмора и много впечатлений." + ] + } + } +} + +protocol FeedGenreConfigurable: AnyObject { + func setGenre(_ genre: FeedGenre) +} diff --git a/Navigation/Models/Feed/SocialFeedPost.swift b/Navigation/Models/Feed/SocialFeedPost.swift new file mode 100644 index 0000000..f62fe57 --- /dev/null +++ b/Navigation/Models/Feed/SocialFeedPost.swift @@ -0,0 +1,10 @@ +import Foundation + +struct SocialFeedPost: Codable, Equatable { + let id: String + let username: String + let avatarURL: URL? + let photoURL: URL? + let caption: String + let date: Date +} diff --git a/Navigation/MyLoginFactory.swift b/Navigation/MyLoginFactory.swift new file mode 100644 index 0000000..9b7427c --- /dev/null +++ b/Navigation/MyLoginFactory.swift @@ -0,0 +1,21 @@ +// +// MyLoginFactory.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/19/25. +// + +import Foundation + +struct MyLoginFactory: LoginFactory { + + private let checkerService: CheckerServiceProtocol + + init(checkerService: CheckerServiceProtocol) { + self.checkerService = checkerService + } + + func makeLoginInspector() -> LoginInspector { + LoginInspector(checkerService: checkerService) + } +} diff --git a/Navigation/Network/APIError.swift b/Navigation/Network/APIError.swift new file mode 100644 index 0000000..55020c4 --- /dev/null +++ b/Navigation/Network/APIError.swift @@ -0,0 +1,24 @@ +import Foundation + +enum APIError: LocalizedError { + case badURL + case network + case invalidResponse + case notFound + case decoding + + var errorDescription: String? { + switch self { + case .badURL: + return L10n.tr("api.error.bad_url") + case .network: + return L10n.tr("api.error.network") + case .invalidResponse: + return L10n.tr("api.error.invalid_response") + case .notFound: + return L10n.tr("api.error.not_found") + case .decoding: + return L10n.tr("api.error.decoding") + } + } +} diff --git a/Navigation/NetworkService.swift b/Navigation/NetworkService.swift new file mode 100644 index 0000000..e2b6e6c --- /dev/null +++ b/Navigation/NetworkService.swift @@ -0,0 +1,75 @@ +// +// NetworkService.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12/16/25. +// + +import Foundation + +protocol NetworkSessionProtocol { + func dataTask(with url: URL, + completion: @escaping (Data?, URLResponse?, Error?) -> Void) +} + +final class URLSessionNetworkSession: NetworkSessionProtocol { + func dataTask(with url: URL, + completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + let task = URLSession.shared.dataTask(with: url, completionHandler: completion) + task.resume() + } +} + +enum NetworkServiceResult: Equatable { + case success(data: String, statusCode: Int?) + case failure(String) + case empty + case invalidURL +} + +struct NetworkService { + + static func request(for configuration: AppConfiguration, + session: NetworkSessionProtocol = URLSessionNetworkSession(), + completion: ((NetworkServiceResult) -> Void)? = nil) { + + let url: URL + + switch configuration { + case .people(let urlString), + .starship(let urlString), + .planet(let urlString): + + guard let validURL = URL(string: urlString) else { + completion?(.invalidURL) + print("Некорректный URL") + return + } + url = validURL + } + + session.dataTask(with: url) { data, response, error in + + if let error = error { + let result: NetworkServiceResult = .failure(error.localizedDescription) + completion?(result) + print("Error:", error.localizedDescription) + return + } + + let statusCode = (response as? HTTPURLResponse)?.statusCode + + if let data = data, + !data.isEmpty, + let dataString = String(data: data, encoding: .utf8) { + let result: NetworkServiceResult = .success(data: dataString, statusCode: statusCode) + completion?(result) + print("Status code:", statusCode ?? -1) + print("Data:\n", dataString) + } else { + let result: NetworkServiceResult = .empty + completion?(result) + } + } + } +} diff --git a/Navigation/Planet.swift b/Navigation/Planet.swift new file mode 100644 index 0000000..76f44b6 --- /dev/null +++ b/Navigation/Planet.swift @@ -0,0 +1,14 @@ +// +// P;anet.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12/18/25. +// + +struct Planet: Decodable { + let orbitalPeriod: String + + enum CodingKeys: String, CodingKey { + case orbitalPeriod = "orbital_period" + } +} diff --git a/Navigation/Post.swift b/Navigation/Post.swift deleted file mode 100644 index a554879..0000000 --- a/Navigation/Post.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Post.swift -// Navigation -// -// Created by MAXIM GORNOSTAEV on 16.07.2025. -// - -struct Post { - let title: String -} diff --git a/Navigation/PostDetailViewController..swift b/Navigation/PostDetailViewController..swift new file mode 100644 index 0000000..38bd53e --- /dev/null +++ b/Navigation/PostDetailViewController..swift @@ -0,0 +1,68 @@ +// +// PostDetailViewController..swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/29/25. +// + +import UIKit +import StorageService + +final class PostDetailViewController: UIViewController { + + var post: Post? + + private let imageView = UIImageView() + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + title = L10n.tr("post.title") + + setupUI() + updateUI() + } + + private func setupUI() { + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + + titleLabel.font = StyleGuide.Fonts.title(20) + titleLabel.textColor = StyleGuide.Colors.textPrimary + + descriptionLabel.font = StyleGuide.Fonts.body() + descriptionLabel.textColor = StyleGuide.Colors.textSecondary + descriptionLabel.numberOfLines = 0 + + [imageView, titleLabel, descriptionLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageView.heightAnchor.constraint(equalToConstant: 250), + + titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12), + descriptionLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + descriptionLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor) + ]) + } + + private func updateUI() { + guard let post else { return } + + titleLabel.text = post.author + descriptionLabel.text = post.description + imageView.image = UIImage(named: post.image) + } +} diff --git a/Navigation/PostViewController.swift b/Navigation/PostViewController.swift index 1d21f60..81efb96 100644 --- a/Navigation/PostViewController.swift +++ b/Navigation/PostViewController.swift @@ -7,20 +7,83 @@ import UIKit -class PostViewController: UIViewController { - var post: Post? +final class PostViewController: UIViewController { + + + let post: Post + private let favorites = FavoritesRepository.shared + private let contentStack = UIStackView() + private let descriptionLabel = UILabel() + private let likesLabel = UILabel() + private let viewsLabel = UILabel() + + //Инициализатор + init(post: Post) { + self.post = post + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemOrange - title = post?.title + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + title = post.author + setupUI() + fillContent() + updateFavoriteState() + } + + private func setupUI() { + contentStack.axis = .vertical + contentStack.spacing = 12 + contentStack.translatesAutoresizingMaskIntoConstraints = false + + descriptionLabel.numberOfLines = 0 + descriptionLabel.font = StyleGuide.Fonts.body(18) + descriptionLabel.textColor = StyleGuide.Colors.textPrimary + + likesLabel.font = StyleGuide.Fonts.body(16, weight: .semibold) + likesLabel.textColor = StyleGuide.Colors.danger + + viewsLabel.font = StyleGuide.Fonts.body() + viewsLabel.textColor = StyleGuide.Colors.textSecondary + + contentStack.addArrangedSubview(descriptionLabel) + contentStack.addArrangedSubview(likesLabel) + contentStack.addArrangedSubview(viewsLabel) + view.addSubview(contentStack) + + NSLayoutConstraint.activate([ + contentStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24), + contentStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + contentStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + ]) + } + + private func fillContent() { + descriptionLabel.text = post.description + likesLabel.text = L10n.format("post.likes", post.likes) + viewsLabel.text = L10n.format("post.views", post.views) + } - navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Info", style: .plain, target: self, action: #selector(showInfo)) + private func updateFavoriteState() { + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage( + systemName: favorites.isFavorite(id: post.id) + ? "heart.fill" + : "heart" + ), + style: .plain, + target: self, + action: #selector(toggleFavorite) + ) } - @objc func showInfo() { - let infoVC = InfoViewController() - infoVC.modalPresentationStyle = .formSheet - present(infoVC, animated: true) + @objc private func toggleFavorite() { + _ = favorites.toggle(post: post) + updateFavoriteState() } } diff --git a/Navigation/Posts/PostCell.swift b/Navigation/Posts/PostCell.swift new file mode 100644 index 0000000..765b4f1 --- /dev/null +++ b/Navigation/Posts/PostCell.swift @@ -0,0 +1,98 @@ +// +// PostCell.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/22/26. +// + +import UIKit + +final class PostCell: UITableViewCell { + + private let titleLabel = UILabel() + private let metaLabel = UILabel() + private let likeImageView = UIImageView() + + var onLikeTap: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + setupGesture() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(post: Post, isFavorite: Bool) { + titleLabel.text = post.description + let likesText = L10n.format("post.likes", post.likes) + let viewsText = L10n.format("post.views", post.views) + metaLabel.text = "\(likesText) \(viewsText)" + likeImageView.image = UIImage( + systemName: isFavorite ? "heart.fill" : "heart" + ) + likeImageView.tintColor = isFavorite ? StyleGuide.Colors.danger : StyleGuide.Colors.muted + } + + private func setupUI() { + selectionStyle = .none + + titleLabel.numberOfLines = 0 + titleLabel.font = StyleGuide.Fonts.body() + + metaLabel.numberOfLines = 1 + metaLabel.font = StyleGuide.Fonts.caption() + metaLabel.textColor = StyleGuide.Colors.textSecondary + + likeImageView.contentMode = .scaleAspectFit + likeImageView.isUserInteractionEnabled = false + + contentView.addSubview(titleLabel) + contentView.addSubview(metaLabel) + contentView.addSubview(likeImageView) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + metaLabel.translatesAutoresizingMaskIntoConstraints = false + likeImageView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + // ЛАЙК — ФИКСИРОВАННЫЙ РАЗМЕР + likeImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + likeImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + likeImageView.widthAnchor.constraint(equalToConstant: 24), + likeImageView.heightAnchor.constraint(equalToConstant: 24), + + // ТЕКСТ + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: likeImageView.leadingAnchor, constant: -12), + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + metaLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + metaLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + metaLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + metaLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12) + ]) + } + + private func setupGesture() { + let tap = UITapGestureRecognizer(target: self, action: #selector(likeTapped)) + tap.numberOfTapsRequired = 2 + contentView.addGestureRecognizer(tap) + } + + @objc private func likeTapped() { + animateLike() + onLikeTap?() + } + + private func animateLike() { + UIView.animate(withDuration: 0.15, animations: { + self.likeImageView.transform = CGAffineTransform(scaleX: 1.4, y: 1.4) + }) { _ in + UIView.animate(withDuration: 0.15) { + self.likeImageView.transform = .identity + } + } + } +} diff --git a/Navigation/Posts/PostProvider.swift b/Navigation/Posts/PostProvider.swift new file mode 100644 index 0000000..22c8259 --- /dev/null +++ b/Navigation/Posts/PostProvider.swift @@ -0,0 +1,37 @@ +// +// PostProvider.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/22/26. +// + +import Foundation + +enum PostProvider { + + static func makePosts() -> [Post] { + [ + Post( + author: L10n.tr("post.author.media"), + description: L10n.tr("post.sample.first"), + image: "my_photo", + likes: 10, + views: 120 + ), + Post( + author: L10n.tr("post.author.friends"), + description: L10n.tr("post.sample.second"), + image: "hulk", + likes: 25, + views: 300 + ), + Post( + author: L10n.tr("post.author.design"), + description: L10n.tr("post.sample.third"), + image: "pp", + likes: 42, + views: 512 + ) + ] + } +} diff --git a/Navigation/Posts/PostsViewController.swift b/Navigation/Posts/PostsViewController.swift new file mode 100644 index 0000000..5098a01 --- /dev/null +++ b/Navigation/Posts/PostsViewController.swift @@ -0,0 +1,84 @@ +// +// PostsViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/22/26. +// + +import UIKit +import StorageService + +final class PostsViewController: UIViewController { + + + private let tableView = UITableView() + private let posts = PostProvider.makePosts() + private let favorites = FavoritesRepository.shared + + + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("menu.posts") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupTableView() + } + + private func setupTableView() { + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + tableView.register(PostCell.self, forCellReuseIdentifier: "PostCell") + tableView.dataSource = self + tableView.delegate = self + } +} + +// MARK: - UITableViewDataSource + +extension PostsViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + posts.count + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + let cell = tableView.dequeueReusableCell( + withIdentifier: "PostCell", + for: indexPath + ) as! PostCell + + + let post = posts[indexPath.row] + cell.configure(post: post, isFavorite: favorites.isFavorite(id: post.id)) + + cell.onLikeTap = { [weak self] in + guard let self else { return } + _ = self.favorites.toggle(post: post) + tableView.reloadRows(at: [indexPath], with: .automatic) + } + + return cell + } +} + +// MARK: - UITableViewDelegate + +extension PostsViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let post = posts[indexPath.row] + let vc = PostViewController(post: post) + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/Navigation/Profile/CurrentUserService.swift b/Navigation/Profile/CurrentUserService.swift new file mode 100644 index 0000000..2f84abc --- /dev/null +++ b/Navigation/Profile/CurrentUserService.swift @@ -0,0 +1,98 @@ +// +// CurrentUserService.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/11/25. +// + +import Foundation +import StorageService +import UIKit + +/// Provides current logged-in user profile data and persists selected avatar locally. +final class CurrentUserService: UserService { + private enum Keys { + static let avatarFileName = "currentUser.avatarFileName" + static let fullName = "currentUser.fullName" + static let status = "currentUser.status" + } + + private let defaultLoginValue = "Wowgorno" + private let defaultFullNameValue = "Maxim Gornostayev" + private let defaultStatusValue = "iOS Developer" + + func getUser(login: String) -> User? { + let sessionUser = FirebaseSessionStorage.shared.user + let targetLogin = sessionUser?.email ?? defaultLoginValue + guard login == targetLogin || login == defaultLoginValue else { return nil } + + // Avatar priority: user-selected image from Documents -> bundled default asset. + let avatar: UIImage + if let fileName = UserDefaults.standard.string(forKey: Keys.avatarFileName), + let storedAvatar = loadAvatar(named: fileName) { + avatar = storedAvatar + } else { + avatar = UIImage(named: "my_photo") ?? UIImage() + } + + let fullName: String = { + if let cached = UserDefaults.standard.string(forKey: Keys.fullName), !cached.isEmpty { + return cached + } + if let sessionName = sessionUser?.displayName, !sessionName.isEmpty { + return sessionName + } + return defaultFullNameValue + }() + + let status: String = { + if let cached = UserDefaults.standard.string(forKey: Keys.status), !cached.isEmpty { + return cached + } + if sessionUser != nil { + return L10n.tr("profile.status.firebase") + } + return defaultStatusValue + }() + + return User( + login: targetLogin, + fullName: fullName, + avatar: avatar, + status: status + ) + } + + func updateAvatar(_ avatar: UIImage) { + guard let fileName = saveAvatar(avatar) else { return } + UserDefaults.standard.set(fileName, forKey: Keys.avatarFileName) + } + + func updateProfile(fullName: String, status: String) { + UserDefaults.standard.set(fullName, forKey: Keys.fullName) + UserDefaults.standard.set(status, forKey: Keys.status) + } + + private func documentsDirectory() -> URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + } + + private func saveAvatar(_ avatar: UIImage) -> String? { + guard let directory = documentsDirectory(), + let data = avatar.jpegData(compressionQuality: 0.9) else { return nil } + let fileName = "current_avatar.jpg" + let fileURL = directory.appendingPathComponent(fileName) + do { + try data.write(to: fileURL, options: .atomic) + return fileName + } catch { + return nil + } + } + + private func loadAvatar(named fileName: String) -> UIImage? { + guard let directory = documentsDirectory() else { return nil } + let fileURL = directory.appendingPathComponent(fileName) + return UIImage(contentsOfFile: fileURL.path) + } +} diff --git a/Navigation/Profile/CustomButton.swift b/Navigation/Profile/CustomButton.swift new file mode 100644 index 0000000..9ef6092 --- /dev/null +++ b/Navigation/Profile/CustomButton.swift @@ -0,0 +1,41 @@ +// +// CustomButton.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/23/25. +// + +import UIKit + +final class CustomButton: UIButton { + + private var action: (() -> Void)? + + init(title: String, + titleColor: UIColor = StyleGuide.Colors.inverseText, + backgroundColor: UIColor = StyleGuide.Colors.accent, + cornerRadius: CGFloat = 12, + action: (() -> Void)? = nil) { + + super.init(frame: .zero) + + self.action = action + + setTitle(title, for: .normal) + setTitleColor(titleColor, for: .normal) + titleLabel?.font = StyleGuide.Fonts.body(15, weight: .semibold) + self.backgroundColor = backgroundColor + layer.cornerRadius = cornerRadius + + addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + translatesAutoresizingMaskIntoConstraints = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func buttonTapped() { + action?() + } +} diff --git a/Navigation/Profile/FeedCoordinator.swift b/Navigation/Profile/FeedCoordinator.swift new file mode 100644 index 0000000..94d7772 --- /dev/null +++ b/Navigation/Profile/FeedCoordinator.swift @@ -0,0 +1,22 @@ +// +// FeedCoordinator.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/29/25. +// + +import UIKit + +final class FeedCoordinator: Coordinator { + + let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let vc = FeedViewController() + navigationController.setViewControllers([vc], animated: false) + } +} diff --git a/Navigation/Profile/Music/MyMusicViewController.swift b/Navigation/Profile/Music/MyMusicViewController.swift new file mode 100644 index 0000000..d52558f --- /dev/null +++ b/Navigation/Profile/Music/MyMusicViewController.swift @@ -0,0 +1,241 @@ +import UIKit +import AVFoundation + +final class MyMusicViewController: UIViewController { + private let viewModel: MyMusicViewModel + private let tableView = UITableView(frame: .zero, style: .plain) + private let stateView = ScreenStateView() + + private let controlsContainer = UIView() + private let titleLabel = UILabel() + private let artistLabel = UILabel() + private let previousButton = UIButton(type: .system) + private let rewindButton = UIButton(type: .system) + private let playPauseButton = UIButton(type: .system) + private let stopButton = UIButton(type: .system) + private let forwardButton = UIButton(type: .system) + private let nextButton = UIButton(type: .system) + + private var player: AVPlayer? + private var currentIndex: Int = 0 + private var isPlaying = false + + init(viewModel: MyMusicViewModel = MyMusicViewModel()) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("music.title") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + + setupTableView() + setupControls() + setupStateView() + bindViewModel() + viewModel.load() + } + + private func setupTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = 58 + tableView.backgroundColor = StyleGuide.Colors.backgroundPrimary + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "trackCell") + + view.addSubview(tableView) + } + + private func setupControls() { + controlsContainer.translatesAutoresizingMaskIntoConstraints = false + controlsContainer.backgroundColor = StyleGuide.Colors.backgroundSecondary + controlsContainer.layer.cornerRadius = 14 + + titleLabel.font = StyleGuide.Fonts.body(14, weight: .semibold) + titleLabel.textColor = StyleGuide.Colors.textPrimary + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + artistLabel.font = StyleGuide.Fonts.caption(12, weight: .regular) + artistLabel.textColor = StyleGuide.Colors.textSecondary + artistLabel.translatesAutoresizingMaskIntoConstraints = false + + configureButton(previousButton, title: "⏮", action: #selector(previousTapped)) + configureButton(rewindButton, title: "-10", action: #selector(rewindTapped)) + configureButton(playPauseButton, title: L10n.tr("music.control.play"), action: #selector(playPauseTapped)) + configureButton(stopButton, title: L10n.tr("music.control.stop"), action: #selector(stopTapped)) + configureButton(forwardButton, title: "+10", action: #selector(forwardTapped)) + configureButton(nextButton, title: "⏭", action: #selector(nextTapped)) + + let controlsStack = UIStackView(arrangedSubviews: [previousButton, rewindButton, playPauseButton, stopButton, forwardButton, nextButton]) + controlsStack.axis = .horizontal + controlsStack.spacing = 6 + controlsStack.distribution = .fillEqually + controlsStack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(controlsContainer) + controlsContainer.addSubview(titleLabel) + controlsContainer.addSubview(artistLabel) + controlsContainer.addSubview(controlsStack) + + NSLayoutConstraint.activate([ + controlsContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12), + controlsContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), + controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8), + controlsContainer.heightAnchor.constraint(equalToConstant: 124), + + titleLabel.topAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: 10), + titleLabel.leadingAnchor.constraint(equalTo: controlsContainer.leadingAnchor, constant: 12), + titleLabel.trailingAnchor.constraint(equalTo: controlsContainer.trailingAnchor, constant: -12), + + artistLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), + artistLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + artistLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + + controlsStack.topAnchor.constraint(equalTo: artistLabel.bottomAnchor, constant: 10), + controlsStack.leadingAnchor.constraint(equalTo: controlsContainer.leadingAnchor, constant: 8), + controlsStack.trailingAnchor.constraint(equalTo: controlsContainer.trailingAnchor, constant: -8), + controlsStack.bottomAnchor.constraint(equalTo: controlsContainer.bottomAnchor, constant: -8), + + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: -8) + ]) + } + + private func setupStateView() { + stateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stateView) + NSLayoutConstraint.activate([ + stateView.topAnchor.constraint(equalTo: tableView.topAnchor), + stateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stateView.bottomAnchor.constraint(equalTo: tableView.bottomAnchor) + ]) + + stateView.onRetry = { [weak self] in + self?.viewModel.load() + } + } + + private func bindViewModel() { + viewModel.onDataChanged = { [weak self] in + self?.tableView.reloadData() + guard let self else { return } + if !self.viewModel.tracks.isEmpty { + self.prepareTrack(at: self.currentIndex) + } + } + + viewModel.onStateChange = { [weak self] state in + self?.stateView.apply(state) + } + } + + private func configureButton(_ button: UIButton, title: String, action: Selector) { + button.setTitle(title, for: .normal) + button.titleLabel?.font = StyleGuide.Fonts.caption(12, weight: .semibold) + button.tintColor = StyleGuide.Colors.accent + button.backgroundColor = StyleGuide.Colors.card + button.layer.cornerRadius = 8 + button.addTarget(self, action: action, for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + } + + private func prepareTrack(at index: Int) { + guard viewModel.tracks.indices.contains(index) else { return } + currentIndex = index + let track = viewModel.tracks[index] + player = AVPlayer(url: track.previewURL) + isPlaying = false + titleLabel.text = track.title + artistLabel.text = track.artist + playPauseButton.setTitle(L10n.tr("music.control.play"), for: .normal) + } + + @objc private func previousTapped() { + guard !viewModel.tracks.isEmpty else { return } + let nextIndex = (currentIndex - 1 + viewModel.tracks.count) % viewModel.tracks.count + prepareTrack(at: nextIndex) + startPlayback() + isPlaying = true + } + + @objc private func nextTapped() { + guard !viewModel.tracks.isEmpty else { return } + let nextIndex = (currentIndex + 1) % viewModel.tracks.count + prepareTrack(at: nextIndex) + startPlayback() + isPlaying = true + } + + @objc private func rewindTapped() { + guard let player else { return } + let current = player.currentTime().seconds + let target = max(current - 10, 0) + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) + } + + @objc private func forwardTapped() { + guard let player else { return } + let current = player.currentTime().seconds + let duration = player.currentItem?.duration.seconds ?? current + 10 + let target = min(current + 10, duration) + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) + } + + @objc private func stopTapped() { + player?.pause() + player?.seek(to: .zero) + isPlaying = false + playPauseButton.setTitle(L10n.tr("music.control.play"), for: .normal) + } + + @objc private func playPauseTapped() { + if isPlaying { + player?.pause() + playPauseButton.setTitle(L10n.tr("music.control.play"), for: .normal) + } else { + startPlayback() + } + isPlaying.toggle() + } + + private func startPlayback() { + player?.play() + playPauseButton.setTitle(L10n.tr("music.control.pause"), for: .normal) + } +} + +extension MyMusicViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.tracks.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "trackCell", for: indexPath) + let track = viewModel.tracks[indexPath.row] + var config = cell.defaultContentConfiguration() + config.text = track.title + config.secondaryText = track.artist + config.image = UIImage(systemName: "music.note") + cell.contentConfiguration = config + cell.accessoryType = .disclosureIndicator + return cell + } +} + +extension MyMusicViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + prepareTrack(at: indexPath.row) + startPlayback() + isPlaying = true + } +} diff --git a/Navigation/Profile/Music/MyMusicViewModel.swift b/Navigation/Profile/Music/MyMusicViewModel.swift new file mode 100644 index 0000000..d527191 --- /dev/null +++ b/Navigation/Profile/Music/MyMusicViewModel.swift @@ -0,0 +1,34 @@ +import Foundation + +final class MyMusicViewModel { + private let service: MusicCatalogServiceProtocol + private(set) var tracks: [MusicTrack] = [] + + var onStateChange: ((ScreenState) -> Void)? + var onDataChanged: (() -> Void)? + + init(service: MusicCatalogServiceProtocol = MusicCatalogService()) { + self.service = service + } + + func load() { + Task { + await MainActor.run { + self.onStateChange?(.loading(L10n.tr("music.state.loading"))) + } + + do { + let loaded = try await service.fetchTracks(query: "top hits 2026", limit: 50) + await MainActor.run { + self.tracks = loaded + self.onDataChanged?() + self.onStateChange?(loaded.isEmpty ? .empty(L10n.tr("music.state.empty")) : .content) + } + } catch { + await MainActor.run { + self.onStateChange?(.error(L10n.tr("music.state.error"))) + } + } + } + } +} diff --git a/Navigation/Profile/PhotosCollectionViewCell.swift b/Navigation/Profile/PhotosCollectionViewCell.swift new file mode 100644 index 0000000..6185b9e --- /dev/null +++ b/Navigation/Profile/PhotosCollectionViewCell.swift @@ -0,0 +1,42 @@ +// +// PhotosCollectionViewCell.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 18.08.2025. +// + +import UIKit + +final class PhotosCollectionViewCell: UICollectionViewCell { + static let reuseIdentifier = "PhotosCell" + + private let imageView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.layer.cornerRadius = 6 + return iv + }() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: contentView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(with imageName: String) { + imageView.image = UIImage(named: imageName) + } + + func configure(with image: UIImage) { + imageView.image = image + } +} diff --git a/Navigation/Profile/PhotosTableViewCell.swift b/Navigation/Profile/PhotosTableViewCell.swift new file mode 100644 index 0000000..2f9bd64 --- /dev/null +++ b/Navigation/Profile/PhotosTableViewCell.swift @@ -0,0 +1,108 @@ +// +// PhotosTableViewCell.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 18.08.2025. +// + +import UIKit + +final class PhotosTableViewCell: UITableViewCell { + + static let identifier = "PhotosTableViewCell" + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = L10n.tr("photos.title") + label.font = StyleGuide.Fonts.title(20) + label.textColor = StyleGuide.Colors.textPrimary + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let arrowImageView: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "chevron.right")) + imageView.tintColor = StyleGuide.Colors.textSecondary + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let scrollView: UIScrollView = { + let view = UIScrollView() + view.showsHorizontalScrollIndicator = false + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let stackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.spacing = 8 + view.alignment = .fill + view.distribution = .fill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = StyleGuide.Colors.backgroundPrimary + contentView.backgroundColor = StyleGuide.Colors.backgroundPrimary + contentView.addSubview(titleLabel) + contentView.addSubview(arrowImageView) + contentView.addSubview(scrollView) + scrollView.addSubview(stackView) + + NSLayoutConstraint.activate([ + // Заголовок + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + + + arrowImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + arrowImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12), + scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12), + scrollView.heightAnchor.constraint(equalToConstant: 90), + + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + } + + func configure(with photos: [String]) { + stackView.arrangedSubviews.forEach { view in + stackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + for imageName in photos { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 8 + imageView.image = UIImage(named: imageName) + imageView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 90), + imageView.heightAnchor.constraint(equalToConstant: 90) + ]) + + stackView.addArrangedSubview(imageView) + } + } +} diff --git a/Navigation/Profile/PhotosViewController.swift b/Navigation/Profile/PhotosViewController.swift new file mode 100644 index 0000000..53e9f45 --- /dev/null +++ b/Navigation/Profile/PhotosViewController.swift @@ -0,0 +1,310 @@ +// +// PhotosViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 18.08.2025. +// +import UIKit +import iOSIntPackage + +final class PhotosViewController: UIViewController { + + // MARK: - Input from coordinator + var photos: [String] = [] { + didSet { + guard isViewLoaded else { return } + startProcessing() + } + } + + // MARK: - Image storage + private var sourceImages: [UIImage] = [] + private var processedImages: [UIImage] = [] + + private let qosLevels: [QualityOfService] = [ + .userInteractive, + .userInitiated, + .utility, + .background + ] + private var currentQoSIndex = 0 + + // MARK: - UI + private let collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.minimumLineSpacing = 8 + layout.minimumInteritemSpacing = 8 + layout.scrollDirection = .vertical + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = StyleGuide.Colors.backgroundPrimary + return cv + }() + + private let statusLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.numberOfLines = 0 + label.font = StyleGuide.Fonts.caption(14, weight: .regular) + label.textColor = StyleGuide.Colors.textSecondary + return label + }() + + // ДОБАВЛЕНО: TextView для логов + private let logTextView: UITextView = { + let tv = UITextView() + tv.isEditable = false + tv.isScrollEnabled = true + tv.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + tv.backgroundColor = StyleGuide.Colors.backgroundSecondary + tv.textColor = StyleGuide.Colors.textSecondary + tv.layer.cornerRadius = 8 + tv.layer.borderWidth = 1 + tv.layer.borderColor = StyleGuide.Colors.borderStrong.cgColor + tv.isHidden = true + return tv + }() + + private let showLogsButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle(L10n.tr("photos.logs.show"), for: .normal) + button.backgroundColor = StyleGuide.Colors.accent + button.setTitleColor(StyleGuide.Colors.inverseText, for: .normal) + button.layer.cornerRadius = 8 + button.titleLabel?.font = StyleGuide.Fonts.caption(13, weight: .semibold) + return button + }() + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + title = L10n.tr("photos.title") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupUI() + + if !photos.isEmpty { + startProcessing() + } + } + + private func addLog(_ message: String) { + print("📋 LOG: \(message)") + + NSLog("📋 %@", message) + + DispatchQueue.main.async { + let timestamp = DateFormatter.localizedString(from: Date(), + dateStyle: .none, + timeStyle: .medium) + let logEntry = "[\(timestamp)] \(message)\n" + + self.logTextView.text = logEntry + (self.logTextView.text ?? "") + + self.logTextView.scrollRangeToVisible(NSRange(location: 0, length: 0)) + } + } + + // MARK: - File: processing launcher + private func startProcessing() { + addLog("Photos count = \(photos.count)") + + sourceImages = photos.compactMap { + if let img = UIImage(named: $0) { + return img + } else { + addLog("IMAGE NOT FOUND: \($0)") + return nil + } + } + + addLog("Loaded source images: \(sourceImages.count)") + + guard !sourceImages.isEmpty else { + addLog(L10n.tr("photos.error.no_images")) + return + } + + startNextQoSTest() + } + + // MARK: - Sequential QoS testing + private func startNextQoSTest() { + guard currentQoSIndex < qosLevels.count else { + addLog(L10n.tr("photos.qos.done_log")) + updateStatus(L10n.tr("photos.qos.done")) + return + } + + let qos = qosLevels[currentQoSIndex] + let qosName = qosDescription(qos) + + addLog(L10n.format("photos.qos.start_log", qosName)) + updateStatus(L10n.format("photos.qos.testing_status", qosName)) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.processImagesWithQoS(qos) + } + } + + private func qosDescription(_ qos: QualityOfService) -> String { + switch qos { + case .userInteractive: return "User Interactive" + case .userInitiated: return "User Initiated" + case .utility: return "Utility" + case .background: return "Background" + default: return "Default" + } + } + + // MARK: - Actual processing + private func processImagesWithQoS(_ qos: QualityOfService) { + let processor = ImageProcessor() + let start = CFAbsoluteTimeGetCurrent() + let qosName = qosDescription(qos) + + addLog(L10n.format("photos.qos.process_start_log", qosName)) + addLog(L10n.tr("photos.filter.chrome")) + addLog(L10n.format("photos.images_count", sourceImages.count)) + + processor.processImagesOnThread( + sourceImages: sourceImages, + filter: .chrome, + qos: qos + ) { [weak self] processedCGImages in + + guard let self else { return } + + let end = CFAbsoluteTimeGetCurrent() + let duration = end - start + + let uiImages = processedCGImages.compactMap { cg -> UIImage? in + guard let cg else { return nil } + return UIImage(cgImage: cg) + } + + DispatchQueue.main.async { + self.processedImages = uiImages + self.collectionView.reloadData() + + let imageCount = uiImages.count + + self.addLog(L10n.format("photos.qos.finish_log", qosName)) + self.addLog(L10n.format("photos.duration_seconds", String(format: "%.3f", duration))) + self.addLog(L10n.format("photos.images_count", imageCount)) + self.addLog(L10n.format("photos.success_ratio", imageCount, self.sourceImages.count)) + + // Обновляем статус + self.updateStatus( + L10n.format("photos.status.multiline", qosName, String(format: "%.3f", duration), imageCount) + ) + + self.currentQoSIndex += 1 + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.startNextQoSTest() + } + } + } + } + + // MARK: - UI Setup + private func setupUI() { + // Статус лейбл + view.addSubview(statusLabel) + statusLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + statusLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + ]) + + view.addSubview(showLogsButton) + showLogsButton.translatesAutoresizingMaskIntoConstraints = false + showLogsButton.addTarget(self, action: #selector(toggleLogs), for: .touchUpInside) + + view.addSubview(logTextView) + logTextView.translatesAutoresizingMaskIntoConstraints = false + + // Коллекция + view.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + // Кнопка логов + showLogsButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 8), + showLogsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + showLogsButton.widthAnchor.constraint(equalToConstant: 120), + showLogsButton.heightAnchor.constraint(equalToConstant: 36), + + // TextView логов + logTextView.topAnchor.constraint(equalTo: showLogsButton.bottomAnchor, constant: 8), + logTextView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + logTextView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + logTextView.heightAnchor.constraint(equalToConstant: 150), + + // Коллекция + collectionView.topAnchor.constraint(equalTo: logTextView.bottomAnchor, constant: 16), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + collectionView.register(PhotosCollectionViewCell.self, + forCellWithReuseIdentifier: "photoCell") + collectionView.dataSource = self + collectionView.delegate = self + + // Начальное сообщение + logTextView.text = L10n.tr("photos.logs.placeholder") + } + + @objc private func toggleLogs() { + UIView.animate(withDuration: 0.3) { + self.logTextView.isHidden = !self.logTextView.isHidden + self.showLogsButton.setTitle( + self.logTextView.isHidden ? L10n.tr("photos.logs.show") : L10n.tr("photos.logs.hide"), + for: .normal + ) + } + } + + private func updateStatus(_ text: String) { + statusLabel.text = text + addLog(L10n.format("photos.status.log", text)) + } +} + +// MARK: - Data Source +extension PhotosViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, + numberOfItemsInSection section: Int) -> Int { + processedImages.count + } + + func collectionView(_ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: "photoCell", + for: indexPath + ) as! PhotosCollectionViewCell + + cell.configure(with: processedImages[indexPath.item]) + return cell + } +} + +// MARK: - Layout +extension PhotosViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath) -> CGSize { + + let spacing: CGFloat = 8 + let itemsPerRow: CGFloat = 3 + let totalSpacing = spacing * (itemsPerRow - 1) + let width = (collectionView.bounds.width - totalSpacing) / itemsPerRow + + return CGSize(width: width, height: width) + } +} diff --git a/Navigation/Profile/Post.swift b/Navigation/Profile/Post.swift new file mode 100644 index 0000000..31f7fd7 --- /dev/null +++ b/Navigation/Profile/Post.swift @@ -0,0 +1,33 @@ +// +// Post.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12.08.2025. +// + +import Foundation + +struct Post: Equatable { + let id: String + let author: String + let description: String + let image: String + let likes: Int + let views: Int + + init( + id: String = UUID().uuidString, + author: String, + description: String, + image: String, + likes: Int, + views: Int + ) { + self.id = id + self.author = author + self.description = description + self.image = image + self.likes = likes + self.views = views + } +} diff --git a/Navigation/Profile/PostTableViewCell.swift b/Navigation/Profile/PostTableViewCell.swift new file mode 100644 index 0000000..9e40e0c --- /dev/null +++ b/Navigation/Profile/PostTableViewCell.swift @@ -0,0 +1,171 @@ +// +// PostTableViewCell.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12.08.2025. +// + +//import StorageService +import UIKit +import iOSIntPackage + + +final class PostTableViewCell: UITableViewCell { + static let identifier = "PostTableViewCell" + var onLikeTap: (() -> Void)? + + private let authorLabel: UILabel = { + let label = UILabel() + label.font = StyleGuide.Fonts.title(18) + label.textColor = StyleGuide.Colors.textPrimary + label.numberOfLines = 1 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let postImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = StyleGuide.Colors.textPrimary + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.font = StyleGuide.Fonts.body(14, weight: .regular) + label.textColor = StyleGuide.Colors.muted + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let likesLabel: UILabel = { + let label = UILabel() + label.font = StyleGuide.Fonts.body(15, weight: .semibold) + label.textColor = StyleGuide.Colors.danger + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let likesIconButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = StyleGuide.Colors.muted + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private let viewsLabel: UILabel = { + let label = UILabel() + label.font = StyleGuide.Fonts.body(15, weight: .semibold) + label.textColor = StyleGuide.Colors.textPrimary + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let viewsIconView: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "eye")) + imageView.tintColor = StyleGuide.Colors.textSecondary + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + // MARK: Init + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + setupActions() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: UI Setup + private func setupUI() { + contentView.addSubview(authorLabel) + contentView.addSubview(postImageView) + contentView.addSubview(descriptionLabel) + contentView.addSubview(likesIconButton) + contentView.addSubview(likesLabel) + contentView.addSubview(viewsIconView) + contentView.addSubview(viewsLabel) + + NSLayoutConstraint.activate([ + // Author label + authorLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + authorLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + authorLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + // Post image + postImageView.topAnchor.constraint(equalTo: authorLabel.bottomAnchor, constant: 12), + postImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + postImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + postImageView.heightAnchor.constraint(equalTo: postImageView.widthAnchor), + + // Description + descriptionLabel.topAnchor.constraint(equalTo: postImageView.bottomAnchor, constant: 16), + descriptionLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + descriptionLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + // Likes label + likesLabel.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 20), + likesLabel.leadingAnchor.constraint(equalTo: likesIconButton.trailingAnchor, constant: 10), + likesLabel.trailingAnchor.constraint(lessThanOrEqualTo: viewsIconView.leadingAnchor, constant: -20), + + // Likes icon + likesIconButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + likesIconButton.centerYAnchor.constraint(equalTo: likesLabel.centerYAnchor), + likesIconButton.widthAnchor.constraint(equalToConstant: 22), + likesIconButton.heightAnchor.constraint(equalToConstant: 22), + + // Views label + viewsLabel.centerYAnchor.constraint(equalTo: likesLabel.centerYAnchor), + viewsLabel.leadingAnchor.constraint(equalTo: viewsIconView.trailingAnchor, constant: 10), + viewsLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16), + viewsLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), + // Views icon + viewsIconView.leadingAnchor.constraint(greaterThanOrEqualTo: likesLabel.trailingAnchor, constant: 20), + viewsIconView.centerYAnchor.constraint(equalTo: viewsLabel.centerYAnchor), + viewsIconView.widthAnchor.constraint(equalToConstant: 18), + viewsIconView.heightAnchor.constraint(equalToConstant: 18) + ]) + } + + // MARK: Configure Cell + func configure(with post: Post, isFavorite: Bool) { + authorLabel.text = post.author + if let image = UIImage(named: post.image) { + ImageProcessor().processImage(sourceImage: image, filter: .noir) { filteredImage in + DispatchQueue.main.async { + self.postImageView.image = filteredImage + } + } + } + descriptionLabel.text = post.description + likesLabel.text = "\(post.likes)" + viewsLabel.text = "\(post.views)" + updateFavoriteUI(isFavorite: isFavorite) + } + + private func setupActions() { + likesIconButton.addTarget(self, action: #selector(likeTapped), for: .touchUpInside) + + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(likeTapped)) + doubleTap.numberOfTapsRequired = 2 + contentView.addGestureRecognizer(doubleTap) + } + + @objc private func likeTapped() { + onLikeTap?() + } + + private func updateFavoriteUI(isFavorite: Bool) { + likesIconButton.setImage( + UIImage(systemName: isFavorite ? "heart.fill" : "heart"), + for: .normal + ) + likesIconButton.tintColor = isFavorite ? StyleGuide.Colors.danger : StyleGuide.Colors.muted + } +} diff --git a/Navigation/Profile/ProfileCoordinator..swift b/Navigation/Profile/ProfileCoordinator..swift new file mode 100644 index 0000000..130cfe7 --- /dev/null +++ b/Navigation/Profile/ProfileCoordinator..swift @@ -0,0 +1,27 @@ +// +// ProfileCoordinator..swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/29/25. +// + +import UIKit + +final class ProfileCoordinator: Coordinator { + + private let navigationController: UINavigationController + private let userService: UserService + + init(navigationController: UINavigationController, + userService: UserService = CurrentUserService()) { + self.navigationController = navigationController + self.userService = userService + } + + func start() { + let user = userService.getUser(login: "Wowgorno") + let viewModel = ProfileViewModel(user: user) + let vc = ProfileViewController(viewModel: viewModel) + navigationController.pushViewController(vc, animated: false) + } +} diff --git a/Navigation/Profile/ProfileHeaderView.swift b/Navigation/Profile/ProfileHeaderView.swift new file mode 100644 index 0000000..b42437a --- /dev/null +++ b/Navigation/Profile/ProfileHeaderView.swift @@ -0,0 +1,321 @@ +// +// ProfileHeaderView.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 22.07.2025. +// + +import UIKit +import SnapKit + +final class ProfileHeaderView: UIView { + var onProfileSettingsTap: (() -> Void)? + var onEditProfileTap: (() -> Void)? + var onAvatarTap: (() -> Void)? + + // MARK: - UI + private let avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.masksToBounds = true + imageView.layer.cornerRadius = 50 + imageView.layer.borderWidth = 2 + imageView.layer.borderColor = StyleGuide.Colors.accent.cgColor + return imageView + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.font = StyleGuide.Fonts.title(18) + label.textColor = StyleGuide.Colors.textPrimary + return label + }() + + private let statusLabel: UILabel = { + let label = UILabel() + label.font = StyleGuide.Fonts.body(14, weight: .regular) + label.textColor = StyleGuide.Colors.muted + return label + }() + + private let statusTextField: UITextField = { + let textField = UITextField() + textField.placeholder = L10n.tr("profile.status.new_placeholder") + textField.font = StyleGuide.Fonts.body(14, weight: .regular) + textField.textColor = StyleGuide.Colors.textPrimary + textField.backgroundColor = StyleGuide.Colors.card + textField.layer.cornerRadius = 12 + textField.layer.borderWidth = 1 + textField.layer.borderColor = StyleGuide.Colors.borderStrong.cgColor + textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 40)) + textField.leftViewMode = .always + return textField + }() + + private lazy var setStatusButton = CustomButton( + title: L10n.tr("profile.status.update"), + backgroundColor: StyleGuide.Colors.accent + ) { [weak self] in + guard let self else { return } + self.applyStatusUpdate() + } + + private let friendsValueLabel = UILabel() + private let friendsTitleLabel = UILabel() + private let followersValueLabel = UILabel() + private let followersTitleLabel = UILabel() + private let countersStackView = UIStackView() + private let profileSettingsButton = UIButton(type: .system) + private let editProfileButton = UIButton(type: .system) + private let avatarEditBadgeView = UIView() + private let avatarEditIconView = UIImageView() + private let avatarBorderLayer = CAShapeLayer() + + // MARK: - Properties + private var statusText: String = "" + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = StyleGuide.Colors.backgroundSecondary + setupViews() + setupConstraints() + setupActions() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + setupConstraints() + setupActions() + } + + // MARK: - Setup + private func setupViews() { + addSubview(avatarImageView) + addSubview(nameLabel) + addSubview(statusLabel) + addSubview(statusTextField) + addSubview(setStatusButton) + addSubview(countersStackView) + addSubview(editProfileButton) + addSubview(profileSettingsButton) + avatarImageView.addSubview(avatarEditBadgeView) + avatarEditBadgeView.addSubview(avatarEditIconView) + + configureCountersUI() + configureActionButtons() + configureAvatarEditUI() + } + + private func setupConstraints() { + avatarImageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.equalToSuperview().offset(16) + make.width.height.equalTo(100) + } + + avatarEditBadgeView.snp.makeConstraints { make in + make.trailing.bottom.equalToSuperview().inset(2) + make.width.height.equalTo(26) + } + + avatarEditIconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(14) + } + + nameLabel.snp.makeConstraints { make in + make.top.equalTo(avatarImageView.snp.top).offset(10) + make.leading.equalTo(avatarImageView.snp.trailing).offset(16) + make.trailing.lessThanOrEqualToSuperview().inset(16) + } + + statusLabel.snp.makeConstraints { make in + make.top.equalTo(nameLabel.snp.bottom).offset(8) + make.leading.equalTo(nameLabel) + make.trailing.lessThanOrEqualToSuperview().inset(16) + } + + countersStackView.snp.makeConstraints { make in + make.top.equalTo(statusLabel.snp.bottom).offset(10) + make.leading.equalTo(nameLabel) + make.trailing.lessThanOrEqualToSuperview().inset(16) + } + + statusTextField.snp.makeConstraints { make in + make.top.equalTo(avatarImageView.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(36) + } + + setStatusButton.snp.makeConstraints { make in + make.top.equalTo(statusTextField.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(44) + } + + editProfileButton.snp.makeConstraints { make in + make.top.equalTo(setStatusButton.snp.bottom).offset(10) + make.leading.equalToSuperview().offset(16) + make.trailing.equalTo(profileSettingsButton.snp.leading).offset(-10) + make.height.equalTo(40) + make.width.equalTo(profileSettingsButton.snp.width) + make.bottom.equalToSuperview().inset(16) + } + + profileSettingsButton.snp.makeConstraints { make in + make.top.equalTo(editProfileButton.snp.top) + make.trailing.equalToSuperview().inset(16) + make.height.equalTo(40) + } + } + + // MARK: - Public configure + func configure(with user: User, friendsCount: Int, followersCount: Int) { + avatarImageView.image = user.avatar.squareCropped() + nameLabel.text = user.fullName + statusLabel.text = user.status + statusText = user.status + friendsValueLabel.text = "\(friendsCount)" + followersValueLabel.text = "\(followersCount)" + } + + // MARK: - Actions + private func setupActions() { + statusTextField.addTarget(self, action: #selector(statusTextChanged(_:)), for: .editingChanged) + setStatusButton.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside) + profileSettingsButton.addTarget(self, action: #selector(profileSettingsPressed), for: .touchUpInside) + editProfileButton.addTarget(self, action: #selector(editProfilePressed), for: .touchUpInside) + let tap = UITapGestureRecognizer(target: self, action: #selector(avatarPressed)) + avatarImageView.isUserInteractionEnabled = true + avatarImageView.addGestureRecognizer(tap) + } + + override func layoutSubviews() { + super.layoutSubviews() + applyAvatarCircleMask() + } + + private func applyAvatarCircleMask() { + let side = min(avatarImageView.bounds.width, avatarImageView.bounds.height) + let x = (avatarImageView.bounds.width - side) / 2 + let y = (avatarImageView.bounds.height - side) / 2 + let circleRect = CGRect(x: x, y: y, width: side, height: side) + let circlePath = UIBezierPath(ovalIn: circleRect).cgPath + + let mask = CAShapeLayer() + mask.path = circlePath + avatarImageView.layer.mask = mask + + avatarBorderLayer.removeFromSuperlayer() + avatarBorderLayer.path = circlePath + avatarBorderLayer.fillColor = UIColor.clear.cgColor + avatarBorderLayer.strokeColor = StyleGuide.Colors.accent.cgColor + avatarBorderLayer.lineWidth = 2 + avatarImageView.layer.addSublayer(avatarBorderLayer) + } + + @objc private func statusTextChanged(_ textField: UITextField) { + statusText = textField.text ?? "" + } + + @objc private func buttonPressed() { + applyStatusUpdate() + } + + @objc private func profileSettingsPressed() { + onProfileSettingsTap?() + } + + @objc private func editProfilePressed() { + onEditProfileTap?() + } + + @objc private func avatarPressed() { + onAvatarTap?() + } + + private func applyStatusUpdate() { + let normalized = statusText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + statusLabel.text = normalized + } + + private func configureCountersUI() { + friendsValueLabel.font = StyleGuide.Fonts.body(16, weight: .semibold) + friendsValueLabel.textColor = StyleGuide.Colors.textPrimary + friendsValueLabel.textAlignment = .left + + friendsTitleLabel.font = StyleGuide.Fonts.caption(12, weight: .regular) + friendsTitleLabel.textColor = StyleGuide.Colors.textSecondary + friendsTitleLabel.text = L10n.tr("profile.friends") + + followersValueLabel.font = StyleGuide.Fonts.body(16, weight: .semibold) + followersValueLabel.textColor = StyleGuide.Colors.textPrimary + followersValueLabel.textAlignment = .left + + followersTitleLabel.font = StyleGuide.Fonts.caption(12, weight: .regular) + followersTitleLabel.textColor = StyleGuide.Colors.textSecondary + followersTitleLabel.text = L10n.tr("profile.followers") + + let leftColumn = UIStackView(arrangedSubviews: [friendsValueLabel, friendsTitleLabel]) + leftColumn.axis = .vertical + leftColumn.spacing = 2 + + let rightColumn = UIStackView(arrangedSubviews: [followersValueLabel, followersTitleLabel]) + rightColumn.axis = .vertical + rightColumn.spacing = 2 + + countersStackView.axis = .horizontal + countersStackView.spacing = 18 + countersStackView.alignment = .center + countersStackView.addArrangedSubview(leftColumn) + countersStackView.addArrangedSubview(rightColumn) + } + + private func configureActionButtons() { + [editProfileButton, profileSettingsButton].forEach { + $0.backgroundColor = StyleGuide.Colors.card + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 1 + $0.layer.borderColor = StyleGuide.Colors.borderStrong.cgColor + $0.titleLabel?.font = StyleGuide.Fonts.caption(13, weight: .semibold) + $0.setTitleColor(StyleGuide.Colors.textPrimary, for: .normal) + $0.tintColor = StyleGuide.Colors.textSecondary + $0.semanticContentAttribute = .forceLeftToRight + } + + editProfileButton.setTitle(L10n.tr("common.edit"), for: .normal) + editProfileButton.setImage(UIImage(systemName: "pencil"), for: .normal) + profileSettingsButton.setTitle(L10n.tr("settings.title"), for: .normal) + profileSettingsButton.setImage(UIImage(systemName: "slider.horizontal.3"), for: .normal) + } + + private func configureAvatarEditUI() { + avatarEditBadgeView.backgroundColor = StyleGuide.Colors.accent + avatarEditBadgeView.layer.cornerRadius = 13 + avatarEditBadgeView.layer.borderWidth = 1 + avatarEditBadgeView.layer.borderColor = StyleGuide.Colors.inverseText.cgColor + + avatarEditIconView.image = UIImage(systemName: "camera.fill") + avatarEditIconView.tintColor = StyleGuide.Colors.inverseText + avatarEditIconView.contentMode = .scaleAspectFit + } +} + +private extension UIImage { + func squareCropped() -> UIImage { + let targetSide = min(size.width, size.height) + let cropRect = CGRect( + x: (size.width - targetSide) / 2, + y: (size.height - targetSide) / 2, + width: targetSide, + height: targetSide + ) + + guard let cg = cgImage?.cropping(to: cropRect) else { return self } + return UIImage(cgImage: cg, scale: scale, orientation: imageOrientation) + } +} diff --git a/Navigation/Profile/ProfileViewController.swift b/Navigation/Profile/ProfileViewController.swift new file mode 100644 index 0000000..49cef8a --- /dev/null +++ b/Navigation/Profile/ProfileViewController.swift @@ -0,0 +1,353 @@ +// +// ProfileViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 22.07.2025. +// + +import UIKit +import StorageService + +final class ProfileViewController: UIViewController { + enum ScreenMode { + case profile + case myProfile + + var title: String { + switch self { + case .profile: + return L10n.tr("profile.title") + case .myProfile: + return L10n.tr("profile.my_title") + } + } + } + + // MARK: - Properties + private let viewModel: ProfileViewModel + private let screenMode: ScreenMode + private let tableView = UITableView(frame: .zero, style: .plain) + private let favoritesRepository = FavoritesRepository.shared + private let headerView = ProfileHeaderView() + + enum Section: Int, CaseIterable { + case photos + case music + case posts + } + + // MARK: - Init + init( + viewModel: ProfileViewModel, + screenMode: ScreenMode = .profile + ) { + self.viewModel = viewModel + self.screenMode = screenMode + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupTableView() + } + + // MARK: - UI + private func setupUI() { + title = screenMode.title + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + navigationItem.largeTitleDisplayMode = .never + } + + private func setupTableView() { + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + tableView.dataSource = self + tableView.delegate = self + tableView.backgroundColor = StyleGuide.Colors.backgroundPrimary + tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 700 + + tableView.register( + PostTableViewCell.self, + forCellReuseIdentifier: PostTableViewCell.identifier + ) + tableView.register( + PhotosTableViewCell.self, + forCellReuseIdentifier: PhotosTableViewCell.identifier + ) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "MusicEntryCell") + + tableView.tableHeaderView = makeHeaderView() + } + + private func makeHeaderView() -> UIView { + if let user = viewModel.user { + headerView.configure( + with: user, + friendsCount: viewModel.friendsCount, + followersCount: viewModel.followersCount + ) + } + headerView.onProfileSettingsTap = { [weak self] in + self?.openProfileSettings() + } + headerView.onEditProfileTap = { [weak self] in + self?.openProfileEditor() + } + headerView.onAvatarTap = { [weak self] in + self?.openAvatarPicker() + } + + // важный момент: tableHeaderView НЕ считает autoLayout + let width = view.bounds.width + headerView.frame = CGRect(x: 0, y: 0, width: width, height: 310) + + return headerView + } + + private func openProfileSettings() { + let vc = SettingsViewController() + vc.onChangePassword = { [weak self] in + guard let self else { return } + let passwordVC = PasswordViewController() + passwordVC.onSuccess = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + self.navigationController?.pushViewController(passwordVC, animated: true) + } + vc.onLogout = { [weak self] in + self?.navigationController?.popToRootViewController(animated: false) + NotificationCenter.default.post(name: .appDidRequestLogout, object: nil) + } + navigationController?.pushViewController(vc, animated: true) + } + + private func openProfileEditor() { + guard let currentUser = viewModel.user else { return } + + let alert = UIAlertController( + title: L10n.tr("profile.edit.title"), + message: nil, + preferredStyle: .alert + ) + alert.addTextField { textField in + textField.placeholder = L10n.tr("profile.edit.name") + textField.text = currentUser.fullName + } + alert.addTextField { textField in + textField.placeholder = L10n.tr("profile.edit.status") + textField.text = currentUser.status + } + + alert.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: L10n.tr("common.save"), style: .default) { [weak self] _ in + guard + let self, + let fullName = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines), + let status = alert.textFields?.last?.text?.trimmingCharacters(in: .whitespacesAndNewlines), + !fullName.isEmpty, + !status.isEmpty + else { return } + + self.viewModel.updateProfile(fullName: fullName, status: status) + if let user = self.viewModel.user { + self.headerView.configure( + with: user, + friendsCount: self.viewModel.friendsCount, + followersCount: self.viewModel.followersCount + ) + } + }) + + present(alert, animated: true) + } + + private func openAvatarPicker() { + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.delegate = self + present(picker, animated: true) + } +} + +// MARK: - UITableViewDataSource + +extension ProfileViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + Section.allCases.count + } + + func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + + guard let section = Section(rawValue: section) else { return 0 } + + switch section { + case .photos: + return 1 + case .music: + return 1 + case .posts: + return viewModel.posts.count + } + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + guard let section = Section(rawValue: indexPath.section) else { + return UITableViewCell() + } + + switch section { + + case .photos: + let cell = tableView.dequeueReusableCell( + withIdentifier: PhotosTableViewCell.identifier, + for: indexPath + ) as! PhotosTableViewCell + cell.configure(with: viewModel.photos) + cell.selectionStyle = .none + return cell + + case .music: + let cell = tableView.dequeueReusableCell( + withIdentifier: "MusicEntryCell", + for: indexPath + ) + var config = cell.defaultContentConfiguration() + config.text = L10n.tr("music.profile.entry") + config.secondaryText = L10n.tr("music.profile.subtitle") + config.image = UIImage(systemName: "music.note.list") + cell.contentConfiguration = config + cell.accessoryType = .disclosureIndicator + return cell + + case .posts: + let cell = tableView.dequeueReusableCell( + withIdentifier: PostTableViewCell.identifier, + for: indexPath + ) as! PostTableViewCell + + let post = viewModel.posts[indexPath.row] + cell.configure(with: post, isFavorite: favoritesRepository.isFavorite(id: post.id)) + cell.onLikeTap = { [weak self, weak tableView] in + guard let self, let tableView else { return } + _ = self.favoritesRepository.toggle(post: post) + tableView.reloadRows(at: [indexPath], with: .none) + } + return cell + } + } +} + +// MARK: - UITableViewDelegate + +extension ProfileViewController: UITableViewDelegate { + + func tableView( + _ tableView: UITableView, + heightForRowAt indexPath: IndexPath + ) -> CGFloat { + guard let section = Section(rawValue: indexPath.section) else { + return UITableView.automaticDimension + } + + switch section { + case .photos: + return 150 + case .music: + return 72 + case .posts: + let post = viewModel.posts[indexPath.row] + let contentWidth = tableView.bounds.width - 32 + let descriptionHeight = post.description.boundingRect( + with: CGSize(width: contentWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: StyleGuide.Fonts.caption(14, weight: .regular)], + context: nil + ).height + + // author + image(square) + paddings + description + metrics row + return 16 + 24 + 12 + tableView.bounds.width + 16 + ceil(descriptionHeight) + 20 + 22 + 16 + } + } + + func tableView( + _ tableView: UITableView, + didSelectRowAt indexPath: IndexPath + ) { + tableView.deselectRow(at: indexPath, animated: true) + + guard let section = Section(rawValue: indexPath.section) else { return } + + switch section { + + case .photos: + let vc = PhotosViewController() + vc.photos = viewModel.photos + navigationController?.pushViewController(vc, animated: true) + + case .music: + let vc = MyMusicViewController() + navigationController?.pushViewController(vc, animated: true) + + case .posts: + let post = viewModel.posts[indexPath.row] + let vc = PostViewController(post: post) + navigationController?.pushViewController(vc, animated: true) + } + } +} + +extension ProfileViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] + ) { + picker.dismiss(animated: true) + guard let image = info[.originalImage] as? UIImage else { return } + let normalized = image.squareCropped() + + viewModel.updateAvatar(normalized) + + if let user = viewModel.user { + headerView.configure( + with: user, + friendsCount: viewModel.friendsCount, + followersCount: viewModel.followersCount + ) + } + } +} + +private extension UIImage { + func squareCropped() -> UIImage { + let side = min(size.width, size.height) + let x = (size.width - side) / 2 + let y = (size.height - side) / 2 + let rect = CGRect(x: x, y: y, width: side, height: side) + guard let cg = cgImage?.cropping(to: rect) else { return self } + return UIImage(cgImage: cg, scale: scale, orientation: imageOrientation) + } +} diff --git a/Navigation/Profile/ProfileViewModel.swift b/Navigation/Profile/ProfileViewModel.swift new file mode 100644 index 0000000..b978b1d --- /dev/null +++ b/Navigation/Profile/ProfileViewModel.swift @@ -0,0 +1,59 @@ +// +// ProfileViewModel.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 11/29/25. +// + +import UIKit +import StorageService + +final class ProfileViewModel { + + private(set) var user: User? + var posts: [Post] + var photos: [String] + let friendsCount: Int + let followersCount: Int + + var onDataChanged: (() -> Void)? + + init(user: User?) { + self.user = user + self.friendsCount = 248 + self.followersCount = 1302 + + self.posts = [ + Post(author: "Wowgorno", description: L10n.tr("profile.post.1"), image: "my_photo", likes: 120, views: 300), + Post(author: "Dady_hulk", description: L10n.tr("profile.post.2"), image: "hulk", likes: 95, views: 180), + Post(author: "Wowgorno", description: L10n.tr("profile.post.3"), image: "pp", likes: 450, views: 900), + Post(author: "Wowgorno", description: L10n.tr("profile.post.4"), image: "skala", likes: 270, views: 500) + ] + + self.photos = ["my_photo", "hulk", "pp", "skala"] + } + + func updateProfile(fullName: String, status: String) { + guard let user else { return } + self.user = User( + login: user.login, + fullName: fullName, + avatar: user.avatar, + status: status + ) + CurrentUserService().updateProfile(fullName: fullName, status: status) + onDataChanged?() + } + + func updateAvatar(_ image: UIImage) { + guard let user else { return } + self.user = User( + login: user.login, + fullName: user.fullName, + avatar: image, + status: user.status + ) + CurrentUserService().updateAvatar(image) + onDataChanged?() + } +} diff --git a/Navigation/Profile/TestUserService.swift b/Navigation/Profile/TestUserService.swift new file mode 100644 index 0000000..34cb203 --- /dev/null +++ b/Navigation/Profile/TestUserService.swift @@ -0,0 +1,22 @@ +// +// TestUserService.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/11/25. +// + +import UIKit + +final class TestUserService: UserService { + + private let testUser = User( + login: "wowgorno", + fullName: "Test User", + avatar: UIImage(named: "avatar") ?? UIImage(), + status: "DEBUG MODE" + ) + + func getUser(login: String) -> User? { + return login == testUser.login ? testUser : nil + } +} diff --git a/Navigation/Profile/User.swift b/Navigation/Profile/User.swift new file mode 100644 index 0000000..1e62ca5 --- /dev/null +++ b/Navigation/Profile/User.swift @@ -0,0 +1,15 @@ +// +// User.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/11/25. +// + +import UIKit + +struct User { + let login: String + let fullName: String + let avatar: UIImage + let status: String +} diff --git a/Navigation/Profile/UserService.swift b/Navigation/Profile/UserService.swift new file mode 100644 index 0000000..83e214f --- /dev/null +++ b/Navigation/Profile/UserService.swift @@ -0,0 +1,11 @@ +// +// UserService.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 10/11/25. +// + +import Foundation +protocol UserService { + func getUser(login: String) -> User? +} diff --git a/Navigation/ProfileViewController.swift b/Navigation/ProfileViewController.swift deleted file mode 100644 index 97a4042..0000000 --- a/Navigation/ProfileViewController.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ProfileViewController.swift -// Navigation -// -// Created by MAXIM GORNOSTAEV on 16.07.2025. -// - -import UIKit - -class ProfileViewController: UIViewController { - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - } -} - diff --git a/Navigation/SceneDelegate.swift b/Navigation/SceneDelegate.swift index c75ab09..1c4cd26 100644 --- a/Navigation/SceneDelegate.swift +++ b/Navigation/SceneDelegate.swift @@ -1,38 +1,74 @@ -// -// SceneDelegate.swift -// Navigation -// -// Created by MAXIM GORNOSTAEV on 16.07.2025. -// - import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + var appCoordinator: AppCoordinator? + var appConfiguration: AppConfiguration? - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { - guard let windowScene = (scene as? UIWindowScene) else { return } + guard let windowScene = scene as? UIWindowScene else { return } let window = UIWindow(windowScene: windowScene) + self.window = window - let feedVC = FeedViewController() - feedVC.title = "Feed" - let feedNav = UINavigationController(rootViewController: feedVC) - feedNav.tabBarItem = UITabBarItem(title: "Feed", image: UIImage(systemName: "list.bullet"), tag: 0) + appConfiguration = makeRandomConfiguration() - let profileVC = ProfileViewController() - profileVC.title = "Profile" - let profileNav = UINavigationController(rootViewController: profileVC) - profileNav.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(systemName: "person"), tag: 1) + if let configuration = appConfiguration { + NetworkService.request(for: configuration) + } - let tabBarController = UITabBarController() - tabBarController.viewControllers = [feedNav, profileNav] + let appCoordinator = AppCoordinator(window: window) + self.appCoordinator = appCoordinator + appCoordinator.start() + applyTheme() - - self.window = window - window.rootViewController = tabBarController - window.makeKeyAndVisible() + NotificationCenter.default.addObserver( + self, + selector: #selector(handleThemeDidChange), + name: .appThemeDidChange, + object: nil + ) + } + + /* 🔥 ВАЖНО ДЛЯ ЗАЧЁТА + func sceneDidDisconnect(_ scene: UIScene) { + do { + try Auth.auth().signOut() + print("✅ User signed out") + } catch { + print("❌ Sign out error:", error.localizedDescription) + } + } + */ + + private func makeRandomConfiguration() -> AppConfiguration { + let configurations: [AppConfiguration] = [ + .people("https://swapi.dev/api/people/8"), + .starship("https://swapi.dev/api/starships/3"), + .planet("https://swapi.dev/api/planets/5") + ] + + return configurations.randomElement()! + } + + @objc private func handleThemeDidChange() { + applyTheme() + } + + private func applyTheme() { + let style: UIUserInterfaceStyle + switch SettingsStorage.shared.themeMode { + case .system: + style = .unspecified + case .light: + style = .light + case .dark: + style = .dark + } + window?.overrideUserInterfaceStyle = style } } diff --git a/Navigation/Screens/Chats/ChatDetailViewController.swift b/Navigation/Screens/Chats/ChatDetailViewController.swift new file mode 100644 index 0000000..a49725f --- /dev/null +++ b/Navigation/Screens/Chats/ChatDetailViewController.swift @@ -0,0 +1,368 @@ +import UIKit + +final class ChatDetailViewController: UIViewController { + struct Message { + let id: UUID + let text: String + let isOutgoing: Bool + let date: Date + } + + private let titleText: String + private let roomID: String + private let chatService: FirebaseChatServiceProtocol + + private let tableView = UITableView(frame: .zero, style: .plain) + private let composerView = UIView() + private let inputContainerView = UIView() + private let textView = UITextView() + private let placeholderLabel = UILabel() + private let emojiButton = UIButton(type: .system) + private let sendButton = UIButton(type: .system) + private var composerBottomConstraint: NSLayoutConstraint? + + private var messages: [Message] = [] + private var refreshTimer: Timer? + + init( + title: String, + roomID: String, + chatService: FirebaseChatServiceProtocol = FirebaseChatService() + ) { + self.titleText = title + self.roomID = roomID + self.chatService = chatService + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = titleText + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupTableView() + setupComposer() + setupKeyboardHandling() + loadMessages() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + startPolling() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopPolling() + } + + deinit { + NotificationCenter.default.removeObserver(self) + stopPolling() + } + + private func setupTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.backgroundColor = StyleGuide.Colors.backgroundPrimary + tableView.separatorStyle = .none + tableView.keyboardDismissMode = .interactive + tableView.dataSource = self + tableView.delegate = self + tableView.register(ChatMessageCell.self, forCellReuseIdentifier: ChatMessageCell.identifier) + view.addSubview(tableView) + } + + private func setupComposer() { + composerView.translatesAutoresizingMaskIntoConstraints = false + composerView.backgroundColor = .clear + + inputContainerView.translatesAutoresizingMaskIntoConstraints = false + inputContainerView.backgroundColor = StyleGuide.Colors.card + inputContainerView.layer.cornerRadius = 20 + inputContainerView.layer.borderWidth = 1 + inputContainerView.layer.borderColor = StyleGuide.Colors.border.cgColor + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.font = StyleGuide.Fonts.body(16) + textView.textColor = StyleGuide.Colors.textPrimary + textView.backgroundColor = .clear + textView.layer.cornerRadius = 16 + textView.textContainerInset = UIEdgeInsets(top: 9, left: 2, bottom: 9, right: 2) + textView.delegate = self + + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.text = L10n.tr("chat.input.placeholder") + placeholderLabel.font = StyleGuide.Fonts.body(16) + placeholderLabel.textColor = StyleGuide.Colors.textSecondary + + emojiButton.translatesAutoresizingMaskIntoConstraints = false + emojiButton.setImage(UIImage(systemName: "face.smiling"), for: .normal) + emojiButton.tintColor = StyleGuide.Colors.textSecondary + emojiButton.backgroundColor = StyleGuide.Colors.backgroundSecondary + emojiButton.layer.cornerRadius = 14 + emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside) + + sendButton.translatesAutoresizingMaskIntoConstraints = false + sendButton.setImage(UIImage(systemName: "paperplane.fill"), for: .normal) + sendButton.tintColor = StyleGuide.Colors.inverseText + sendButton.backgroundColor = StyleGuide.Colors.accent + sendButton.layer.cornerRadius = 16 + sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) + + view.addSubview(composerView) + composerView.addSubview(inputContainerView) + inputContainerView.addSubview(emojiButton) + inputContainerView.addSubview(sendButton) + inputContainerView.addSubview(textView) + textView.addSubview(placeholderLabel) + + composerBottomConstraint = composerView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + composerBottomConstraint?.isActive = true + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: composerView.topAnchor), + + composerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + inputContainerView.topAnchor.constraint(equalTo: composerView.topAnchor, constant: 8), + inputContainerView.leadingAnchor.constraint(equalTo: composerView.leadingAnchor, constant: 12), + inputContainerView.trailingAnchor.constraint(equalTo: composerView.trailingAnchor, constant: -12), + inputContainerView.bottomAnchor.constraint(equalTo: composerView.safeAreaLayoutGuide.bottomAnchor, constant: -8), + + emojiButton.leadingAnchor.constraint(equalTo: inputContainerView.leadingAnchor, constant: 8), + emojiButton.bottomAnchor.constraint(equalTo: inputContainerView.bottomAnchor, constant: -6), + emojiButton.widthAnchor.constraint(equalToConstant: 28), + emojiButton.heightAnchor.constraint(equalToConstant: 28), + + sendButton.trailingAnchor.constraint(equalTo: inputContainerView.trailingAnchor, constant: -8), + sendButton.bottomAnchor.constraint(equalTo: inputContainerView.bottomAnchor, constant: -6), + sendButton.widthAnchor.constraint(equalToConstant: 32), + sendButton.heightAnchor.constraint(equalToConstant: 32), + + textView.topAnchor.constraint(equalTo: inputContainerView.topAnchor, constant: 4), + textView.leadingAnchor.constraint(equalTo: emojiButton.trailingAnchor, constant: 8), + textView.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -8), + textView.bottomAnchor.constraint(equalTo: inputContainerView.bottomAnchor, constant: -4), + textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 36), + + placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 6), + placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor, constant: 10) + ]) + } + + private func setupKeyboardHandling() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard + let userInfo = notification.userInfo, + let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, + let curveRaw = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt + else { return } + + let keyboardInView = view.convert(endFrame, from: nil) + let overlap = max(view.bounds.maxY - keyboardInView.minY - view.safeAreaInsets.bottom, 0) + composerBottomConstraint?.constant = -overlap + + UIView.animate( + withDuration: duration, + delay: 0, + options: UIView.AnimationOptions(rawValue: curveRaw << 16), + animations: { self.view.layoutIfNeeded() } + ) + } + + @objc private func emojiTapped() { + let emojis = ["😀", "😂", "😍", "🔥", "👍", "❤️", "👏", "🥳", "🤝"] + let sheet = UIAlertController(title: L10n.tr("chat.emoji.pick"), message: nil, preferredStyle: .actionSheet) + emojis.forEach { emoji in + sheet.addAction(UIAlertAction(title: emoji, style: .default, handler: { [weak self] _ in + self?.insertEmoji(emoji) + })) + } + sheet.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + + if let popover = sheet.popoverPresentationController { + popover.sourceView = emojiButton + popover.sourceRect = emojiButton.bounds + } + present(sheet, animated: true) + } + + private func insertEmoji(_ emoji: String) { + textView.text += emoji + placeholderLabel.isHidden = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func startPolling() { + stopPolling() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { [weak self] _ in + self?.loadMessages() + } + } + + private func stopPolling() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + private func loadMessages() { + guard let token = FirebaseSessionStorage.shared.token, + let currentUser = FirebaseSessionStorage.shared.user?.email else { + return + } + + Task { + let apiMessages = (try? await chatService.fetchMessages(roomID: roomID, token: token)) ?? [] + let mapped = apiMessages.map { + Message( + id: UUID(), + text: $0.text, + isOutgoing: FirebaseChatService.normalizedUserID($0.sender) == FirebaseChatService.normalizedUserID(currentUser), + date: $0.sentAt + ) + } + + await MainActor.run { + self.messages = mapped + self.tableView.reloadData() + self.scrollToBottom(animated: true) + } + } + } + + @objc private func sendTapped() { + let text = textView.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + guard let token = FirebaseSessionStorage.shared.token, + let currentUser = FirebaseSessionStorage.shared.user?.email else { return } + + textView.text = "" + placeholderLabel.isHidden = false + + Task { + do { + try await chatService.sendMessage(roomID: roomID, sender: currentUser, text: text, token: token) + await MainActor.run { + self.loadMessages() + } + } catch { + await MainActor.run { + self.textView.text = text + self.placeholderLabel.isHidden = true + } + } + } + } + + private func scrollToBottom(animated: Bool) { + guard !messages.isEmpty else { return } + let lastRow = messages.count - 1 + tableView.scrollToRow(at: IndexPath(row: lastRow, section: 0), at: .bottom, animated: animated) + } +} + +extension ChatDetailViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + messages.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: ChatMessageCell.identifier, for: indexPath) as! ChatMessageCell + cell.configure(with: messages[indexPath.row]) + return cell + } +} + +extension ChatDetailViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + placeholderLabel.isHidden = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +private final class ChatMessageCell: UITableViewCell { + static let identifier = "ChatMessageCell" + + private let bubble = UIView() + private let messageLabel = UILabel() + private let container = UIView() + private var leadingConstraint: NSLayoutConstraint? + private var trailingConstraint: NSLayoutConstraint? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + container.translatesAutoresizingMaskIntoConstraints = false + bubble.translatesAutoresizingMaskIntoConstraints = false + messageLabel.translatesAutoresizingMaskIntoConstraints = false + + messageLabel.numberOfLines = 0 + messageLabel.font = StyleGuide.Fonts.body(16, weight: .regular) + bubble.layer.cornerRadius = 16 + + contentView.addSubview(container) + container.addSubview(bubble) + bubble.addSubview(messageLabel) + + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4), + container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + + bubble.topAnchor.constraint(equalTo: container.topAnchor), + bubble.bottomAnchor.constraint(equalTo: container.bottomAnchor), + bubble.widthAnchor.constraint(lessThanOrEqualTo: container.widthAnchor, multiplier: 0.74), + + messageLabel.topAnchor.constraint(equalTo: bubble.topAnchor, constant: 10), + messageLabel.bottomAnchor.constraint(equalTo: bubble.bottomAnchor, constant: -10), + messageLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12), + messageLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12) + ]) + + leadingConstraint = bubble.leadingAnchor.constraint(equalTo: container.leadingAnchor) + trailingConstraint = bubble.trailingAnchor.constraint(equalTo: container.trailingAnchor) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with message: ChatDetailViewController.Message) { + messageLabel.text = message.text + leadingConstraint?.isActive = false + trailingConstraint?.isActive = false + + if message.isOutgoing { + bubble.backgroundColor = StyleGuide.Colors.accent + messageLabel.textColor = StyleGuide.Colors.inverseText + leadingConstraint = bubble.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 48) + trailingConstraint = bubble.trailingAnchor.constraint(equalTo: container.trailingAnchor) + } else { + bubble.backgroundColor = StyleGuide.Colors.card + messageLabel.textColor = StyleGuide.Colors.textPrimary + leadingConstraint = bubble.leadingAnchor.constraint(equalTo: container.leadingAnchor) + trailingConstraint = bubble.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -48) + } + leadingConstraint?.isActive = true + trailingConstraint?.isActive = true + } +} diff --git a/Navigation/Screens/Chats/ChatsViewController.swift b/Navigation/Screens/Chats/ChatsViewController.swift new file mode 100644 index 0000000..f2f11e6 --- /dev/null +++ b/Navigation/Screens/Chats/ChatsViewController.swift @@ -0,0 +1,146 @@ +import UIKit + +final class ChatsViewController: UIViewController { + private struct ChatItem { + let name: String + let roomID: String + let lastMessage: String + let time: String + } + + private let tableView = UITableView() + private let stateView = ScreenStateView() + private let chatService: FirebaseChatServiceProtocol + private let userProfileService: FirebaseUserProfileServiceProtocol + private var peers: [String] = [] + + private var chats: [ChatItem] = [] + + init( + chatService: FirebaseChatServiceProtocol = FirebaseChatService(), + userProfileService: FirebaseUserProfileServiceProtocol = FirebaseUserProfileService() + ) { + self.chatService = chatService + self.userProfileService = userProfileService + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("tab.chats") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupTableView() + setupStateView() + stateView.onRetry = { [weak self] in + self?.loadData() + } + loadData() + } + + private func setupTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = 72 + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "chatCell") + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func setupStateView() { + stateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stateView) + NSLayoutConstraint.activate([ + stateView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stateView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func loadData() { + stateView.apply(.loading(L10n.tr("chats.state.loading"))) + + guard let token = FirebaseSessionStorage.shared.token, + let currentUser = FirebaseSessionStorage.shared.user?.email else { + let fallbackPeers = defaultPeers() + chats = fallbackPeers.map { + ChatItem( + name: $0, + roomID: FirebaseChatService.roomID(currentUser: "guest", peer: $0), + lastMessage: L10n.tr("chat.placeholder.no_messages"), + time: "" + ) + } + tableView.reloadData() + stateView.apply(.content) + return + } + + Task { + let remotePeers = (try? await userProfileService.fetchUserEmails(idToken: token, excluding: currentUser)) ?? [] + self.peers = remotePeers.isEmpty ? self.defaultPeers() : remotePeers + let dialogs = (try? await chatService.fetchDialogs(currentUser: currentUser, peers: self.peers, token: token)) ?? [] + await MainActor.run { + self.chats = dialogs.map { + ChatItem(name: $0.peerName, roomID: $0.roomID, lastMessage: $0.lastMessage, time: $0.time) + } + + if self.chats.isEmpty { + self.stateView.apply(.empty(L10n.tr("chats.state.empty"))) + } else { + self.tableView.reloadData() + self.stateView.apply(.content) + } + } + } + } + + private func defaultPeers() -> [String] { + [ + L10n.tr("chat.item.netology.name"), + "ios.team@platform.app", + L10n.tr("chat.item.friends.name"), + "maxim@platform.app" + ] + } +} + +extension ChatsViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + chats.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "chatCell", for: indexPath) + let chat = chats[indexPath.row] + var config = cell.defaultContentConfiguration() + config.text = chat.name + config.secondaryText = "\(chat.lastMessage) • \(chat.time)" + config.image = UIImage(systemName: "person.crop.circle.fill") + cell.contentConfiguration = config + cell.accessoryType = .disclosureIndicator + return cell + } +} + +extension ChatsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard chats.indices.contains(indexPath.row) else { return } + let item = chats[indexPath.row] + let vc = ChatDetailViewController(title: item.name, roomID: item.roomID) + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/Navigation/Screens/Clips/ClipsViewController.swift b/Navigation/Screens/Clips/ClipsViewController.swift new file mode 100644 index 0000000..5545960 --- /dev/null +++ b/Navigation/Screens/Clips/ClipsViewController.swift @@ -0,0 +1,351 @@ +import UIKit +import AVFoundation + +final class ClipsViewController: UIViewController { + private let collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.minimumLineSpacing = 12 + layout.minimumInteritemSpacing = 12 + return UICollectionView(frame: .zero, collectionViewLayout: layout) + }() + + private let stateView = ScreenStateView() + private let controlsContainer = UIView() + private let trackTitleLabel = UILabel() + private let artistLabel = UILabel() + + private let previousButton = UIButton(type: .system) + private let rewindButton = UIButton(type: .system) + private let playPauseButton = UIButton(type: .system) + private let stopButton = UIButton(type: .system) + private let forwardButton = UIButton(type: .system) + private let nextButton = UIButton(type: .system) + + private let service: MusicCatalogServiceProtocol = MusicCatalogService() + private var tracks: [MusicTrack] = [] + private var player: AVPlayer? + private var currentIndex: Int = 0 + private var isPlaying = false + + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("tab.music") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + + setupCollectionView() + setupControls() + setupStateView() + + stateView.onRetry = { [weak self] in + self?.loadData() + } + + loadData() + } + + private func setupCollectionView() { + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColor = StyleGuide.Colors.backgroundPrimary + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register(MusicCoverCell.self, forCellWithReuseIdentifier: MusicCoverCell.identifier) + + view.addSubview(collectionView) + } + + private func setupControls() { + controlsContainer.translatesAutoresizingMaskIntoConstraints = false + controlsContainer.backgroundColor = StyleGuide.Colors.backgroundSecondary + controlsContainer.layer.cornerRadius = 14 + + trackTitleLabel.font = StyleGuide.Fonts.body(14, weight: .semibold) + trackTitleLabel.textColor = StyleGuide.Colors.textPrimary + trackTitleLabel.translatesAutoresizingMaskIntoConstraints = false + + artistLabel.font = StyleGuide.Fonts.caption(12, weight: .regular) + artistLabel.textColor = StyleGuide.Colors.textSecondary + artistLabel.translatesAutoresizingMaskIntoConstraints = false + + configureControl(previousButton, systemName: "backward.end.fill", action: #selector(previousTapped)) + configureControl(rewindButton, systemName: "gobackward.10", action: #selector(rewindTapped)) + configureControl(playPauseButton, systemName: "play.fill", action: #selector(playPauseTapped)) + configureControl(stopButton, systemName: "stop.fill", action: #selector(stopTapped)) + configureControl(forwardButton, systemName: "goforward.10", action: #selector(forwardTapped)) + configureControl(nextButton, systemName: "forward.end.fill", action: #selector(nextTapped)) + + let controlsStack = UIStackView(arrangedSubviews: [previousButton, rewindButton, playPauseButton, stopButton, forwardButton, nextButton]) + controlsStack.axis = .horizontal + controlsStack.spacing = 8 + controlsStack.distribution = .fillEqually + controlsStack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(controlsContainer) + controlsContainer.addSubview(trackTitleLabel) + controlsContainer.addSubview(artistLabel) + controlsContainer.addSubview(controlsStack) + + NSLayoutConstraint.activate([ + controlsContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12), + controlsContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), + controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8), + controlsContainer.heightAnchor.constraint(equalToConstant: 126), + + trackTitleLabel.topAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: 10), + trackTitleLabel.leadingAnchor.constraint(equalTo: controlsContainer.leadingAnchor, constant: 12), + trackTitleLabel.trailingAnchor.constraint(equalTo: controlsContainer.trailingAnchor, constant: -12), + + artistLabel.topAnchor.constraint(equalTo: trackTitleLabel.bottomAnchor, constant: 2), + artistLabel.leadingAnchor.constraint(equalTo: trackTitleLabel.leadingAnchor), + artistLabel.trailingAnchor.constraint(equalTo: trackTitleLabel.trailingAnchor), + + controlsStack.topAnchor.constraint(equalTo: artistLabel.bottomAnchor, constant: 10), + controlsStack.leadingAnchor.constraint(equalTo: controlsContainer.leadingAnchor, constant: 8), + controlsStack.trailingAnchor.constraint(equalTo: controlsContainer.trailingAnchor, constant: -8), + controlsStack.bottomAnchor.constraint(equalTo: controlsContainer.bottomAnchor, constant: -8), + + collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), + collectionView.bottomAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: -8) + ]) + } + + private func setupStateView() { + stateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stateView) + NSLayoutConstraint.activate([ + stateView.topAnchor.constraint(equalTo: collectionView.topAnchor), + stateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stateView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor) + ]) + } + + private func configureControl(_ button: UIButton, systemName: String, action: Selector) { + button.setImage(UIImage(systemName: systemName), for: .normal) + button.tintColor = StyleGuide.Colors.accent + button.backgroundColor = StyleGuide.Colors.card + button.layer.cornerRadius = 8 + button.addTarget(self, action: action, for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + } + + private func loadData() { + stateView.apply(.loading(L10n.tr("music.state.loading"))) + + Task { + do { + let loaded = try await service.fetchTracks(query: "top hits 2026", limit: 50) + await MainActor.run { + self.tracks = loaded + self.collectionView.reloadData() + if loaded.isEmpty { + self.stateView.apply(.empty(L10n.tr("music.state.empty"))) + } else { + self.prepareTrack(at: 0) + self.stateView.apply(.content) + } + } + } catch { + await MainActor.run { + self.stateView.apply(.error(L10n.tr("music.state.error"))) + } + } + } + } + + private func prepareTrack(at index: Int) { + guard tracks.indices.contains(index) else { return } + currentIndex = index + let track = tracks[index] + player = AVPlayer(url: track.previewURL) + isPlaying = false + trackTitleLabel.text = track.title + artistLabel.text = track.artist + playPauseButton.setImage(UIImage(systemName: "play.fill"), for: .normal) + } + + private func startPlayback() { + player?.play() + isPlaying = true + playPauseButton.setImage(UIImage(systemName: "pause.fill"), for: .normal) + } + + @objc private func previousTapped() { + guard !tracks.isEmpty else { return } + let next = (currentIndex - 1 + tracks.count) % tracks.count + prepareTrack(at: next) + startPlayback() + } + + @objc private func nextTapped() { + guard !tracks.isEmpty else { return } + let next = (currentIndex + 1) % tracks.count + prepareTrack(at: next) + startPlayback() + } + + @objc private func rewindTapped() { + guard let player else { return } + let target = max(0, player.currentTime().seconds - 10) + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) + } + + @objc private func forwardTapped() { + guard let player else { return } + let current = player.currentTime().seconds + let duration = player.currentItem?.duration.seconds ?? current + 10 + let target = min(duration, current + 10) + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) + } + + @objc private func stopTapped() { + player?.pause() + player?.seek(to: .zero) + isPlaying = false + playPauseButton.setImage(UIImage(systemName: "play.fill"), for: .normal) + } + + @objc private func playPauseTapped() { + guard let player else { return } + if isPlaying { + player.pause() + isPlaying = false + playPauseButton.setImage(UIImage(systemName: "play.fill"), for: .normal) + } else { + player.play() + isPlaying = true + playPauseButton.setImage(UIImage(systemName: "pause.fill"), for: .normal) + } + } +} + +extension ClipsViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + tracks.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MusicCoverCell.identifier, for: indexPath) as! MusicCoverCell + cell.configure(with: tracks[indexPath.item]) + return cell + } +} + +extension ClipsViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let columns: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 3 : 2 + let totalSpacing = (columns - 1) * 12 + let width = (collectionView.bounds.width - totalSpacing) / columns + return CGSize(width: width, height: width + 54) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + prepareTrack(at: indexPath.item) + startPlayback() + } +} + +final class MusicCoverCell: UICollectionViewCell { + static let identifier = "MusicCoverCell" + + private let artworkImageView = UIImageView() + private let titleLabel = UILabel() + private let artistLabel = UILabel() + + private var task: URLSessionDataTask? + private static let cache = NSCache() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + task?.cancel() + task = nil + artworkImageView.image = UIImage(systemName: "music.note") + } + + func configure(with track: MusicTrack) { + titleLabel.text = track.title + artistLabel.text = track.artist + loadArtwork(url: track.artworkURL) + } + + private func setupUI() { + backgroundColor = StyleGuide.Colors.card + layer.cornerRadius = 12 + layer.borderWidth = 0.5 + layer.borderColor = StyleGuide.Colors.border.cgColor + + artworkImageView.translatesAutoresizingMaskIntoConstraints = false + artworkImageView.contentMode = .scaleAspectFill + artworkImageView.clipsToBounds = true + artworkImageView.layer.cornerRadius = 10 + artworkImageView.image = UIImage(systemName: "music.note") + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = StyleGuide.Fonts.caption(12, weight: .semibold) + titleLabel.textColor = StyleGuide.Colors.textPrimary + titleLabel.numberOfLines = 1 + + artistLabel.translatesAutoresizingMaskIntoConstraints = false + artistLabel.font = StyleGuide.Fonts.caption(11, weight: .regular) + artistLabel.textColor = StyleGuide.Colors.textSecondary + artistLabel.numberOfLines = 1 + + contentView.addSubview(artworkImageView) + contentView.addSubview(titleLabel) + contentView.addSubview(artistLabel) + + NSLayoutConstraint.activate([ + artworkImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + artworkImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + artworkImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + artworkImageView.heightAnchor.constraint(equalTo: artworkImageView.widthAnchor), + + titleLabel.topAnchor.constraint(equalTo: artworkImageView.bottomAnchor, constant: 6), + titleLabel.leadingAnchor.constraint(equalTo: artworkImageView.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: artworkImageView.trailingAnchor), + + artistLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), + artistLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + artistLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + artistLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6) + ]) + } + + private func loadArtwork(url: URL?) { + task?.cancel() + task = nil + + guard let url else { + artworkImageView.image = UIImage(systemName: "music.note") + return + } + + let nsURL = url as NSURL + if let cached = Self.cache.object(forKey: nsURL) { + artworkImageView.image = cached + return + } + + artworkImageView.image = UIImage(systemName: "music.note") + task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self, + let data, + let image = UIImage(data: data) else { return } + Self.cache.setObject(image, forKey: nsURL) + DispatchQueue.main.async { + self.artworkImageView.image = image + } + } + task?.resume() + } +} diff --git a/Navigation/Screens/Files/FilesViewController.swift b/Navigation/Screens/Files/FilesViewController.swift new file mode 100644 index 0000000..0a61874 --- /dev/null +++ b/Navigation/Screens/Files/FilesViewController.swift @@ -0,0 +1,99 @@ +// +// FilesViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/13/26. +// + +import UIKit + +final class FilesViewController: UIViewController { + + // MARK: - UI + private let tableView = UITableView() + + // MARK: - Data + private var files: [String] = [ + "Document.txt", + "Image.png", + "Notes.md", + "Archive.zip", + "Presentation.key", + "Report.pdf" + ] + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + title = L10n.tr("menu.files") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + + setupTableView() + sortFiles() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + sortFiles() + tableView.reloadData() + } + + // MARK: - Setup + private func setupTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "FileCell") + + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + // MARK: - Sorting + private func sortFiles() { + let isAscending = SettingsStorage.shared.isAscending + + files.sort { + isAscending ? $0.localizedCompare($1) == .orderedAscending + : $0.localizedCompare($1) == .orderedDescending + } + } +} + +// MARK: - UITableViewDataSource +extension FilesViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + files.count + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + let cell = tableView.dequeueReusableCell( + withIdentifier: "FileCell", + for: indexPath + ) + + cell.textLabel?.text = files[indexPath.row] + cell.accessoryType = .disclosureIndicator + return cell + } +} + +// MARK: - UITableViewDelegate +extension FilesViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/Navigation/Screens/Home/HomePostCell.swift b/Navigation/Screens/Home/HomePostCell.swift new file mode 100644 index 0000000..d8414c1 --- /dev/null +++ b/Navigation/Screens/Home/HomePostCell.swift @@ -0,0 +1,309 @@ +import UIKit + +struct HomeFeedPost { + var post: Post + var avatarURL: URL? + var publishedAt: Date? + var localImage: UIImage? + var localImageFileName: String? + var remoteImageURL: URL? + var likeCount: Int + var commentCount: Int + var shareCount: Int + var isLiked: Bool + var isCustomPublication: Bool +} + +final class HomePostCell: UITableViewCell { + static let identifier = "HomePostCell" + + private let cardView = UIView() + private let avatarImageView = UIImageView() + private let authorLabel = UILabel() + private let dateLabel = UILabel() + private let postImageView = UIImageView() + private let descriptionLabel = UILabel() + + private let likeButton = UIButton(type: .system) + private let commentButton = UIButton(type: .system) + private let shareButton = UIButton(type: .system) + private let actionsStack = UIStackView() + private var imageTask: URLSessionDataTask? + private var avatarTask: URLSessionDataTask? + private static let imageCache = NSCache() + + var onLikeTap: (() -> Void)? + var onCommentTap: (() -> Void)? + var onShareTap: (() -> Void)? + private var isSkeletonMode = false + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + setupActions() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with model: HomeFeedPost) { + isSkeletonMode = false + avatarImageView.isHidden = false + authorLabel.isHidden = false + dateLabel.isHidden = false + descriptionLabel.isHidden = false + actionsStack.isHidden = false + [cardView, authorLabel, descriptionLabel, postImageView].forEach { view in + view.layer.removeAllAnimations() + view.backgroundColor = .clear + } + + authorLabel.text = model.post.author + dateLabel.text = model.publishedAt.map(Self.relativeDateText) ?? "" + descriptionLabel.text = model.post.description + applyImage(model: model) + applyAvatar(model: model) + + let likeIcon = model.isLiked ? "heart.fill" : "heart" + likeButton.setImage(UIImage(systemName: likeIcon), for: .normal) + likeButton.setTitle(" \(model.likeCount)", for: .normal) + likeButton.tintColor = model.isLiked ? StyleGuide.Colors.danger : StyleGuide.Colors.textSecondary + + commentButton.setImage(UIImage(systemName: "bubble.right"), for: .normal) + commentButton.setTitle(" \(model.commentCount)", for: .normal) + + shareButton.setImage(UIImage(systemName: "arrowshape.turn.up.right"), for: .normal) + shareButton.setTitle(" \(model.shareCount)", for: .normal) + } + + func configureSkeleton() { + isSkeletonMode = true + authorLabel.text = nil + dateLabel.text = nil + descriptionLabel.text = nil + postImageView.image = nil + avatarImageView.image = nil + likeButton.setTitle(nil, for: .normal) + commentButton.setTitle(nil, for: .normal) + shareButton.setTitle(nil, for: .normal) + + actionsStack.isHidden = true + avatarImageView.isHidden = true + authorLabel.isHidden = true + dateLabel.isHidden = true + descriptionLabel.isHidden = true + + postImageView.backgroundColor = StyleGuide.Colors.backgroundSecondary + cardView.backgroundColor = StyleGuide.Colors.card.withAlphaComponent(0.65) + + let pulse = CABasicAnimation(keyPath: "opacity") + pulse.fromValue = 0.45 + pulse.toValue = 1 + pulse.duration = 0.85 + pulse.autoreverses = true + pulse.repeatCount = .infinity + cardView.layer.add(pulse, forKey: "skeletonPulse") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageTask?.cancel() + avatarTask?.cancel() + imageTask = nil + avatarTask = nil + postImageView.image = nil + avatarImageView.image = nil + cardView.layer.removeAnimation(forKey: "skeletonPulse") + if isSkeletonMode { + [avatarImageView, authorLabel, dateLabel, descriptionLabel, actionsStack].forEach { $0.isHidden = false } + postImageView.backgroundColor = .clear + cardView.backgroundColor = StyleGuide.Colors.card + isSkeletonMode = false + } + } + + private func setupUI() { + backgroundColor = StyleGuide.Colors.backgroundPrimary + selectionStyle = .none + + cardView.backgroundColor = StyleGuide.Colors.card + cardView.layer.cornerRadius = 14 + cardView.layer.borderWidth = 0.5 + cardView.layer.borderColor = StyleGuide.Colors.border.cgColor + cardView.translatesAutoresizingMaskIntoConstraints = false + + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + avatarImageView.layer.cornerRadius = 16 + avatarImageView.backgroundColor = StyleGuide.Colors.backgroundSecondary + + authorLabel.font = StyleGuide.Fonts.body(15, weight: .semibold) + authorLabel.textColor = StyleGuide.Colors.textPrimary + authorLabel.translatesAutoresizingMaskIntoConstraints = false + + dateLabel.font = StyleGuide.Fonts.caption(12, weight: .regular) + dateLabel.textColor = StyleGuide.Colors.textSecondary + dateLabel.translatesAutoresizingMaskIntoConstraints = false + + postImageView.contentMode = .scaleAspectFill + postImageView.clipsToBounds = true + postImageView.layer.cornerRadius = 10 + postImageView.translatesAutoresizingMaskIntoConstraints = false + + descriptionLabel.numberOfLines = 0 + descriptionLabel.font = StyleGuide.Fonts.body(15) + descriptionLabel.textColor = StyleGuide.Colors.textPrimary + descriptionLabel.translatesAutoresizingMaskIntoConstraints = false + + [likeButton, commentButton, shareButton].forEach { + $0.titleLabel?.font = StyleGuide.Fonts.caption(14, weight: .medium) + $0.tintColor = StyleGuide.Colors.textSecondary + $0.contentHorizontalAlignment = .leading + } + + actionsStack.axis = .horizontal + actionsStack.distribution = .fillEqually + actionsStack.spacing = 6 + actionsStack.translatesAutoresizingMaskIntoConstraints = false + actionsStack.addArrangedSubview(likeButton) + actionsStack.addArrangedSubview(commentButton) + actionsStack.addArrangedSubview(shareButton) + + contentView.addSubview(cardView) + cardView.addSubview(avatarImageView) + cardView.addSubview(authorLabel) + cardView.addSubview(dateLabel) + cardView.addSubview(postImageView) + cardView.addSubview(descriptionLabel) + cardView.addSubview(actionsStack) + + NSLayoutConstraint.activate([ + cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + + avatarImageView.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10), + avatarImageView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 12), + avatarImageView.widthAnchor.constraint(equalToConstant: 32), + avatarImageView.heightAnchor.constraint(equalToConstant: 32), + + authorLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 11), + authorLabel.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 10), + authorLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -12), + + dateLabel.topAnchor.constraint(equalTo: authorLabel.bottomAnchor, constant: 1), + dateLabel.leadingAnchor.constraint(equalTo: authorLabel.leadingAnchor), + dateLabel.trailingAnchor.constraint(equalTo: authorLabel.trailingAnchor), + + postImageView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 10), + postImageView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 12), + postImageView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -12), + postImageView.heightAnchor.constraint(equalTo: postImageView.widthAnchor, multiplier: 0.62), + + descriptionLabel.topAnchor.constraint(equalTo: postImageView.bottomAnchor, constant: 10), + descriptionLabel.leadingAnchor.constraint(equalTo: postImageView.leadingAnchor), + descriptionLabel.trailingAnchor.constraint(equalTo: postImageView.trailingAnchor), + + actionsStack.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 10), + actionsStack.leadingAnchor.constraint(equalTo: postImageView.leadingAnchor), + actionsStack.trailingAnchor.constraint(equalTo: postImageView.trailingAnchor), + actionsStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10), + actionsStack.heightAnchor.constraint(equalToConstant: 30) + ]) + } + + private func setupActions() { + likeButton.addTarget(self, action: #selector(likeTap), for: .touchUpInside) + commentButton.addTarget(self, action: #selector(commentTap), for: .touchUpInside) + shareButton.addTarget(self, action: #selector(shareTap), for: .touchUpInside) + } + + @objc private func likeTap() { + onLikeTap?() + } + + @objc private func commentTap() { + onCommentTap?() + } + + @objc private func shareTap() { + onShareTap?() + } + + private func applyImage(model: HomeFeedPost) { + imageTask?.cancel() + imageTask = nil + + if let localImage = model.localImage { + postImageView.image = localImage + return + } + + if let remoteURL = model.remoteImageURL { + let nsURL = remoteURL as NSURL + if let cached = Self.imageCache.object(forKey: nsURL) { + postImageView.image = cached + postImageView.backgroundColor = .clear + return + } + + postImageView.image = nil + postImageView.backgroundColor = StyleGuide.Colors.backgroundSecondary + imageTask = URLSession.shared.dataTask(with: remoteURL) { [weak self] data, _, _ in + guard let self, + let data, + let image = UIImage(data: data) else { return } + Self.imageCache.setObject(image, forKey: nsURL) + DispatchQueue.main.async { + self.postImageView.image = image + self.postImageView.backgroundColor = .clear + } + } + imageTask?.resume() + return + } + + postImageView.image = fallbackImage(for: model) + postImageView.backgroundColor = .clear + } + + private func applyAvatar(model: HomeFeedPost) { + avatarTask?.cancel() + avatarTask = nil + avatarImageView.image = UIImage(systemName: "person.crop.circle.fill") + avatarImageView.tintColor = StyleGuide.Colors.textSecondary + + guard let remoteURL = model.avatarURL else { return } + let nsURL = remoteURL as NSURL + + if let cached = Self.imageCache.object(forKey: nsURL) { + avatarImageView.image = cached + return + } + + avatarTask = URLSession.shared.dataTask(with: remoteURL) { [weak self] data, _, _ in + guard let self, + let data, + let image = UIImage(data: data) else { return } + + Self.imageCache.setObject(image, forKey: nsURL) + DispatchQueue.main.async { + self.avatarImageView.image = image + } + } + avatarTask?.resume() + } + + private static func relativeDateText(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private func fallbackImage(for model: HomeFeedPost) -> UIImage? { + return UIImage(named: model.post.image) + } +} diff --git a/Navigation/Screens/Home/HomeViewController.swift b/Navigation/Screens/Home/HomeViewController.swift new file mode 100644 index 0000000..c5f031d --- /dev/null +++ b/Navigation/Screens/Home/HomeViewController.swift @@ -0,0 +1,835 @@ +import UIKit + +final class HomeViewController: UIViewController { + private enum FeatureFlags { + // CloudKit is disabled for Personal Team builds to avoid runtime crashes without entitlement. + static let cloudSyncEnabled = false + } + + /// Сохраненное созданных пользователями публикаций в `UserDefaults`. + private struct StoredPublication: Codable { + let id: String + let author: String + let description: String + let imageFileName: String? + let remoteImageURL: String? + let likes: Int + let views: Int + let comments: Int + let shares: Int + let isLiked: Bool + } + + private enum StorageKeys { + static let customPublications = "home.customPublications" + } + + private let tableView = UITableView(frame: .zero, style: .plain) + private let refreshControl = UIRefreshControl() + private let favoritesRepository = FavoritesRepository.shared + private let interactionsStore: FeedInteractionsStoreProtocol + private let remoteFeedViewModel: SocialFeedViewModel + private let cloudPostsService: CloudPostsService? = { + guard FeatureFlags.cloudSyncEnabled else { return nil } + return CloudPostsService(container: .default()) + }() + private let stateView = ScreenStateView() + private var avatarImage: UIImage? + private var pendingImageForPost: UIImage? + private var isSkeletonLoading = true + private var remoteFeed: [HomeFeedPost] = [] + private var customFeed: [HomeFeedPost] = [] + private var autoRefreshTimer: Timer? + private var noInternetWorkItem: DispatchWorkItem? + private var lastNoInternetAlertDate: Date? + private var selectedGenre: FeedGenre = .humor + var onOpenProfile: (() -> Void)? + + private var stories: [StoryItem] = [ + StoryItem(id: UUID().uuidString, name: "Maxim", imageName: "my_photo"), + StoryItem(id: UUID().uuidString, name: "Dady", imageName: "hulk"), + StoryItem(id: UUID().uuidString, name: "Plein", imageName: "pp"), + StoryItem(id: UUID().uuidString, name: "Swift", imageName: "skala"), + StoryItem(id: UUID().uuidString, name: "Netology", imageName: "avatar") + ] + + // Итоговая лента = пользовательские публикации + посты из API. + private var feed: [HomeFeedPost] = [] + + init( + remoteFeedViewModel: SocialFeedViewModel = SocialFeedViewModel( + service: CatFeedService(), + cacheRepository: CoreDataFeedCacheRepository() + ), + interactionsStore: FeedInteractionsStoreProtocol = FeedInteractionsStore() + ) { + self.remoteFeedViewModel = remoteFeedViewModel + self.interactionsStore = interactionsStore + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("tab.home") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupNavigationBar() + setupTableView() + setupStoriesHeader() + setupStateView() + stateView.onRetry = { [weak self] in + self?.requestRemoteFeed(isRefresh: false) + } + remoteFeedViewModel.setGenre(selectedGenre) + bindRemoteFeedViewModel() + loadCustomPublications() + + requestRemoteFeed(isRefresh: false) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + let user = currentUser() + configureAvatar(user?.avatar) + startAutoFeedRefresh() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopAutoFeedRefresh() + } + + private func setupTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.backgroundColor = StyleGuide.Colors.backgroundPrimary + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.estimatedRowHeight = 440 + tableView.rowHeight = UITableView.automaticDimension + tableView.register(HomePostCell.self, forCellReuseIdentifier: HomePostCell.identifier) + refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + tableView.refreshControl = refreshControl + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func setupStoriesHeader() { + let headerContainer = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 116)) + headerContainer.backgroundColor = StyleGuide.Colors.backgroundPrimary + + let storiesView = StoriesHeaderView() + storiesView.translatesAutoresizingMaskIntoConstraints = false + storiesView.configure(with: stories) + storiesView.onStoryTap = { [weak self] _, index in + self?.openStory(at: index) + } + + headerContainer.addSubview(storiesView) + NSLayoutConstraint.activate([ + storiesView.topAnchor.constraint(equalTo: headerContainer.topAnchor, constant: 4), + storiesView.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor), + storiesView.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor), + storiesView.bottomAnchor.constraint(equalTo: headerContainer.bottomAnchor, constant: -4) + ]) + + tableView.tableHeaderView = headerContainer + } + + private func setupStateView() { + stateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stateView) + NSLayoutConstraint.activate([ + stateView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stateView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func setupNavigationBar() { + applyAvatarImage() + let addButton = UIBarButtonItem( + image: UIImage(systemName: "plus"), + style: .plain, + target: self, + action: #selector(addPostTapped) + ) + navigationItem.rightBarButtonItem = addButton + } + + func configureAvatar(_ image: UIImage?) { + avatarImage = image + if isViewLoaded { + applyAvatarImage() + } + } + + private func applyAvatarImage() { + let image = makeAvatarBarImage(from: avatarImage) + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: image, + style: .plain, + target: self, + action: #selector(profileTapped) + ) + navigationItem.leftBarButtonItem?.tintColor = nil + } + + private func makeAvatarBarImage(from image: UIImage?) -> UIImage { + let size = CGSize(width: 30, height: 30) + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.image { _ in + let rect = CGRect(origin: .zero, size: size) + let path = UIBezierPath(ovalIn: rect) + path.addClip() + + if let image { + image.draw(in: rect) + } else { + UIColor.systemGray5.setFill() + path.fill() + let symbol = UIImage(systemName: "person.fill")?.withTintColor(.white, renderingMode: .alwaysOriginal) + symbol?.draw(in: CGRect(x: 8, y: 7, width: 14, height: 16)) + } + }.withRenderingMode(.alwaysOriginal) + } + + private func loadContent() { + if feed.isEmpty { + stateView.apply(.empty(L10n.tr("home.state.empty"))) + } else { + tableView.reloadData() + stateView.apply(.content) + } + } + + private func bindRemoteFeedViewModel() { + remoteFeedViewModel.onStateChange = { [weak self] state in + self?.renderRemoteState(state) + } + } + + private func renderRemoteState(_ state: SocialFeedViewModel.State) { + switch state { + case .idle: + break + case .loading: + isSkeletonLoading = true + scheduleNoInternetWarningIfNeeded() + stateView.apply(.loading(L10n.tr("home.state.loading"))) + tableView.reloadData() + case .content(let posts): + noInternetWorkItem?.cancel() + isSkeletonLoading = false + remoteFeed = posts.map(mapRemotePost) + rebuildFeed() + refreshControl.endRefreshing() + case .error(let message): + noInternetWorkItem?.cancel() + isSkeletonLoading = false + refreshControl.endRefreshing() + + if feed.isEmpty { + stateView.apply(.error(message)) + } else { + stateView.apply(.content) + } + tableView.reloadData() + } + } + + private func mapRemotePost(_ post: SocialFeedPost) -> HomeFeedPost { + let interaction = interactionsStore.snapshot(for: post.id, userID: currentUserID()) + let generated = Post( + id: post.id, + author: post.username, + description: post.caption, + image: "my_photo", + likes: interaction.likesCount, + views: Int.random(in: 300...7000) + ) + + return HomeFeedPost( + post: generated, + avatarURL: post.avatarURL, + publishedAt: post.date, + localImage: nil, + localImageFileName: nil, + remoteImageURL: post.photoURL, + likeCount: interaction.likesCount, + commentCount: interaction.commentsCount, + shareCount: interaction.sharesCount, + isLiked: interaction.isLiked, + isCustomPublication: false + ) + } + + private func rebuildFeed() { + feed = customFeed + remoteFeed + loadContent() + } + + @objc private func refreshPulled() { + requestRemoteFeed(isRefresh: true) + } + + private func requestRemoteFeed(isRefresh: Bool) { + Task { [weak self] in + guard let self else { return } + if isRefresh { + await remoteFeedViewModel.refresh() + } else { + await remoteFeedViewModel.loadInitial() + } + } + } + + private func startAutoFeedRefresh() { + stopAutoFeedRefresh() + autoRefreshTimer = Timer.scheduledTimer(withTimeInterval: 45, repeats: true) { [weak self] _ in + self?.requestRemoteFeed(isRefresh: true) + } + } + + private func stopAutoFeedRefresh() { + autoRefreshTimer?.invalidate() + autoRefreshTimer = nil + } + + private func toggleLike(at index: Int) { + guard feed.indices.contains(index) else { return } + + if feed[index].isCustomPublication { + feed[index].isLiked.toggle() + feed[index].likeCount += feed[index].isLiked ? 1 : -1 + syncCustomFeedItem(feed[index]) + persistCustomPublications() + syncCustomPostToCloud(feed[index]) + } else { + let snapshot = interactionsStore.toggleLike(for: feed[index].post.id, userID: currentUserID()) + feed[index].isLiked = snapshot.isLiked + feed[index].likeCount = snapshot.likesCount + + if snapshot.isLiked { + favoritesRepository.save(post: feed[index].post) + } else { + favoritesRepository.remove(id: feed[index].post.id) + } + } + + tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .none) + } + + private func addComment(at index: Int) { + guard feed.indices.contains(index) else { return } + presentCommentDialog(at: index) + } + + private func sharePost(at index: Int) { + guard feed.indices.contains(index) else { return } + + if feed[index].isCustomPublication { + feed[index].shareCount += 1 + } else { + let snapshot = interactionsStore.incrementShare(for: feed[index].post.id, userID: currentUserID()) + feed[index].shareCount = snapshot.sharesCount + } + + let activity = UIActivityViewController( + activityItems: [feed[index].post.description], + applicationActivities: nil + ) + present(activity, animated: true) + + if feed[index].isCustomPublication { + syncCustomFeedItem(feed[index]) + persistCustomPublications() + syncCustomPostToCloud(feed[index]) + } + tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .none) + } + + private func presentCommentDialog(at index: Int) { + let alert = UIAlertController( + title: L10n.tr("home.post.comment_title"), + message: nil, + preferredStyle: .alert + ) + alert.addTextField { textField in + textField.placeholder = L10n.tr("home.post.comment_placeholder") + } + alert.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: L10n.tr("home.post.publish"), style: .default) { [weak self] _ in + guard let self else { return } + guard self.feed.indices.contains(index) else { return } + let text = alert.textFields?.first?.text ?? "" + + if self.feed[index].isCustomPublication { + self.feed[index].commentCount += 1 + self.syncCustomFeedItem(self.feed[index]) + self.persistCustomPublications() + self.syncCustomPostToCloud(self.feed[index]) + } else { + let snapshot = self.interactionsStore.addComment( + for: self.feed[index].post.id, + userID: self.currentUserID(), + text: text + ) + self.feed[index].commentCount = snapshot.commentsCount + } + self.tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .none) + }) + present(alert, animated: true) + } + + private func currentUserID() -> String { + FirebaseSessionStorage.shared.user?.email.lowercased() ?? "guest" + } + + private func scheduleNoInternetWarningIfNeeded() { + noInternetWorkItem?.cancel() + guard feed.isEmpty else { return } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.feed.isEmpty else { return } + let now = Date() + if let last = self.lastNoInternetAlertDate, + now.timeIntervalSince(last) < 60 { + return + } + + self.lastNoInternetAlertDate = now + let alert = UIAlertController( + title: L10n.tr("common.error"), + message: L10n.tr("home.error.long_no_internet"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: L10n.tr("common.ok"), style: .default)) + if self.presentedViewController == nil { + self.present(alert, animated: true) + } + } + + noInternetWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 8, execute: work) + } + + private func openStory(at index: Int) { + let viewer = StoryViewerViewController(stories: stories, initialIndex: index) + present(viewer, animated: true) + } + + @objc private func addPostTapped() { + let sheet = UIAlertController( + title: L10n.tr("home.post.create_source"), + message: nil, + preferredStyle: .actionSheet + ) + sheet.addAction(UIAlertAction(title: L10n.tr("home.post.from_gallery"), style: .default) { [weak self] _ in + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.delegate = self + self?.present(picker, animated: true) + }) + sheet.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + if let popover = sheet.popoverPresentationController { + popover.barButtonItem = navigationItem.rightBarButtonItem + } + present(sheet, animated: true) + } + + @objc private func profileTapped() { + if let onOpenProfile { + onOpenProfile() + return + } + + let user = currentUser() + let vm = ProfileViewModel(user: user) + let profileVC = ProfileViewController( + viewModel: vm, + screenMode: .myProfile + ) + navigationController?.pushViewController(profileVC, animated: true) + } + + private func currentUser() -> User? { + let userService = CurrentUserService() + if let email = FirebaseSessionStorage.shared.user?.email, + let user = userService.getUser(login: email) { + return user + } + + return userService.getUser(login: "Wowgorno") + } + + private func presentCreatePostDialog() { + let alert = UIAlertController( + title: L10n.tr("home.post.new_title"), + message: L10n.tr("home.post.new_message"), + preferredStyle: .alert + ) + alert.addTextField { textField in + textField.placeholder = L10n.tr("home.post.description_placeholder") + } + alert.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel) { [weak self] _ in + self?.pendingImageForPost = nil + }) + alert.addAction(UIAlertAction(title: L10n.tr("home.post.publish"), style: .default) { [weak self] _ in + guard let self else { return } + let description = alert.textFields?.first?.text? + .trimmingCharacters(in: .whitespacesAndNewlines) + self.publishNewPost(description: description) + }) + present(alert, animated: true) + } + + private func publishNewPost(description: String?) { + guard let image = pendingImageForPost else { return } + pendingImageForPost = nil + + let text = (description?.isEmpty == false) ? description! : L10n.tr("home.post.new_title") + guard let imageFileName = saveImageToDocuments(image) else { + stateView.apply(.error(L10n.tr("home.error.save_photo"))) + return + } + + let newPost = Post( + author: L10n.tr("common.you"), + description: text, + image: "my_photo", + likes: 0, + views: 0 + ) + + customFeed.insert( + HomeFeedPost( + post: newPost, + avatarURL: nil, + publishedAt: Date(), + localImage: image, + localImageFileName: imageFileName, + remoteImageURL: nil, + likeCount: 0, + commentCount: 0, + shareCount: 0, + isLiked: false, + isCustomPublication: true + ), + at: 0 + ) + enforceCustomPostLimit(maxCount: 3) + + persistCustomPublications() + if let newest = customFeed.first { + syncCustomPostToCloud(newest) + } + rebuildFeed() + } + + private func persistCustomPublications() { + // сохранения сообщений в ленте автора + let stored: [StoredPublication] = Array(customFeed.compactMap { item in + return StoredPublication( + id: item.post.id, + author: item.post.author, + description: item.post.description, + imageFileName: item.localImageFileName, + remoteImageURL: item.remoteImageURL?.absoluteString, + likes: item.likeCount, + views: item.post.views, + comments: item.commentCount, + shares: item.shareCount, + isLiked: item.isLiked + ) + }.prefix(3)) + + guard let data = try? JSONEncoder().encode(stored) else { return } + UserDefaults.standard.set(data, forKey: StorageKeys.customPublications) + } + + private func loadCustomPublications() { + guard + let data = UserDefaults.standard.data(forKey: StorageKeys.customPublications), + let stored = try? JSONDecoder().decode([StoredPublication].self, from: data) + else { return } + + let loaded: [HomeFeedPost] = stored.compactMap { item in + let image = item.imageFileName.flatMap { loadImageFromDocuments(named: $0) } + let post = Post( + id: item.id, + author: item.author, + description: item.description, + image: "my_photo", + likes: item.likes, + views: item.views + ) + return HomeFeedPost( + post: post, + avatarURL: nil, + publishedAt: Date(), + localImage: image, + localImageFileName: item.imageFileName, + remoteImageURL: item.remoteImageURL.flatMap { URL(string: $0) }, + likeCount: item.likes, + commentCount: item.comments, + shareCount: item.shares, + isLiked: item.isLiked, + isCustomPublication: true + ) + } + + customFeed = Array(loaded.prefix(3)) + rebuildFeed() + } + + private func enforceCustomPostLimit(maxCount: Int) { + guard maxCount > 0 else { return } + + guard customFeed.count > maxCount else { return } + + let toRemove = customFeed.dropFirst(maxCount) + toRemove.forEach { item in + if let fileName = item.localImageFileName { + removeImageFromDocuments(named: fileName) + } + removeCustomPostFromCloud(postId: item.post.id) + } + customFeed = Array(customFeed.prefix(maxCount)) + rebuildFeed() + } + + private func makeCloudPost(from item: HomeFeedPost) -> CloudPost { + CloudPost( + id: item.post.id, + author: item.post.author, + description: item.post.description, + likes: item.likeCount, + views: item.post.views, + comments: item.commentCount, + shares: item.shareCount, + isLiked: item.isLiked, + image: item.localImage, + createdAt: Date() + ) + } + + private func syncCustomPostToCloud(_ item: HomeFeedPost) { + guard item.isCustomPublication else { return } + cloudPostsService?.upsert(post: makeCloudPost(from: item), completion: nil) + } + + private func removeCustomPostFromCloud(postId: String) { + cloudPostsService?.delete(postId: postId, completion: nil) + } + + private func documentsDirectory() -> URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + } + + private func saveImageToDocuments(_ image: UIImage) -> String? { + guard let directory = documentsDirectory(), + let data = image.jpegData(compressionQuality: 0.9) else { return nil } + + let fileName = "post_\(UUID().uuidString).jpg" + let fileURL = directory.appendingPathComponent(fileName) + do { + try data.write(to: fileURL) + return fileName + } catch { + return nil + } + } + + private func loadImageFromDocuments(named fileName: String) -> UIImage? { + guard let directory = documentsDirectory() else { return nil } + let fileURL = directory.appendingPathComponent(fileName) + return UIImage(contentsOfFile: fileURL.path) + } + + private func removeImageFromDocuments(named fileName: String) { + guard let directory = documentsDirectory() else { return } + let fileURL = directory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + + private func syncCustomFeedItem(_ updated: HomeFeedPost) { + guard let index = customFeed.firstIndex(where: { $0.post.id == updated.post.id }) else { return } + customFeed[index] = updated + rebuildFeed() + } + + private func presentEditPostDialog(postId: String) { + guard let currentIndex = feed.firstIndex(where: { $0.post.id == postId }) else { return } + let item = feed[currentIndex] + guard item.isCustomPublication else { return } + + let alert = UIAlertController( + title: L10n.tr("home.post.edit_title"), + message: nil, + preferredStyle: .alert + ) + alert.addTextField { textField in + textField.placeholder = L10n.tr("home.post.description_placeholder") + textField.text = item.post.description + } + alert.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: L10n.tr("common.save"), style: .default) { [weak self] _ in + guard let self else { return } + guard let updateIndex = self.feed.firstIndex(where: { $0.post.id == postId }) else { return } + + let newDescription = alert.textFields?.first?.text? + .trimmingCharacters(in: .whitespacesAndNewlines) + let text = (newDescription?.isEmpty == false) ? newDescription! : self.feed[updateIndex].post.description + + let oldPost = self.feed[updateIndex].post + let updatedPost = Post( + id: oldPost.id, + author: oldPost.author, + description: text, + image: oldPost.image, + likes: self.feed[updateIndex].likeCount, + views: oldPost.views + ) + self.feed[updateIndex].post = updatedPost + self.syncCustomFeedItem(self.feed[updateIndex]) + self.persistCustomPublications() + self.syncCustomPostToCloud(self.feed[updateIndex]) + self.tableView.reloadRows(at: [IndexPath(row: updateIndex, section: 0)], with: .automatic) + }) + present(alert, animated: true) + } + + private func deleteCustomPost(postId: String) { + guard let item = feed.first(where: { $0.post.id == postId }) else { return } + guard item.isCustomPublication else { return } + + if let fileName = item.localImageFileName { + removeImageFromDocuments(named: fileName) + } + + removeCustomPostFromCloud(postId: item.post.id) + customFeed.removeAll { $0.post.id == item.post.id } + persistCustomPublications() + rebuildFeed() + } +} + +extension HomeViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + isSkeletonLoading ? 4 : feed.count + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: HomePostCell.identifier, + for: indexPath + ) as! HomePostCell + + if isSkeletonLoading { + cell.configureSkeleton() + cell.onLikeTap = nil + cell.onCommentTap = nil + cell.onShareTap = nil + return cell + } + + cell.configure(with: feed[indexPath.row]) + cell.onLikeTap = { [weak self] in + self?.toggleLike(at: indexPath.row) + } + cell.onCommentTap = { [weak self] in + self?.addComment(at: indexPath.row) + } + cell.onShareTap = { [weak self] in + self?.sharePost(at: indexPath.row) + } + + return cell + } +} + +extension HomeViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard !isSkeletonLoading else { return } + tableView.deselectRow(at: indexPath, animated: true) + guard feed.indices.contains(indexPath.row) else { return } + let item = feed[indexPath.row] + guard item.isCustomPublication else { return } + + let sheet = UIAlertController( + title: L10n.tr("home.post.sheet_title"), + message: nil, + preferredStyle: .actionSheet + ) + sheet.addAction(UIAlertAction(title: L10n.tr("home.post.edit_description"), style: .default) { [weak self] _ in + self?.presentEditPostDialog(postId: item.post.id) + }) + sheet.addAction(UIAlertAction(title: L10n.tr("home.post.delete"), style: .destructive) { [weak self] _ in + self?.deleteCustomPost(postId: item.post.id) + }) + sheet.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + + if let popover = sheet.popoverPresentationController, + let cell = tableView.cellForRow(at: indexPath) { + popover.sourceView = cell + popover.sourceRect = cell.bounds + } + present(sheet, animated: true) + } + + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + guard feed.indices.contains(indexPath.row), feed[indexPath.row].isCustomPublication else { + return nil + } + let postId = feed[indexPath.row].post.id + + let delete = UIContextualAction(style: .destructive, title: L10n.tr("common.delete")) { [weak self] _, _, completion in + self?.deleteCustomPost(postId: postId) + completion(true) + } + + let edit = UIContextualAction(style: .normal, title: L10n.tr("common.edit")) { [weak self] _, _, completion in + self?.presentEditPostDialog(postId: postId) + completion(true) + } + edit.backgroundColor = StyleGuide.Colors.accent + + return UISwipeActionsConfiguration(actions: [delete, edit]) + } +} + +extension HomeViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] + ) { + picker.dismiss(animated: true) + + guard let image = info[.originalImage] as? UIImage else { + stateView.apply(.error(L10n.tr("home.error.load_photo"))) + return + } + + pendingImageForPost = image + presentCreatePostDialog() + } +} diff --git a/Navigation/Screens/Home/StoriesHeaderView.swift b/Navigation/Screens/Home/StoriesHeaderView.swift new file mode 100644 index 0000000..6279258 --- /dev/null +++ b/Navigation/Screens/Home/StoriesHeaderView.swift @@ -0,0 +1,123 @@ +import UIKit + +struct StoryItem { + let id: String + let name: String + let imageName: String +} + +final class StoriesHeaderView: UIView { + private let scrollView = UIScrollView() + private let stackView = UIStackView() + + private var stories: [StoryItem] = [] + var onStoryTap: ((StoryItem, Int) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with stories: [StoryItem]) { + self.stories = stories + reloadStories() + } + + private func setupUI() { + backgroundColor = StyleGuide.Colors.backgroundPrimary + + scrollView.showsHorizontalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + + stackView.axis = .horizontal + stackView.spacing = 12 + stackView.alignment = .top + stackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(scrollView) + scrollView.addSubview(stackView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + } + + private func reloadStories() { + stackView.arrangedSubviews.forEach { view in + stackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + stories.forEach { item in + let control = makeStoryControl(for: item) + stackView.addArrangedSubview(control) + } + } + + private func makeStoryControl(for item: StoryItem) -> UIControl { + let control = UIControl() + control.translatesAutoresizingMaskIntoConstraints = false + control.widthAnchor.constraint(equalToConstant: 76).isActive = true + control.tag = stories.firstIndex(where: { $0.id == item.id }) ?? 0 + + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 30 + imageView.layer.borderWidth = 2 + imageView.layer.borderColor = StyleGuide.Colors.accent.cgColor + imageView.translatesAutoresizingMaskIntoConstraints = false + + if let image = UIImage(named: item.imageName) { + imageView.image = image + } else { + imageView.image = UIImage(systemName: "person.crop.circle.fill") + imageView.tintColor = StyleGuide.Colors.accent + imageView.backgroundColor = StyleGuide.Colors.backgroundSecondary + } + + let nameLabel = UILabel() + nameLabel.text = item.name + nameLabel.font = StyleGuide.Fonts.caption(11, weight: .regular) + nameLabel.textColor = StyleGuide.Colors.textPrimary + nameLabel.textAlignment = .center + nameLabel.numberOfLines = 2 + nameLabel.translatesAutoresizingMaskIntoConstraints = false + + control.addSubview(imageView) + control.addSubview(nameLabel) + + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: control.topAnchor, constant: 4), + imageView.centerXAnchor.constraint(equalTo: control.centerXAnchor), + imageView.widthAnchor.constraint(equalToConstant: 60), + imageView.heightAnchor.constraint(equalToConstant: 60), + + nameLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 6), + nameLabel.leadingAnchor.constraint(equalTo: control.leadingAnchor), + nameLabel.trailingAnchor.constraint(equalTo: control.trailingAnchor), + nameLabel.bottomAnchor.constraint(lessThanOrEqualTo: control.bottomAnchor, constant: -4) + ]) + + control.addTarget(self, action: #selector(handleStoryTap(_:)), for: .touchUpInside) + return control + } + + @objc private func handleStoryTap(_ sender: UIControl) { + guard sender.tag < stories.count else { return } + onStoryTap?(stories[sender.tag], sender.tag) + } +} diff --git a/Navigation/Screens/Home/StoryViewerViewController.swift b/Navigation/Screens/Home/StoryViewerViewController.swift new file mode 100644 index 0000000..f9df703 --- /dev/null +++ b/Navigation/Screens/Home/StoryViewerViewController.swift @@ -0,0 +1,138 @@ +import UIKit + +final class StoryViewerViewController: UIViewController { + private let stories: [StoryItem] + private let initialIndex: Int + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.isPagingEnabled = true + view.showsHorizontalScrollIndicator = false + view.backgroundColor = .black + view.dataSource = self + view.delegate = self + view.register(StoryViewerCell.self, forCellWithReuseIdentifier: StoryViewerCell.identifier) + return view + }() + + init(stories: [StoryItem], initialIndex: Int) { + self.stories = stories + self.initialIndex = initialIndex + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + + let closeButton = UIButton(type: .system) + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + closeButton.tintColor = .white + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + view.addSubview(closeButton) + + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + closeButton.widthAnchor.constraint(equalToConstant: 32), + closeButton.heightAnchor.constraint(equalToConstant: 32) + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let indexPath = IndexPath(item: initialIndex, section: 0) + if collectionView.indexPathsForVisibleItems.isEmpty, stories.indices.contains(initialIndex) { + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) + } + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} + +extension StoryViewerViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + stories.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StoryViewerCell.identifier, + for: indexPath + ) as! StoryViewerCell + cell.configure(with: stories[indexPath.item]) + return cell + } +} + +extension StoryViewerViewController: UICollectionViewDelegateFlowLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + collectionView.bounds.size + } +} + +private final class StoryViewerCell: UICollectionViewCell { + static let identifier = "StoryViewerCell" + + private let imageView = UIImageView() + private let nameLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + + nameLabel.font = StyleGuide.Fonts.body(18, weight: .semibold) + nameLabel.textColor = .white + nameLabel.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(imageView) + contentView.addSubview(nameLabel) + + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 40), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + nameLabel.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 10), + nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with story: StoryItem) { + nameLabel.text = story.name + imageView.image = UIImage(named: story.imageName) + } +} diff --git a/Navigation/Screens/Menu/MenuViewController.swift b/Navigation/Screens/Menu/MenuViewController.swift new file mode 100644 index 0000000..d53013e --- /dev/null +++ b/Navigation/Screens/Menu/MenuViewController.swift @@ -0,0 +1,116 @@ +import UIKit + +final class MenuViewController: UIViewController { + enum MenuAction: CaseIterable { + case profile + case favorites + case files + case settings + case posts + case info + + var title: String { + switch self { + case .profile: return L10n.tr("menu.profile") + case .favorites: return L10n.tr("menu.favorites") + case .files: return L10n.tr("menu.files") + case .settings: return L10n.tr("menu.settings") + case .posts: return L10n.tr("menu.posts") + case .info: return L10n.tr("menu.info") + } + } + + var icon: String { + switch self { + case .profile: return "person.crop.circle" + case .favorites: return "heart" + case .files: return "folder" + case .settings: return "gearshape" + case .posts: return "doc.text.image" + case .info: return "info.circle" + } + } + } + + var onAction: ((MenuAction) -> Void)? + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let stateView = ScreenStateView() + + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("tab.menu") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupTableView() + setupStateView() + stateView.onRetry = { [weak self] in + self?.loadData() + } + loadData() + } + + private func setupTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "menuCell") + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func setupStateView() { + stateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stateView) + NSLayoutConstraint.activate([ + stateView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stateView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func loadData() { + stateView.apply(.loading(L10n.tr("menu.state.loading"))) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + if MenuAction.allCases.isEmpty { + self.stateView.apply(.empty(L10n.tr("menu.state.empty"))) + } else { + self.tableView.reloadData() + self.stateView.apply(.content) + } + } + } +} + +extension MenuViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + MenuAction.allCases.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "menuCell", for: indexPath) + let action = MenuAction.allCases[indexPath.row] + + var config = cell.defaultContentConfiguration() + config.text = action.title + config.image = UIImage(systemName: action.icon) + config.imageProperties.tintColor = StyleGuide.Colors.textSecondary + cell.contentConfiguration = config + cell.accessoryType = .disclosureIndicator + + return cell + } +} + +extension MenuViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + onAction?(MenuAction.allCases[indexPath.row]) + } +} diff --git a/Navigation/Screens/Password/PasswordState.swift b/Navigation/Screens/Password/PasswordState.swift new file mode 100644 index 0000000..cb18d1c --- /dev/null +++ b/Navigation/Screens/Password/PasswordState.swift @@ -0,0 +1,12 @@ +// +// PasswordState.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/14/26. +// + +enum PasswordState { + case create + case repeatPassword(first: String) + case enter(saved: String) +} diff --git a/Navigation/Screens/Password/PasswordViewController.swift b/Navigation/Screens/Password/PasswordViewController.swift new file mode 100644 index 0000000..6c9a3c2 --- /dev/null +++ b/Navigation/Screens/Password/PasswordViewController.swift @@ -0,0 +1,141 @@ +// +// PasswordViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/13/26. +// + +import UIKit + +final class PasswordViewController: UIViewController { + + // MARK: - Callback + var onSuccess: (() -> Void)? + + // MARK: - UI + + private let passwordTextField: UITextField = { + let tf = UITextField() + tf.placeholder = L10n.tr("password.enter") + tf.isSecureTextEntry = true + tf.borderStyle = .roundedRect + tf.translatesAutoresizingMaskIntoConstraints = false + return tf + }() + + private let actionButton: UIButton = { + let btn = UIButton(type: .system) + btn.titleLabel?.font = StyleGuide.Fonts.title(18) + btn.translatesAutoresizingMaskIntoConstraints = false + return btn + }() + + private let errorLabel: UILabel = { + let label = UILabel() + label.textColor = StyleGuide.Colors.danger + label.font = StyleGuide.Fonts.caption(14, weight: .regular) + label.textAlignment = .center + label.numberOfLines = 0 + label.isHidden = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + // MARK: - State + + private var state: PasswordState! + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + setupLayout() + setupInitialState() + actionButton.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + // MARK: - Setup + + private func setupInitialState() { + if let savedPassword = KeychainService.shared.getPassword() { + state = .enter(saved: savedPassword) + actionButton.setTitle(L10n.tr("password.enter"), for: .normal) + } else { + state = .create + actionButton.setTitle(L10n.tr("password.create"), for: .normal) + } + } + + // MARK: - Actions + + @objc private func buttonTapped() { + errorLabel.isHidden = true + + guard let text = passwordTextField.text, text.count >= 4 else { + showError(L10n.tr("password.error.too_short")) + return + } + + switch state { + + case .create: + state = .repeatPassword(first: text) + passwordTextField.text = "" + actionButton.setTitle(L10n.tr("password.repeat"), for: .normal) + + case .repeatPassword(let first): + if first == text { + KeychainService.shared.savePassword(text) + print(L10n.tr("password.log.saved")) + onSuccess?() // ПЕРЕХОД ДАЛЬШЕ + } else { + showError(L10n.tr("password.error.mismatch")) + resetToCreate() + } + + case .enter(let saved): + if text == saved { + print(L10n.tr("password.log.correct")) + onSuccess?() // ПЕРЕХОД ДАЛЬШЕ + } else { + showError(L10n.tr("password.error.wrong")) + passwordTextField.text = "" + } + case .none: + break + } + } + + // MARK: - Helpers + + private func resetToCreate() { + state = .create + passwordTextField.text = "" + actionButton.setTitle(L10n.tr("password.create"), for: .normal) + } + + private func showError(_ message: String) { + errorLabel.text = message + errorLabel.isHidden = false + } + + private func setupLayout() { + view.addSubview(passwordTextField) + view.addSubview(actionButton) + view.addSubview(errorLabel) + + NSLayoutConstraint.activate([ + passwordTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor), + passwordTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + passwordTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + + actionButton.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 20), + actionButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + errorLabel.topAnchor.constraint(equalTo: actionButton.bottomAnchor, constant: 12), + errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32) + ]) + } +} diff --git a/Navigation/Screens/ScreenStateView.swift b/Navigation/Screens/ScreenStateView.swift new file mode 100644 index 0000000..a11e97b --- /dev/null +++ b/Navigation/Screens/ScreenStateView.swift @@ -0,0 +1,91 @@ +import UIKit + +enum ScreenState { + case loading(String) + case empty(String) + case error(String) + case content +} + +final class ScreenStateView: UIView { + private let activityIndicator = UIActivityIndicatorView(style: .large) + private let messageLabel = UILabel() + private let retryButton = UIButton(type: .system) + private let stackView = UIStackView() + + var onRetry: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(_ state: ScreenState) { + switch state { + case .loading(let message): + isHidden = false + activityIndicator.startAnimating() + messageLabel.text = message + retryButton.isHidden = true + + case .empty(let message): + isHidden = false + activityIndicator.stopAnimating() + messageLabel.text = message + retryButton.isHidden = true + + case .error(let message): + isHidden = false + activityIndicator.stopAnimating() + messageLabel.text = message + retryButton.isHidden = false + + case .content: + activityIndicator.stopAnimating() + isHidden = true + } + } + + private func setupUI() { + backgroundColor = StyleGuide.Colors.backgroundPrimary + + stackView.axis = .vertical + stackView.spacing = 12 + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + + activityIndicator.hidesWhenStopped = true + + messageLabel.font = StyleGuide.Fonts.body(16, weight: .medium) + messageLabel.textColor = StyleGuide.Colors.textSecondary + messageLabel.numberOfLines = 0 + messageLabel.textAlignment = .center + + retryButton.setTitle(L10n.tr("common.retry"), for: .normal) + retryButton.titleLabel?.font = StyleGuide.Fonts.body(16, weight: .semibold) + retryButton.tintColor = StyleGuide.Colors.accent + retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + + addSubview(stackView) + stackView.addArrangedSubview(activityIndicator) + stackView.addArrangedSubview(messageLabel) + stackView.addArrangedSubview(retryButton) + + NSLayoutConstraint.activate([ + stackView.centerXAnchor.constraint(equalTo: centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -24) + ]) + + isHidden = true + } + + @objc private func retryTapped() { + onRetry?() + } +} diff --git a/Navigation/Screens/Search/SearchViewController.swift b/Navigation/Screens/Search/SearchViewController.swift new file mode 100644 index 0000000..9e17bbb --- /dev/null +++ b/Navigation/Screens/Search/SearchViewController.swift @@ -0,0 +1,235 @@ +import UIKit +import AVFoundation + +final class SearchViewController: UIViewController { + private let tableView = UITableView(frame: .zero, style: .plain) + private let stateView = ScreenStateView() + private let segmentedControl = UISegmentedControl(items: [ + L10n.tr("search.segment.music") + ]) + + private let playerContainer = UIView() + private let playerTitleLabel = UILabel() + private let playerSubtitleLabel = UILabel() + private let playPauseButton = UIButton(type: .system) + + private var player: AVPlayer? + private var isPlaying = false + + private let viewModel: SearchViewModel + + init(viewModel: SearchViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + convenience init() { + self.init(viewModel: SearchViewModel()) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("tab.search") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + + setupSearch() + setupSegment() + setupPlayer() + setupTableView() + setupStateView() + bindViewModel() + + viewModel.load() + } + + private func setupSearch() { + let searchController = UISearchController(searchResultsController: nil) + searchController.searchBar.placeholder = L10n.tr("search.placeholder") + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchResultsUpdater = self + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + } + + private func setupSegment() { + segmentedControl.selectedSegmentIndex = 0 + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + segmentedControl.addTarget(self, action: #selector(segmentChanged), for: .valueChanged) + view.addSubview(segmentedControl) + + NSLayoutConstraint.activate([ + segmentedControl.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + segmentedControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + segmentedControl.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + ]) + } + + private func setupTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.dataSource = self + tableView.delegate = self + tableView.backgroundColor = StyleGuide.Colors.backgroundPrimary + tableView.separatorColor = StyleGuide.Colors.border + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 8), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: playerContainer.topAnchor, constant: -8) + ]) + } + + private func setupPlayer() { + playerContainer.translatesAutoresizingMaskIntoConstraints = false + playerContainer.backgroundColor = StyleGuide.Colors.backgroundSecondary + playerContainer.layer.cornerRadius = 12 + playerContainer.isHidden = true + + playerTitleLabel.font = StyleGuide.Fonts.body(14, weight: .semibold) + playerTitleLabel.textColor = StyleGuide.Colors.textPrimary + playerTitleLabel.translatesAutoresizingMaskIntoConstraints = false + + playerSubtitleLabel.font = StyleGuide.Fonts.caption(12, weight: .regular) + playerSubtitleLabel.textColor = StyleGuide.Colors.textSecondary + playerSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false + + playPauseButton.setTitle(L10n.tr("search.player.play"), for: .normal) + playPauseButton.titleLabel?.font = StyleGuide.Fonts.body(14, weight: .semibold) + playPauseButton.tintColor = StyleGuide.Colors.accent + playPauseButton.translatesAutoresizingMaskIntoConstraints = false + playPauseButton.addTarget(self, action: #selector(playPauseTapped), for: .touchUpInside) + + view.addSubview(playerContainer) + playerContainer.addSubview(playerTitleLabel) + playerContainer.addSubview(playerSubtitleLabel) + playerContainer.addSubview(playPauseButton) + + NSLayoutConstraint.activate([ + playerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12), + playerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), + playerContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8), + playerContainer.heightAnchor.constraint(equalToConstant: 70), + + playerTitleLabel.topAnchor.constraint(equalTo: playerContainer.topAnchor, constant: 10), + playerTitleLabel.leadingAnchor.constraint(equalTo: playerContainer.leadingAnchor, constant: 12), + playerTitleLabel.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -12), + + playerSubtitleLabel.topAnchor.constraint(equalTo: playerTitleLabel.bottomAnchor, constant: 4), + playerSubtitleLabel.leadingAnchor.constraint(equalTo: playerTitleLabel.leadingAnchor), + playerSubtitleLabel.trailingAnchor.constraint(equalTo: playerTitleLabel.trailingAnchor), + + playPauseButton.trailingAnchor.constraint(equalTo: playerContainer.trailingAnchor, constant: -12), + playPauseButton.centerYAnchor.constraint(equalTo: playerContainer.centerYAnchor), + playPauseButton.widthAnchor.constraint(equalToConstant: 90) + ]) + } + + private func setupStateView() { + stateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stateView) + NSLayoutConstraint.activate([ + stateView.topAnchor.constraint(equalTo: tableView.topAnchor), + stateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stateView.bottomAnchor.constraint(equalTo: tableView.bottomAnchor) + ]) + + stateView.onRetry = { [weak self] in + self?.viewModel.load() + } + } + + private func bindViewModel() { + viewModel.onStateChange = { [weak self] state in + self?.stateView.apply(state) + } + + viewModel.onItemsChange = { [weak self] in + self?.tableView.reloadData() + } + + viewModel.onTrackSelected = { [weak self] track in + self?.play(track: track) + } + } + + private func play(track: MusicTrack?) { + guard let track else { + player?.pause() + player = nil + isPlaying = false + playerContainer.isHidden = true + return + } + + player = AVPlayer(url: track.previewURL) + isPlaying = false + + playerTitleLabel.text = track.title + playerSubtitleLabel.text = track.artist + playPauseButton.setTitle(L10n.tr("search.player.play"), for: .normal) + playerContainer.isHidden = false + } + + @objc private func playPauseTapped() { + guard let player else { return } + + if isPlaying { + player.pause() + playPauseButton.setTitle(L10n.tr("search.player.play"), for: .normal) + } else { + player.play() + playPauseButton.setTitle(L10n.tr("search.player.pause"), for: .normal) + } + isPlaying.toggle() + } + + @objc private func segmentChanged() { + viewModel.updateSegment(index: segmentedControl.selectedSegmentIndex) + } +} + +extension SearchViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + viewModel.updateSearch(text: searchController.searchBar.text ?? "") + } +} + +extension SearchViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.items.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + let item = viewModel.items[indexPath.row] + + var config = cell.defaultContentConfiguration() + config.textProperties.color = StyleGuide.Colors.textPrimary + config.secondaryTextProperties.color = StyleGuide.Colors.textSecondary + + if case .track(let track) = item { + config.text = track.title + config.secondaryText = "\(track.artist) • \(L10n.tr("search.track.preview"))" + config.image = UIImage(systemName: "music.note") + cell.accessoryType = .disclosureIndicator + } + + cell.contentConfiguration = config + return cell + } +} + +extension SearchViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard viewModel.items.indices.contains(indexPath.row) else { return } + viewModel.selectItem(at: indexPath.row) + } +} diff --git a/Navigation/Screens/Search/SearchViewModel.swift b/Navigation/Screens/Search/SearchViewModel.swift new file mode 100644 index 0000000..d10542a --- /dev/null +++ b/Navigation/Screens/Search/SearchViewModel.swift @@ -0,0 +1,86 @@ +import Foundation + +final class SearchViewModel { + enum Segment: Int { + case music + } + + enum Item: Equatable { + case track(MusicTrack) + } + + private let musicService: MusicCatalogServiceProtocol + + private var allTracks: [MusicTrack] = [] + private var searchText: String = "" + private(set) var segment: Segment = .music + + private(set) var items: [Item] = [] + private(set) var selectedTrack: MusicTrack? + + var onStateChange: ((ScreenState) -> Void)? + var onItemsChange: (() -> Void)? + var onTrackSelected: ((MusicTrack?) -> Void)? + + init(musicService: MusicCatalogServiceProtocol = MusicCatalogService()) { + self.musicService = musicService + } + + func load() { + Task { + await MainActor.run { + self.onStateChange?(.loading(L10n.tr("search.state.loading"))) + } + let tracks: [MusicTrack] = (try? await musicService.fetchTracks(query: "top hits 2026", limit: 50)) ?? [] + + await MainActor.run { + self.allTracks = tracks + self.applyFilterAndRefresh() + } + } + } + + func updateSegment(index: Int) { + segment = Segment(rawValue: index) ?? .music + applyFilterAndRefresh() + } + + func updateSearch(text: String) { + searchText = text.trimmingCharacters(in: .whitespacesAndNewlines) + applyFilterAndRefresh() + } + + func selectItem(at index: Int) { + guard items.indices.contains(index) else { return } + if case .track(let track) = items[index] { + selectedTrack = track + onTrackSelected?(track) + } + } + + private func applyFilterAndRefresh() { + let normalized = searchText.lowercased() + let filtered: [MusicTrack] + if normalized.isEmpty { + filtered = allTracks + } else { + filtered = allTracks.filter { + $0.title.lowercased().contains(normalized) + || $0.artist.lowercased().contains(normalized) + } + } + + items = filtered.map { .track($0) } + + if let current = selectedTrack, + filtered.contains(where: { $0.id == current.id }) { + onTrackSelected?(current) + } else { + selectedTrack = filtered.first + onTrackSelected?(selectedTrack) + } + + onItemsChange?() + onStateChange?(items.isEmpty ? .empty(L10n.tr("search.state.empty")) : .content) + } +} diff --git a/Navigation/Screens/Settings/SettingsViewController.swift b/Navigation/Screens/Settings/SettingsViewController.swift new file mode 100644 index 0000000..a139b86 --- /dev/null +++ b/Navigation/Screens/Settings/SettingsViewController.swift @@ -0,0 +1,152 @@ +// +// SettingsViewController.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/13/26. +// + +import UIKit + +extension Notification.Name { + static let appDidRequestLogout = Notification.Name("appDidRequestLogout") +} + +final class SettingsViewController: UITableViewController { + + // MARK: - Callbacks (для Coordinator) + var onChangePassword: (() -> Void)? + var onLogout: (() -> Void)? + + // MARK: - Sections + enum Section: Int, CaseIterable { + case appearance + case sorting + case security + case account + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + title = L10n.tr("settings.title") + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + } + + // MARK: - Table DataSource + override func numberOfSections(in tableView: UITableView) -> Int { + Section.allCases.count + } + + override func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + 1 + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + cell.accessoryType = .none + cell.accessoryView = nil + cell.selectionStyle = .default + cell.textLabel?.textAlignment = .natural + cell.textLabel?.textColor = StyleGuide.Colors.textPrimary + + switch Section(rawValue: indexPath.section)! { + case .appearance: + cell.textLabel?.text = L10n.tr("settings.theme") + + let segmented = UISegmentedControl(items: [ + L10n.tr("theme.system"), + L10n.tr("theme.light"), + L10n.tr("theme.dark") + ]) + segmented.selectedSegmentIndex = SettingsStorage.shared.themeMode.rawValue + segmented.addTarget(self, action: #selector(themeChanged(_:)), for: .valueChanged) + cell.accessoryView = segmented + cell.selectionStyle = .none + cell.textLabel?.textAlignment = .natural + cell.textLabel?.textColor = StyleGuide.Colors.textPrimary + + case .sorting: + cell.textLabel?.text = L10n.tr("settings.sort") + + let toggle = UISwitch() + toggle.isOn = SettingsStorage.shared.isAscending + toggle.addTarget(self, action: #selector(sortChanged(_:)), for: .valueChanged) + cell.accessoryView = toggle + cell.selectionStyle = .none + + case .security: + cell.textLabel?.text = L10n.tr("settings.change_password") + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + + case .account: + cell.textLabel?.text = L10n.tr("settings.logout") + cell.textLabel?.textColor = StyleGuide.Colors.danger + cell.textLabel?.textAlignment = .center + cell.selectionStyle = .default + } + + return cell + } + + // MARK: - Table Delegate + override func tableView( + _ tableView: UITableView, + didSelectRowAt indexPath: IndexPath + ) { + tableView.deselectRow(at: indexPath, animated: true) + + switch Section(rawValue: indexPath.section)! { + case .appearance: + break + + case .sorting: + break + + case .security: + onChangePassword?() + + case .account: + presentLogoutConfirmation() + } + } + + // MARK: - Actions + @objc private func sortChanged(_ sender: UISwitch) { + SettingsStorage.shared.isAscending = sender.isOn + } + + @objc private func themeChanged(_ sender: UISegmentedControl) { + guard let mode = AppThemeMode(rawValue: sender.selectedSegmentIndex) else { return } + SettingsStorage.shared.themeMode = mode + } + + private func presentLogoutConfirmation() { + let alert = UIAlertController( + title: L10n.tr("settings.logout"), + message: L10n.tr("settings.logout.confirm_message"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: L10n.tr("common.cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: L10n.tr("settings.logout"), style: .destructive) { [weak self] _ in + self?.performLogout() + }) + present(alert, animated: true) + } + + private func performLogout() { + FirebaseSessionStorage.shared.clear() + if let onLogout { + onLogout() + } else { + NotificationCenter.default.post(name: .appDidRequestLogout, object: nil) + } + } +} diff --git a/Navigation/Services/API/CelebritiesService.swift b/Navigation/Services/API/CelebritiesService.swift new file mode 100644 index 0000000..a7d5d9f --- /dev/null +++ b/Navigation/Services/API/CelebritiesService.swift @@ -0,0 +1,114 @@ +import Foundation + +struct Celebrity: Equatable { + let id: Int + let name: String + let country: String? + let imageURL: URL? + let portfolio: [String] +} + +protocol CelebritiesServiceProtocol { + func fetchCelebrities(limit: Int) async throws -> [Celebrity] +} + +final class CelebritiesService: CelebritiesServiceProtocol { + private struct PersonResponse: Decodable { + struct Country: Decodable { + let name: String? + } + + struct ImageData: Decodable { + let medium: String? + let original: String? + } + + let id: Int + let name: String + let country: Country? + let image: ImageData? + } + + private struct CastCreditResponse: Decodable { + struct Embedded: Decodable { + struct Show: Decodable { + let name: String + } + + let show: Show? + } + + let embedded: Embedded? + + private enum CodingKeys: String, CodingKey { + case embedded = "_embedded" + } + } + + private let session: URLSession + private let decoder: JSONDecoder + + init(session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) { + self.session = session + self.decoder = decoder + } + + func fetchCelebrities(limit: Int) async throws -> [Celebrity] { + guard limit > 0 else { return [] } + + let peopleURL = URL(string: "https://api.tvmaze.com/people?page=1")! + let people: [PersonResponse] = try await fetch(type: [PersonResponse].self, from: peopleURL) + let selected = Array(people.prefix(limit)) + + return try await withThrowingTaskGroup(of: Celebrity.self) { group in + for person in selected { + group.addTask { [weak self] in + let portfolio = try await self?.fetchPortfolio(for: person.id) ?? [] + return Celebrity( + id: person.id, + name: person.name, + country: person.country?.name, + imageURL: URL(string: person.image?.medium ?? person.image?.original ?? ""), + portfolio: portfolio + ) + } + } + + var result: [Celebrity] = [] + for try await celebrity in group { + result.append(celebrity) + } + + let idToIndex = Dictionary(uniqueKeysWithValues: selected.enumerated().map { ($1.id, $0) }) + return result.sorted { (idToIndex[$0.id] ?? 0) < (idToIndex[$1.id] ?? 0) } + } + } + + private func fetchPortfolio(for personId: Int) async throws -> [String] { + let url = URL(string: "https://api.tvmaze.com/people/\(personId)/castcredits?embed=show")! + let credits: [CastCreditResponse] = try await fetch(type: [CastCreditResponse].self, from: url) + + var seen = Set() + var portfolio: [String] = [] + + for credit in credits { + guard let title = credit.embedded?.show?.name, !title.isEmpty else { continue } + if seen.insert(title).inserted { + portfolio.append(title) + } + if portfolio.count == 3 { break } + } + + return portfolio + } + + private func fetch(type: T.Type, from url: URL) async throws -> T { + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + return try decoder.decode(T.self, from: data) + } +} diff --git a/Navigation/Services/API/MusicCatalogService.swift b/Navigation/Services/API/MusicCatalogService.swift new file mode 100644 index 0000000..487cec2 --- /dev/null +++ b/Navigation/Services/API/MusicCatalogService.swift @@ -0,0 +1,73 @@ +import Foundation + +struct MusicTrack: Equatable { + let id: Int + let title: String + let artist: String + let previewURL: URL + let artworkURL: URL? +} + +protocol MusicCatalogServiceProtocol { + func fetchTracks(query: String, limit: Int) async throws -> [MusicTrack] +} + +final class MusicCatalogService: MusicCatalogServiceProtocol { + private struct SearchResponse: Decodable { + struct TrackResponse: Decodable { + let trackId: Int + let trackName: String + let artistName: String + let previewUrl: String? + let artworkUrl100: String? + } + + let results: [TrackResponse] + } + + private let session: URLSession + private let decoder: JSONDecoder + + init(session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) { + self.session = session + self.decoder = decoder + } + + func fetchTracks(query: String, limit: Int) async throws -> [MusicTrack] { + guard limit > 0 else { return [] } + + var components = URLComponents(string: "https://itunes.apple.com/search")! + components.queryItems = [ + URLQueryItem(name: "term", value: query), + URLQueryItem(name: "media", value: "music"), + URLQueryItem(name: "entity", value: "song"), + URLQueryItem(name: "limit", value: String(limit)) + ] + + guard let url = components.url else { + throw URLError(.badURL) + } + + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + let payload = try decoder.decode(SearchResponse.self, from: data) + return payload.results.compactMap { item in + guard let previewUrl = item.previewUrl, + let previewURL = URL(string: previewUrl) else { + return nil + } + + return MusicTrack( + id: item.trackId, + title: item.trackName, + artist: item.artistName, + previewURL: previewURL, + artworkURL: URL(string: item.artworkUrl100 ?? "") + ) + } + } +} diff --git a/Navigation/Services/CheckerService/CheckerService.swift b/Navigation/Services/CheckerService/CheckerService.swift new file mode 100644 index 0000000..b51d58e --- /dev/null +++ b/Navigation/Services/CheckerService/CheckerService.swift @@ -0,0 +1,87 @@ +// +// CheckerService.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12/24/25. +// + +import Foundation + +/// Service responsible for email/password authentication via Firebase. +final class CheckerService: CheckerServiceProtocol { + private let authService: FirebaseAuthServiceProtocol + private let userProfileService: FirebaseUserProfileServiceProtocol + private let sessionStorage: FirebaseSessionStorage + + init( + authService: FirebaseAuthServiceProtocol = FirebaseAuthRESTService(), + userProfileService: FirebaseUserProfileServiceProtocol = FirebaseUserProfileService(), + sessionStorage: FirebaseSessionStorage = .shared + ) { + self.authService = authService + self.userProfileService = userProfileService + self.sessionStorage = sessionStorage + } + + func checkCredentials( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) { + guard !email.isEmpty, !password.isEmpty else { + completion(.failure(NSError( + domain: "Auth", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Invalid credentials"] + ))) + return + } + + Task { + do { + let session = try await authService.signIn(email: email, password: password) + sessionStorage.store(session: session) + try? await userProfileService.upsertUserProfile(user: session.user, idToken: session.idToken) + await MainActor.run { + completion(.success(())) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } + + func signUp( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) { + guard !email.isEmpty, !password.isEmpty else { + completion(.failure(NSError( + domain: "Auth", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Invalid credentials"] + ))) + return + } + + Task { + do { + let session = try await authService.signUp(email: email, password: password) + sessionStorage.store(session: session) + // Профиль в Firestore пишем best-effort: + // если запись не удалась, регистрацию не считаем проваленной. + try? await userProfileService.upsertUserProfile(user: session.user, idToken: session.idToken) + await MainActor.run { + completion(.success(())) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } +} diff --git a/Navigation/Services/CheckerService/CheckerServiceProtocol.swift b/Navigation/Services/CheckerService/CheckerServiceProtocol.swift new file mode 100644 index 0000000..e397ff1 --- /dev/null +++ b/Navigation/Services/CheckerService/CheckerServiceProtocol.swift @@ -0,0 +1,20 @@ +// +// CheckerServiceProtocol.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 12/24/25. +// + +protocol CheckerServiceProtocol { + func checkCredentials( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) + + func signUp( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) +} diff --git a/Navigation/Services/CloudPostsService.swift b/Navigation/Services/CloudPostsService.swift new file mode 100644 index 0000000..ed911d1 --- /dev/null +++ b/Navigation/Services/CloudPostsService.swift @@ -0,0 +1,254 @@ +import CloudKit +import UIKit + +struct CloudPost { + let id: String + let author: String + let description: String + let likes: Int + let views: Int + let comments: Int + let shares: Int + let isLiked: Bool + let image: UIImage? + let createdAt: Date +} + +/// Cloud-backed store for user publications shared across devices/simulators. +final class CloudPostsService { + private enum Constants { + static let recordType = "UserPost" + } + + private enum Keys { + static let postId = "postId" + static let author = "author" + static let description = "description" + static let likes = "likes" + static let views = "views" + static let comments = "comments" + static let shares = "shares" + static let isLiked = "isLiked" + static let imageAsset = "imageAsset" + static let createdAt = "createdAt" + } + + private let container: CKContainer + private let database: CKDatabase + + init(container: CKContainer) { + self.container = container + self.database = container.publicCloudDatabase + } + + func fetchPosts(completion: @escaping (Result<[CloudPost], Error>) -> Void) { + checkCloudAvailability { [weak self] available in + guard let self else { return } + guard available else { + completion(.failure(NSError(domain: "CloudKit", code: 0, userInfo: [NSLocalizedDescriptionKey: "CloudKit is unavailable"]))) + return + } + + let query = CKQuery(recordType: Constants.recordType, predicate: NSPredicate(value: true)) + query.sortDescriptors = [NSSortDescriptor(key: Keys.createdAt, ascending: false)] + self.fetchPosts(query: query, accumulated: [], completion: completion) + } + } + + func upsert(post: CloudPost, completion: ((Result) -> Void)? = nil) { + checkCloudAvailability { [weak self] available in + guard let self else { return } + guard available else { + completion?(.success(())) + return + } + + let recordID = CKRecord.ID(recordName: post.id) + + self.database.fetch(withRecordID: recordID) { [weak self] fetchedRecord, error in + let record: CKRecord + + if let ckError = error as? CKError, ckError.code == .unknownItem { + record = CKRecord(recordType: Constants.recordType, recordID: recordID) + } else if let fetchedRecord { + record = fetchedRecord + } else if let error { + completion?(.failure(error)) + return + } else { + record = CKRecord(recordType: Constants.recordType, recordID: recordID) + } + + self?.fill(record: record, with: post) + self?.database.save(record) { _, saveError in + if let saveError { + completion?(.failure(saveError)) + } else { + completion?(.success(())) + } + } + } + } + } + + func delete(postId: String, completion: ((Result) -> Void)? = nil) { + checkCloudAvailability { [weak self] available in + guard let self else { return } + guard available else { + completion?(.success(())) + return + } + + let recordID = CKRecord.ID(recordName: postId) + self.database.delete(withRecordID: recordID) { _, error in + if let ckError = error as? CKError, ckError.code == .unknownItem { + completion?(.success(())) + return + } + + if let error { + completion?(.failure(error)) + } else { + completion?(.success(())) + } + } + } + } + + private func fill(record: CKRecord, with post: CloudPost) { + record[Keys.postId] = post.id as CKRecordValue + record[Keys.author] = post.author as CKRecordValue + record[Keys.description] = post.description as CKRecordValue + record[Keys.likes] = post.likes as CKRecordValue + record[Keys.views] = post.views as CKRecordValue + record[Keys.comments] = post.comments as CKRecordValue + record[Keys.shares] = post.shares as CKRecordValue + record[Keys.isLiked] = post.isLiked as CKRecordValue + record[Keys.createdAt] = post.createdAt as CKRecordValue + + if let image = post.image, + let url = makeTemporaryImageURL(image: image, id: post.id) { + record[Keys.imageAsset] = CKAsset(fileURL: url) + } + } + + private func map(record: CKRecord) -> CloudPost? { + guard + let id = record[Keys.postId] as? String, + let author = record[Keys.author] as? String, + let description = record[Keys.description] as? String, + let likes = record[Keys.likes] as? Int, + let views = record[Keys.views] as? Int, + let comments = record[Keys.comments] as? Int, + let shares = record[Keys.shares] as? Int, + let isLiked = record[Keys.isLiked] as? Bool + else { + return nil + } + + let createdAt = (record[Keys.createdAt] as? Date) ?? Date.distantPast + let image: UIImage? + if let asset = record[Keys.imageAsset] as? CKAsset, + let fileURL = asset.fileURL, + let data = try? Data(contentsOf: fileURL) { + image = UIImage(data: data) + } else { + image = nil + } + + return CloudPost( + id: id, + author: author, + description: description, + likes: likes, + views: views, + comments: comments, + shares: shares, + isLiked: isLiked, + image: image, + createdAt: createdAt + ) + } + + private func makeTemporaryImageURL(image: UIImage, id: String) -> URL? { + guard let data = image.jpegData(compressionQuality: 0.9) else { return nil } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cloud_post_\(id).jpg") + + do { + try data.write(to: url, options: .atomic) + return url + } catch { + return nil + } + } + + private func checkCloudAvailability(completion: @escaping (Bool) -> Void) { + container.accountStatus { status, _ in + completion(status == .available) + } + } + + private func fetchPosts( + query: CKQuery, + accumulated: [CloudPost], + completion: @escaping (Result<[CloudPost], Error>) -> Void + ) { + database.fetch( + withQuery: query, + inZoneWith: nil, + desiredKeys: nil, + resultsLimit: CKQueryOperation.maximumResults + ) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + + case .success(let success): + let pagePosts = success.matchResults.compactMap { _, itemResult -> CloudPost? in + guard case .success(let record) = itemResult else { return nil } + return self?.map(record: record) + } + + let merged = accumulated + pagePosts + + if let cursor = success.queryCursor { + self?.fetchPosts(cursor: cursor, accumulated: merged, completion: completion) + } else { + completion(.success(merged)) + } + } + } + } + + private func fetchPosts( + cursor: CKQueryOperation.Cursor, + accumulated: [CloudPost], + completion: @escaping (Result<[CloudPost], Error>) -> Void + ) { + database.fetch( + withCursor: cursor, + desiredKeys: nil, + resultsLimit: CKQueryOperation.maximumResults + ) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + + case .success(let success): + let pagePosts = success.matchResults.compactMap { _, itemResult -> CloudPost? in + guard case .success(let record) = itemResult else { return nil } + return self?.map(record: record) + } + + let merged = accumulated + pagePosts + + if let nextCursor = success.queryCursor { + self?.fetchPosts(cursor: nextCursor, accumulated: merged, completion: completion) + } else { + completion(.success(merged)) + } + } + } + } +} diff --git a/Navigation/Services/CoreData/CoreDataStack.swift b/Navigation/Services/CoreData/CoreDataStack.swift new file mode 100644 index 0000000..d536b5e --- /dev/null +++ b/Navigation/Services/CoreData/CoreDataStack.swift @@ -0,0 +1,36 @@ +// +// CoreDataStack.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/22/26. +// + +import CoreData + +final class CoreDataStack { + + static let shared = CoreDataStack() + private init() {} + + lazy var persistentContainer: NSPersistentContainer = { + let container = NSPersistentContainer(name: "Navigation") + container.loadPersistentStores { _, error in + if let error { + fatalError("CoreData error: \(error)") + } + } + return container + }() + + // MARK: - ViewContext (только для чтения) + var viewContext: NSManagedObjectContext { + persistentContainer.viewContext + } + + // MARK: - BackgroundContext (для записи) + func newBackgroundContext() -> NSManagedObjectContext { + let context = persistentContainer.newBackgroundContext() + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + return context + } +} diff --git a/Navigation/Services/CoreData/FavoritePostEntity+CoreDataProperties.swift b/Navigation/Services/CoreData/FavoritePostEntity+CoreDataProperties.swift new file mode 100644 index 0000000..c8368ee --- /dev/null +++ b/Navigation/Services/CoreData/FavoritePostEntity+CoreDataProperties.swift @@ -0,0 +1,8 @@ +import Foundation +import CoreData + +extension FavoritePostEntity { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "FavoritePostEntity") + } +} diff --git a/Navigation/Services/CoreData/FavoritePostEntity.swift b/Navigation/Services/CoreData/FavoritePostEntity.swift new file mode 100644 index 0000000..2e49f5e --- /dev/null +++ b/Navigation/Services/CoreData/FavoritePostEntity.swift @@ -0,0 +1,13 @@ +import Foundation +import CoreData + +@objc(FavoritePostEntity) +public class FavoritePostEntity: NSManagedObject { + + @NSManaged public var id: String? + @NSManaged public var author: String? + @NSManaged public var text: String? + @NSManaged public var likes: Int64 + @NSManaged public var views: Int64 + @NSManaged public var image: String? +} diff --git a/Navigation/Services/CoreData/FavoriteRepositoryProtocol.swift b/Navigation/Services/CoreData/FavoriteRepositoryProtocol.swift new file mode 100644 index 0000000..2d8c2ce --- /dev/null +++ b/Navigation/Services/CoreData/FavoriteRepositoryProtocol.swift @@ -0,0 +1,14 @@ +// +// FavoriteRepositoryProtocol.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/25/26. +// + +import Foundation + +protocol FavoritesRepositoryProtocol { + func toggle(post: Post) -> Bool + func isFavorite(id: String) -> Bool + func fetchAll() -> [Post] +} diff --git a/Navigation/Services/CoreData/FavoritesRepository.swift b/Navigation/Services/CoreData/FavoritesRepository.swift new file mode 100644 index 0000000..3e82fdd --- /dev/null +++ b/Navigation/Services/CoreData/FavoritesRepository.swift @@ -0,0 +1,121 @@ +// +// FavoritesRepository.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/22/26. +// + +import CoreData + +final class FavoritesRepository: FavoritesRepositoryProtocol { + + static let shared = FavoritesRepository() + private init() {} + + // MARK: - Save (background) + func save(post: Post) { + let context = CoreDataStack.shared.newBackgroundContext() + + context.perform { + guard !self.isFavorite(id: post.id, context: context) else { return } + + let entity = FavoritePostEntity(context: context) + entity.id = post.id + entity.author = post.author + entity.text = post.description + entity.image = post.image + entity.likes = Int64(post.likes) + entity.views = Int64(post.views) + + try? context.save() + } + } + + // MARK: - Remove (background) + func remove(id: String) { + let context = CoreDataStack.shared.newBackgroundContext() + + context.perform { + let request: NSFetchRequest = + FavoritePostEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id) + + let objects = (try? context.fetch(request)) ?? [] + objects.forEach { context.delete($0) } + + try? context.save() + } + } + + // MARK: - Toggle + func toggle(post: Post) -> Bool { + if isFavorite(id: post.id) { + remove(id: post.id) + return false + } else { + save(post: post) + return true + } + } + + // MARK: - Fetch all (viewContext) + func fetchAll() -> [Post] { + let context = CoreDataStack.shared.viewContext + + let request: NSFetchRequest = + FavoritePostEntity.fetchRequest() + + let result = (try? context.fetch(request)) ?? [] + + return result.map { + Post( + id: $0.id ?? "", + author: $0.author ?? "", + description: $0.text ?? "", + image: $0.image ?? "", + likes: Int($0.likes), + views: Int($0.views) + ) + } + } + + // MARK: - Fetch by author (ДЗ!) + func fetch(by author: String) -> [Post] { + let context = CoreDataStack.shared.viewContext + + let request: NSFetchRequest = + FavoritePostEntity.fetchRequest() + request.predicate = NSPredicate(format: "author == %@", author) + + let result = (try? context.fetch(request)) ?? [] + + return result.map { + Post( + id: $0.id ?? "", + author: $0.author ?? "", + description: $0.text ?? "", + image: $0.image ?? "", + likes: Int($0.likes), + views: Int($0.views) + ) + } + } + + // MARK: - Is favorite + func isFavorite(id: String) -> Bool { + isFavorite(id: id, context: CoreDataStack.shared.viewContext) + } + + private func isFavorite( + id: String, + context: NSManagedObjectContext + ) -> Bool { + let request: NSFetchRequest = + FavoritePostEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id) + request.fetchLimit = 1 + + let count = (try? context.count(for: request)) ?? 0 + return count > 0 + } +} diff --git a/Navigation/Services/CoreData/Navigation.xcdatamodeld/Navigation.xcdatamodel/contents b/Navigation/Services/CoreData/Navigation.xcdatamodeld/Navigation.xcdatamodel/contents new file mode 100644 index 0000000..65c7d35 --- /dev/null +++ b/Navigation/Services/CoreData/Navigation.xcdatamodeld/Navigation.xcdatamodel/contents @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Navigation/Services/CoreData/Untitled.swift b/Navigation/Services/CoreData/Untitled.swift new file mode 100644 index 0000000..e69de29 diff --git a/Navigation/Services/FavoritesCoordinator.swift b/Navigation/Services/FavoritesCoordinator.swift new file mode 100644 index 0000000..be5a68e --- /dev/null +++ b/Navigation/Services/FavoritesCoordinator.swift @@ -0,0 +1,61 @@ +// +// FavoritesCoordinator.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/29/26. +// + +import UIKit + +final class FavoritesCoordinator: Coordinator { + + let navigationController = UINavigationController() + private var passwordCoordinator: PasswordCoordinator? + + func start() { + let viewController = FavoritesViewController() + viewController.title = L10n.tr("favorites.title") + viewController.onOpenFiles = { [weak self] in + self?.showFiles() + } + viewController.onOpenSettings = { [weak self] in + self?.showSettings() + } + + navigationController.viewControllers = [viewController] + navigationController.tabBarItem = UITabBarItem( + title: L10n.tr("favorites.title"), + image: UIImage(systemName: "heart.fill"), + tag: 1 + ) + } + + private func showFiles() { + let vc = FilesViewController() + navigationController.pushViewController(vc, animated: true) + } + + private func showSettings() { + let vc = SettingsViewController() + vc.onChangePassword = { [weak self] in + self?.showPasswordFlow() + } + vc.onLogout = { [weak self] in + self?.navigationController.popToRootViewController(animated: false) + NotificationCenter.default.post(name: .appDidRequestLogout, object: nil) + } + navigationController.pushViewController(vc, animated: true) + } + + private func showPasswordFlow() { + let passwordCoordinator = PasswordCoordinator(navigationController: navigationController) + self.passwordCoordinator = passwordCoordinator + + passwordCoordinator.onFinish = { [weak self] in + self?.passwordCoordinator = nil + self?.navigationController.popViewController(animated: true) + } + + passwordCoordinator.start() + } +} diff --git a/Navigation/Services/Feed/CatFeedService.swift b/Navigation/Services/Feed/CatFeedService.swift new file mode 100644 index 0000000..710063c --- /dev/null +++ b/Navigation/Services/Feed/CatFeedService.swift @@ -0,0 +1,227 @@ +import Foundation + +final class CatFeedService: FeedServiceProtocol, FeedGenreConfigurable { + private struct CatImage: Decodable { + struct Breed: Decodable { + let name: String? + let origin: String? + let temperament: String? + let descriptionText: String? + + enum CodingKeys: String, CodingKey { + case name + case origin + case temperament + case descriptionText = "description" + } + } + + let id: String + let url: String + let breeds: [Breed]? + } + + private let session: URLSession + private let apiKey: String? + private let decoder = JSONDecoder() + private var selectedGenre: FeedGenre = .humor + + init( + session: URLSession = CatFeedService.makeSession(), + apiKey: String? = CatFeedService.readAPIKey() + ) { + self.session = session + self.apiKey = apiKey + } + + func setGenre(_ genre: FeedGenre) { + selectedGenre = genre + } + + func fetchPosts(limit: Int) async throws -> [SocialFeedPost] { + let clampedLimit = max(6, min(30, limit)) + do { + let postsWithCategory = try await fetchFromAPI(limit: clampedLimit, genre: selectedGenre, withCategory: true) + if !postsWithCategory.isEmpty { + return postsWithCategory + } + } catch { + if let apiError = error as? APIError { + if case .network = apiError { + throw apiError + } + } + } + + do { + let postsWithoutCategory = try await fetchFromAPI(limit: clampedLimit, genre: selectedGenre, withCategory: false) + if !postsWithoutCategory.isEmpty { + return postsWithoutCategory + } + } catch { + if let apiError = error as? APIError { + throw apiError + } + throw APIError.network + } + + throw APIError.notFound + } + + private func fetchFromAPI(limit: Int, genre: FeedGenre, withCategory: Bool) async throws -> [SocialFeedPost] { + var components = URLComponents(string: "https://api.thecatapi.com/v1/images/search") + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "has_breeds", value: "1"), + URLQueryItem(name: "mime_types", value: "jpg,png"), + URLQueryItem(name: "order", value: "RANDOM"), + URLQueryItem(name: "size", value: "med"), + URLQueryItem(name: "page", value: String(Int.random(in: 0...25))) + ] + if withCategory, let categoryID = genre.catAPICategoryID { + queryItems.append(URLQueryItem(name: "category_ids", value: String(categoryID))) + } + components?.queryItems = queryItems + + guard let url = components?.url else { + throw APIError.badURL + } + + var request = URLRequest(url: url) + request.timeoutInterval = 12 + request.cachePolicy = .reloadIgnoringLocalCacheData + if let apiKey, !apiKey.isEmpty { + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw APIError.network + } + + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw APIError.invalidResponse + } + + let images: [CatImage] + do { + images = try decoder.decode([CatImage].self, from: data) + } catch { + throw APIError.decoding + } + + guard !images.isEmpty else { return [] } + + return images.enumerated().compactMap { index, image in + guard let photoURL = URL(string: image.url) else { return nil } + let breed = image.breeds?.first?.name?.nonEmpty + + return SocialFeedPost( + id: "catapi_\(image.id)", + username: breed ?? genre.authorTitles[index % genre.authorTitles.count], + avatarURL: URL(string: "https://cataas.com/cat/says/Hi?width=120&height=120&fontColor=white"), + photoURL: photoURL, + caption: makeCaption(for: image, genre: genre), + date: Calendar.current.date(byAdding: .minute, value: -(index * 4), to: Date()) ?? Date() + ) + } + } + + private func makeCaption(for image: CatImage, genre: FeedGenre) -> String { + guard let breed = image.breeds?.first else { + return "\(genre.captionPrefix)\nФото из The CatAPI. ID: \(image.id)" + } + + var parts: [String] = [genre.russianCaptions.randomElement() ?? genre.captionPrefix] + if let name = breed.name?.nonEmpty { + parts.append("Порода: \(name).") + } + if let origin = translatedOrigin(breed.origin) { + parts.append("Страна происхождения: \(origin).") + } + if let temperament = translatedTemperament(breed.temperament) { + parts.append("Характер: \(temperament).") + } + parts.append("Источник: The CatAPI.") + return parts.joined(separator: "\n") + } + + private func translatedOrigin(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let map: [String: String] = [ + "United States": "США", + "USA": "США", + "United Kingdom": "Великобритания", + "England": "Англия", + "Scotland": "Шотландия", + "Canada": "Канада", + "Australia": "Австралия", + "Egypt": "Египет", + "Russia": "Россия", + "France": "Франция", + "Germany": "Германия", + "Italy": "Италия", + "Turkey": "Турция", + "Japan": "Япония", + "Thailand": "Таиланд", + "Norway": "Норвегия" + ] + return map[raw] + } + + private func translatedTemperament(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let map: [String: String] = [ + "Affectionate": "ласковый", + "Curious": "любознательный", + "Friendly": "дружелюбный", + "Gentle": "мягкий", + "Playful": "игривый", + "Active": "активный", + "Calm": "спокойный", + "Social": "общительный", + "Intelligent": "умный", + "Loyal": "преданный", + "Energetic": "энергичный", + "Independent": "самостоятельный", + "Quiet": "тихий", + "Sweet": "милый", + "Agile": "ловкий" + ] + + let translated = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .compactMap { map[$0] } + + guard !translated.isEmpty else { return nil } + return translated.joined(separator: ", ") + } + + private static func readAPIKey() -> String? { + (Bundle.main.object(forInfoDictionaryKey: "CAT_API_KEY") as? String) + ?? (Bundle.main.object(forInfoDictionaryKey: "THE_CAT_API_KEY") as? String) + } + + private static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = URLCache( + memoryCapacity: 20 * 1024 * 1024, + diskCapacity: 60 * 1024 * 1024, + diskPath: "cat-feed-cache" + ) + return URLSession(configuration: configuration) + } + +} + +private extension String { + var nonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Navigation/Services/Feed/FeedCacheRepository.swift b/Navigation/Services/Feed/FeedCacheRepository.swift new file mode 100644 index 0000000..da70429 --- /dev/null +++ b/Navigation/Services/Feed/FeedCacheRepository.swift @@ -0,0 +1,65 @@ +import Foundation +import CoreData + +protocol FeedCacheRepositoryProtocol { + func save(posts: [SocialFeedPost]) throws + func loadPosts(limit: Int) throws -> [SocialFeedPost] +} + +final class CoreDataFeedCacheRepository: FeedCacheRepositoryProtocol { + private enum Constants { + static let entityName = "CachedFeedPostEntity" + } + + private let coreDataStack: CoreDataStack + + init(coreDataStack: CoreDataStack = .shared) { + self.coreDataStack = coreDataStack + } + + func save(posts: [SocialFeedPost]) throws { + let context = coreDataStack.viewContext + + let fetchRequest = NSFetchRequest(entityName: Constants.entityName) + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + try context.execute(deleteRequest) + + posts.forEach { post in + let object = NSEntityDescription.insertNewObject(forEntityName: Constants.entityName, into: context) + object.setValue(post.id, forKey: "id") + object.setValue(post.username, forKey: "username") + object.setValue(post.avatarURL?.absoluteString, forKey: "avatarURL") + object.setValue(post.photoURL?.absoluteString, forKey: "photoURL") + object.setValue(post.caption, forKey: "caption") + object.setValue(post.date, forKey: "date") + } + + try context.save() + } + + func loadPosts(limit: Int = 20) throws -> [SocialFeedPost] { + let request = NSFetchRequest(entityName: Constants.entityName) + request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)] + request.fetchLimit = max(1, min(50, limit)) + + return try coreDataStack.viewContext.fetch(request).compactMap { object in + guard + let id = object.value(forKey: "id") as? String, + let username = object.value(forKey: "username") as? String, + let caption = object.value(forKey: "caption") as? String, + let date = object.value(forKey: "date") as? Date + else { + return nil + } + + return SocialFeedPost( + id: id, + username: username, + avatarURL: URL(string: object.value(forKey: "avatarURL") as? String ?? ""), + photoURL: URL(string: object.value(forKey: "photoURL") as? String ?? ""), + caption: caption, + date: date + ) + } + } +} diff --git a/Navigation/Services/Feed/FeedInteractionsStore.swift b/Navigation/Services/Feed/FeedInteractionsStore.swift new file mode 100644 index 0000000..a64739e --- /dev/null +++ b/Navigation/Services/Feed/FeedInteractionsStore.swift @@ -0,0 +1,103 @@ +import Foundation + +struct FeedInteractionSnapshot { + let isLiked: Bool + let likesCount: Int + let commentsCount: Int + let sharesCount: Int +} + +protocol FeedInteractionsStoreProtocol { + func snapshot(for postID: String, userID: String) -> FeedInteractionSnapshot + func toggleLike(for postID: String, userID: String) -> FeedInteractionSnapshot + func addComment(for postID: String, userID: String, text: String) -> FeedInteractionSnapshot + func incrementShare(for postID: String, userID: String) -> FeedInteractionSnapshot +} + +final class FeedInteractionsStore: FeedInteractionsStoreProtocol { + private struct CommentItem: Codable { + let id: String + let userID: String + let text: String + let createdAt: Date + } + + private struct InteractionRecord: Codable { + var likedUserIDs: Set + var comments: [CommentItem] + var sharesCount: Int + } + + private enum Keys { + static let storage = "feed.interactions.store.v1" + } + + private let storage: UserDefaults + private var records: [String: InteractionRecord] + + init(storage: UserDefaults = .standard) { + self.storage = storage + self.records = Self.load(from: storage) + } + + func snapshot(for postID: String, userID: String) -> FeedInteractionSnapshot { + let record = records[postID] ?? .init(likedUserIDs: [], comments: [], sharesCount: 0) + return snapshot(from: record, userID: userID) + } + + func toggleLike(for postID: String, userID: String) -> FeedInteractionSnapshot { + var record = records[postID] ?? .init(likedUserIDs: [], comments: [], sharesCount: 0) + if record.likedUserIDs.contains(userID) { + record.likedUserIDs.remove(userID) + } else { + record.likedUserIDs.insert(userID) + } + records[postID] = record + persist() + return snapshot(from: record, userID: userID) + } + + func addComment(for postID: String, userID: String, text: String) -> FeedInteractionSnapshot { + var record = records[postID] ?? .init(likedUserIDs: [], comments: [], sharesCount: 0) + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return snapshot(from: record, userID: userID) + } + record.comments.append(.init(id: UUID().uuidString, userID: userID, text: trimmed, createdAt: Date())) + records[postID] = record + persist() + return snapshot(from: record, userID: userID) + } + + func incrementShare(for postID: String, userID: String) -> FeedInteractionSnapshot { + var record = records[postID] ?? .init(likedUserIDs: [], comments: [], sharesCount: 0) + record.sharesCount += 1 + records[postID] = record + persist() + return snapshot(from: record, userID: userID) + } + + private func snapshot(from record: InteractionRecord, userID: String) -> FeedInteractionSnapshot { + FeedInteractionSnapshot( + isLiked: record.likedUserIDs.contains(userID), + likesCount: record.likedUserIDs.count, + commentsCount: record.comments.count, + sharesCount: record.sharesCount + ) + } + + private func persist() { + guard let data = try? JSONEncoder().encode(records) else { return } + storage.set(data, forKey: Keys.storage) + } + + private static func load(from storage: UserDefaults) -> [String: InteractionRecord] { + guard + let data = storage.data(forKey: Keys.storage), + let records = try? JSONDecoder().decode([String: InteractionRecord].self, from: data) + else { + return [:] + } + return records + } +} diff --git a/Navigation/Services/Feed/FeedService.swift b/Navigation/Services/Feed/FeedService.swift new file mode 100644 index 0000000..8e7c797 --- /dev/null +++ b/Navigation/Services/Feed/FeedService.swift @@ -0,0 +1,87 @@ +import Foundation + +protocol FeedServiceProtocol { + func fetchPosts(limit: Int) async throws -> [SocialFeedPost] +} + +final class FeedService: FeedServiceProtocol { + private struct APIUser: Decodable { + let id: Int + let username: String + } + + private struct APIPost: Decodable { + let id: Int + let userId: Int + let title: String + let body: String + } + + private let session: URLSession + + init(session: URLSession = FeedService.makeSession()) { + self.session = session + } + + func fetchPosts(limit: Int = 20) async throws -> [SocialFeedPost] { + let clampedLimit = max(1, min(50, limit)) + + async let postsTask: [APIPost] = request("https://jsonplaceholder.typicode.com/posts?_limit=\(clampedLimit)") + async let usersTask: [APIUser] = request("https://jsonplaceholder.typicode.com/users") + + let (posts, users) = try await (postsTask, usersTask) + let usersById = Dictionary(uniqueKeysWithValues: users.map { ($0.id, $0.username) }) + + return posts.enumerated().map { index, post in + let username = usersById[post.userId] ?? "Athlete" + let avatarURL = URL(string: "https://i.pravatar.cc/120?u=\(post.userId)") + let photoURL = URL(string: "https://picsum.photos/seed/workout_\(post.id)/960/640") + let caption = "\(post.title.capitalized)\n\n\(post.body)" + let date = Calendar.current.date(byAdding: .hour, value: -(index * 3), to: Date()) ?? Date() + + return SocialFeedPost( + id: String(post.id), + username: username, + avatarURL: avatarURL, + photoURL: photoURL, + caption: caption, + date: date + ) + } + } + + private func request(_ rawURL: String) async throws -> T { + guard let url = URL(string: rawURL) else { + throw APIError.badURL + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(from: url) + } catch { + throw APIError.network + } + + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw APIError.invalidResponse + } + + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw APIError.decoding + } + } + + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.default + config.requestCachePolicy = .reloadRevalidatingCacheData + config.urlCache = URLCache( + memoryCapacity: 40 * 1024 * 1024, + diskCapacity: 120 * 1024 * 1024, + diskPath: "feed-service-cache" + ) + return URLSession(configuration: config) + } +} diff --git a/Navigation/Services/Firebase/FirebaseAuthRESTService.swift b/Navigation/Services/Firebase/FirebaseAuthRESTService.swift new file mode 100644 index 0000000..3db6391 --- /dev/null +++ b/Navigation/Services/Firebase/FirebaseAuthRESTService.swift @@ -0,0 +1,202 @@ +import Foundation + +struct FirebaseAuthenticatedUser: Equatable { + let email: String + let uid: String? + let displayName: String? + let photoURL: String? + + init(email: String, uid: String? = nil, displayName: String? = nil, photoURL: String? = nil) { + self.email = email + self.uid = uid + self.displayName = displayName + self.photoURL = photoURL + } +} + +struct FirebaseAuthSession: Equatable { + let idToken: String + let refreshToken: String + let user: FirebaseAuthenticatedUser +} + +protocol FirebaseAuthServiceProtocol { + func signIn(email: String, password: String) async throws -> FirebaseAuthSession + func signUp(email: String, password: String) async throws -> FirebaseAuthSession +} + +final class FirebaseSessionStorage { + static let shared = FirebaseSessionStorage() + + private enum Keys { + static let idToken = "firebase.session.idToken" + static let refreshToken = "firebase.session.refreshToken" + static let email = "firebase.session.email" + static let uid = "firebase.session.uid" + static let displayName = "firebase.session.displayName" + static let photoURL = "firebase.session.photoURL" + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var isAuthorized: Bool { + token != nil + } + + var token: String? { + defaults.string(forKey: Keys.idToken) + } + + var user: FirebaseAuthenticatedUser? { + guard let email = defaults.string(forKey: Keys.email) else { return nil } + return FirebaseAuthenticatedUser( + email: email, + uid: defaults.string(forKey: Keys.uid), + displayName: defaults.string(forKey: Keys.displayName), + photoURL: defaults.string(forKey: Keys.photoURL) + ) + } + + func store(session: FirebaseAuthSession) { + defaults.set(session.idToken, forKey: Keys.idToken) + defaults.set(session.refreshToken, forKey: Keys.refreshToken) + defaults.set(session.user.email, forKey: Keys.email) + defaults.set(session.user.uid, forKey: Keys.uid) + defaults.set(session.user.displayName, forKey: Keys.displayName) + defaults.set(session.user.photoURL, forKey: Keys.photoURL) + } + + func clear() { + defaults.removeObject(forKey: Keys.idToken) + defaults.removeObject(forKey: Keys.refreshToken) + defaults.removeObject(forKey: Keys.email) + defaults.removeObject(forKey: Keys.uid) + defaults.removeObject(forKey: Keys.displayName) + defaults.removeObject(forKey: Keys.photoURL) + } +} + +final class FirebaseAuthRESTService: FirebaseAuthServiceProtocol { + private struct AuthRequest: Encodable { + let email: String + let password: String + let returnSecureToken = true + } + + private struct AuthResponse: Decodable { + let idToken: String + let refreshToken: String + let email: String + let localId: String? + } + + private struct LookupRequest: Encodable { + let idToken: String + } + + private struct LookupResponse: Decodable { + struct UserData: Decodable { + let localId: String? + let email: String? + let displayName: String? + let photoUrl: String? + } + + let users: [UserData] + } + + private let session: URLSession + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + init( + session: URLSession = .shared, + decoder: JSONDecoder = JSONDecoder(), + encoder: JSONEncoder = JSONEncoder() + ) { + self.session = session + self.decoder = decoder + self.encoder = encoder + } + + func signIn(email: String, password: String) async throws -> FirebaseAuthSession { + try await authenticate(path: "accounts:signInWithPassword", email: email, password: password) + } + + func signUp(email: String, password: String) async throws -> FirebaseAuthSession { + try await authenticate(path: "accounts:signUp", email: email, password: password) + } + + private func authenticate(path: String, email: String, password: String) async throws -> FirebaseAuthSession { + let apiKey = try firebaseAPIKey() + let authResponse: AuthResponse = try await performRequest( + path: path, + apiKey: apiKey, + body: AuthRequest(email: email, password: password) + ) + + let profile = try await lookupUser(idToken: authResponse.idToken, apiKey: apiKey) + return FirebaseAuthSession( + idToken: authResponse.idToken, + refreshToken: authResponse.refreshToken, + user: FirebaseAuthenticatedUser( + email: profile?.email ?? authResponse.email, + uid: profile?.localId ?? authResponse.localId, + displayName: profile?.displayName, + photoURL: profile?.photoUrl + ) + ) + } + + private func lookupUser(idToken: String, apiKey: String) async throws -> LookupResponse.UserData? { + let response: LookupResponse = try await performRequest( + path: "accounts:lookup", + apiKey: apiKey, + body: LookupRequest(idToken: idToken) + ) + + return response.users.first + } + + private func performRequest( + path: String, + apiKey: String, + body: Body + ) async throws -> Output { + let endpoint = "https://identitytoolkit.googleapis.com/v1/\(path)?key=\(apiKey)" + guard let url = URL(string: endpoint) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(body) + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw URLError(.userAuthenticationRequired) + } + + return try decoder.decode(Output.self, from: data) + } + + private func firebaseAPIKey() throws -> String { + guard + let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + let info = NSDictionary(contentsOfFile: path), + let key = info["API_KEY"] as? String, + !key.isEmpty + else { + throw URLError(.cannotFindHost) + } + + return key + } +} diff --git a/Navigation/Services/Firebase/FirebaseChatService.swift b/Navigation/Services/Firebase/FirebaseChatService.swift new file mode 100644 index 0000000..b1105e8 --- /dev/null +++ b/Navigation/Services/Firebase/FirebaseChatService.swift @@ -0,0 +1,171 @@ +import Foundation + +struct ChatDialog { + let peerName: String + let roomID: String + let lastMessage: String + let time: String +} + +struct ChatAPIMessage { + let id: String + let sender: String + let text: String + let sentAt: Date +} + +protocol FirebaseChatServiceProtocol { + func fetchDialogs(currentUser: String, peers: [String], token: String) async throws -> [ChatDialog] + func fetchMessages(roomID: String, token: String) async throws -> [ChatAPIMessage] + func sendMessage(roomID: String, sender: String, text: String, token: String) async throws +} + +final class FirebaseChatService: FirebaseChatServiceProtocol { + private struct DocumentsResponse: Decodable { + struct Document: Decodable { + let name: String + let fields: [String: FirestoreFieldValue]? + } + + let documents: [Document]? + } + + private struct PatchRequest: Encodable { + let fields: [String: FirestoreFieldValue] + } + + private let session: URLSession + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(session: URLSession = .shared) { + self.session = session + } + + func fetchDialogs(currentUser: String, peers: [String], token: String) async throws -> [ChatDialog] { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + + var dialogs: [ChatDialog] = [] + for peer in peers { + let roomID = Self.roomID(currentUser: currentUser, peer: peer) + let messages = try await fetchMessages(roomID: roomID, token: token) + let last = messages.last + dialogs.append( + ChatDialog( + peerName: peer, + roomID: roomID, + lastMessage: last?.text ?? L10n.tr("chat.placeholder.no_messages"), + time: last.map { formatter.string(from: $0.sentAt) } ?? "" + ) + ) + } + + return dialogs + } + + func fetchMessages(roomID: String, token: String) async throws -> [ChatAPIMessage] { + let projectID = try firebaseProjectID() + var components = URLComponents(url: messagesCollectionURL(projectID: projectID, roomID: roomID), resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "pageSize", value: "100")] + + guard let url = components.url else { throw URLError(.badURL) } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 8 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw URLError(.cannotLoadFromNetwork) + } + + let payload = try decoder.decode(DocumentsResponse.self, from: data) + + let mapped: [ChatAPIMessage] = (payload.documents ?? []).compactMap { document in + guard let fields = document.fields else { return nil } + + let sentAtString = fields["sentAt"]?.timestampValue + ?? fields["createdAt"]?.timestampValue + ?? ISO8601DateFormatter().string(from: Date()) + + let sentAt = ISO8601DateFormatter().date(from: sentAtString) ?? Date() + + return ChatAPIMessage( + id: document.name, + sender: fields["sender"]?.stringValue ?? "", + text: fields["text"]?.stringValue ?? "", + sentAt: sentAt + ) + } + + return mapped.sorted(by: { $0.sentAt < $1.sentAt }) + } + + func sendMessage(roomID: String, sender: String, text: String, token: String) async throws { + let projectID = try firebaseProjectID() + let messageID = UUID().uuidString + let url = try messageDocumentURL(projectID: projectID, roomID: roomID, messageID: messageID) + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.timeoutInterval = 8 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let body = PatchRequest(fields: [ + "sender": .string(sender), + "text": .string(text), + "sentAt": .timestamp(ISO8601DateFormatter().string(from: Date())) + ]) + + request.httpBody = try encoder.encode(body) + + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw URLError(.cannotWriteToFile) + } + } + + static func roomID(currentUser: String, peer: String) -> String { + let left = normalizedUserID(currentUser) + let right = normalizedUserID(peer) + return [left, right].sorted().joined(separator: "__") + } + + static func normalizedUserID(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics + let lowered = value.lowercased() + let mapped = lowered.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + return String(mapped) + } + + private func firebaseProjectID() throws -> String { + guard + let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + let info = NSDictionary(contentsOfFile: path), + let projectID = info["PROJECT_ID"] as? String, + !projectID.isEmpty + else { + throw URLError(.cannotFindHost) + } + return projectID + } + + private func messagesCollectionURL(projectID: String, roomID: String) -> URL { + URL(string: "https://firestore.googleapis.com/v1/projects/\(projectID)/databases/(default)/documents/chat_rooms/\(roomID)/messages")! + } + + private func messageDocumentURL(projectID: String, roomID: String, messageID: String) throws -> URL { + guard let encodedRoom = roomID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let encodedMessage = messageID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "https://firestore.googleapis.com/v1/projects/\(projectID)/databases/(default)/documents/chat_rooms/\(encodedRoom)/messages/\(encodedMessage)") + else { + throw URLError(.badURL) + } + return url + } +} diff --git a/Navigation/Services/Firebase/FirebaseUserProfileService.swift b/Navigation/Services/Firebase/FirebaseUserProfileService.swift new file mode 100644 index 0000000..04dd6f4 --- /dev/null +++ b/Navigation/Services/Firebase/FirebaseUserProfileService.swift @@ -0,0 +1,104 @@ +import Foundation + +protocol FirebaseUserProfileServiceProtocol { + func upsertUserProfile(user: FirebaseAuthenticatedUser, idToken: String) async throws + func fetchUserEmails(idToken: String, excluding email: String?) async throws -> [String] +} + +final class FirebaseUserProfileService: FirebaseUserProfileServiceProtocol { + private struct PatchRequest: Encodable { + let fields: [String: FirestoreFieldValue] + } + + private let session: URLSession + private let encoder = JSONEncoder() + + init(session: URLSession = .shared) { + self.session = session + } + + func upsertUserProfile(user: FirebaseAuthenticatedUser, idToken: String) async throws { + let projectID = try firebaseProjectID() + let documentID = (user.uid?.isEmpty == false ? user.uid! : user.email) + .replacingOccurrences(of: "[^A-Za-z0-9_-]", with: "_", options: .regularExpression) + + guard let encodedID = documentID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "https://firestore.googleapis.com/v1/projects/\(projectID)/databases/(default)/documents/users/\(encodedID)") + else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.timeoutInterval = 12 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") + + request.httpBody = try encoder.encode( + PatchRequest(fields: [ + "email": .string(user.email), + "uid": .string(user.uid ?? ""), + "displayName": .string(user.displayName ?? user.email.components(separatedBy: "@").first ?? "User"), + "photoURL": .string(user.photoURL ?? ""), + "updatedAt": .timestamp(ISO8601DateFormatter().string(from: Date())) + ]) + ) + + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw URLError(.cannotWriteToFile) + } + } + + func fetchUserEmails(idToken: String, excluding email: String?) async throws -> [String] { + struct DocumentsResponse: Decodable { + struct Document: Decodable { + let fields: [String: FirestoreFieldValue]? + } + let documents: [Document]? + } + + let projectID = try firebaseProjectID() + guard let url = URL(string: "https://firestore.googleapis.com/v1/projects/\(projectID)/databases/(default)/documents/users?pageSize=50") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 12 + request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw URLError(.cannotLoadFromNetwork) + } + + let payload = try JSONDecoder().decode(DocumentsResponse.self, from: data) + let excluded = email?.lowercased() + + let emails = (payload.documents ?? []).compactMap { document -> String? in + let value = document.fields?["email"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value, !value.isEmpty else { return nil } + return value + } + + let unique = Array(Set(emails)) + return unique + .filter { $0.lowercased() != excluded } + .sorted() + } + + private func firebaseProjectID() throws -> String { + guard + let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + let info = NSDictionary(contentsOfFile: path), + let projectID = info["PROJECT_ID"] as? String, + !projectID.isEmpty + else { + throw URLError(.cannotFindHost) + } + return projectID + } +} diff --git a/Navigation/Services/Firebase/FirestoreFieldValue.swift b/Navigation/Services/Firebase/FirestoreFieldValue.swift new file mode 100644 index 0000000..c1f1bd3 --- /dev/null +++ b/Navigation/Services/Firebase/FirestoreFieldValue.swift @@ -0,0 +1,19 @@ +import Foundation + +struct FirestoreFieldValue: Codable { + let stringValue: String? + let timestampValue: String? + + init(stringValue: String? = nil, timestampValue: String? = nil) { + self.stringValue = stringValue + self.timestampValue = timestampValue + } + + static func string(_ value: String) -> FirestoreFieldValue { + FirestoreFieldValue(stringValue: value) + } + + static func timestamp(_ value: String) -> FirestoreFieldValue { + FirestoreFieldValue(timestampValue: value) + } +} diff --git a/Navigation/Services/Keychain/KeychainService.swift b/Navigation/Services/Keychain/KeychainService.swift new file mode 100644 index 0000000..4da6eab --- /dev/null +++ b/Navigation/Services/Keychain/KeychainService.swift @@ -0,0 +1,36 @@ +// +// KeychainService.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/13/26. +// + +import Foundation +import KeychainAccess + +final class KeychainService { + + static let shared = KeychainService() + + private let keychain = Keychain(service: "com.navigation.password") + + private let passwordKey = "user_password" + + private init() {} + + func savePassword(_ password: String) { + keychain[passwordKey] = password + } + + func getPassword() -> String? { + keychain[passwordKey] + } + + func hasPassword() -> Bool { + getPassword() != nil + } + + func removePassword() { + try? keychain.remove(passwordKey) + } +} diff --git a/Navigation/Services/LocalNotificationsService.swift b/Navigation/Services/LocalNotificationsService.swift new file mode 100644 index 0000000..94046a5 --- /dev/null +++ b/Navigation/Services/LocalNotificationsService.swift @@ -0,0 +1,79 @@ +import Foundation +import UserNotifications + +protocol LocalNotificationsServiceProtocol { + func registerForLatestUpdatesIfPossible() +} + +/// Обрабатывает разрешения на локальные уведомления +final class LocalNotificationsService: NSObject, UNUserNotificationCenterDelegate, LocalNotificationsServiceProtocol { + private let center = UNUserNotificationCenter.current() + private let latestUpdatesRequestId = "latestUpdatesDaily19" + private let updatesCategoryId = "updates" + private let openUpdatesActionId = "openUpdates" + + func registerForLatestUpdatesIfPossible() { + registerUpdatesCategory() + + center.requestAuthorization(options: [.sound, .badge, .alert]) { [weak self] granted, _ in + guard granted, let self = self else { return } + + self.center.delegate = self + + let content = UNMutableNotificationContent() + content.body = L10n.tr("notifications.latest_updates.body") + content.sound = .default + content.badge = 1 + content.categoryIdentifier = self.updatesCategoryId + + var dateComponents = DateComponents() + dateComponents.hour = 19 + dateComponents.minute = 0 + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + + self.center.removePendingNotificationRequests(withIdentifiers: [self.latestUpdatesRequestId]) + + let request = UNNotificationRequest( + identifier: self.latestUpdatesRequestId, + content: content, + trigger: trigger + ) + + self.center.add(request, withCompletionHandler: nil) + } + } + + func registerUpdatesCategory() { + let action = UNNotificationAction( + identifier: openUpdatesActionId, + title: L10n.tr("notifications.latest_updates.open_action"), + options: [.foreground] + ) + + let category = UNNotificationCategory( + identifier: updatesCategoryId, + actions: [action], + intentIdentifiers: [], + options: [] + ) + + center.setNotificationCategories([category]) + } +} + +extension LocalNotificationsService { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.notification.request.content.categoryIdentifier == updatesCategoryId, + response.actionIdentifier == openUpdatesActionId { + // Entry point for the "updates" action. Here we keep lightweight analytics/logging only. + print(L10n.tr("notifications.latest_updates.action_triggered_log")) + } + + completionHandler() + } +} diff --git a/Navigation/Services/SettingsStorage.swift b/Navigation/Services/SettingsStorage.swift new file mode 100644 index 0000000..756781d --- /dev/null +++ b/Navigation/Services/SettingsStorage.swift @@ -0,0 +1,48 @@ +// +// SettingsStorage.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/13/26. +// + +import Foundation + +enum AppThemeMode: Int { + case system + case light + case dark +} + +extension Notification.Name { + static let appThemeDidChange = Notification.Name("appThemeDidChange") +} + +/// Настройка: Сортировка файлов +final class SettingsStorage { + + static let shared = SettingsStorage() + private init() {} + + private let sortKey = "sortAscending" + private let themeModeKey = "themeMode" + + var isAscending: Bool { + get { + UserDefaults.standard.object(forKey: sortKey) as? Bool ?? true + } + set { + UserDefaults.standard.set(newValue, forKey: sortKey) + } + } + + var themeMode: AppThemeMode { + get { + let raw = UserDefaults.standard.integer(forKey: themeModeKey) + return AppThemeMode(rawValue: raw) ?? .system + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: themeModeKey) + NotificationCenter.default.post(name: .appThemeDidChange, object: nil) + } + } +} diff --git a/Navigation/StyleGuide.swift b/Navigation/StyleGuide.swift new file mode 100644 index 0000000..550a8fe --- /dev/null +++ b/Navigation/StyleGuide.swift @@ -0,0 +1,38 @@ +import UIKit + +enum StyleGuide { + enum Colors { + // Адаптация темная/светлая тема + static let vkBlue = UIColor(red: 0.16, green: 0.47, blue: 0.91, alpha: 1.0) + static let backgroundPrimary = UIColor.systemBackground + static let backgroundSecondary = UIColor.secondarySystemBackground + static let textPrimary = UIColor.label + static let textSecondary = UIColor.secondaryLabel + static let accent = vkBlue + static let border = UIColor.separator + static let borderStrong = UIColor.systemGray3 + static let danger = UIColor.systemRed + static let success = UIColor.systemGreen + static let muted = UIColor.systemGray + static let inverseText = UIColor.white + static let card = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.tertiarySystemBackground + : UIColor.white + } + } + + enum Fonts { + static func title(_ size: CGFloat = 24, weight: UIFont.Weight = .bold) -> UIFont { + UIFont.systemFont(ofSize: size, weight: weight) + } + + static func body(_ size: CGFloat = 16, weight: UIFont.Weight = .regular) -> UIFont { + UIFont.systemFont(ofSize: size, weight: weight) + } + + static func caption(_ size: CGFloat = 13, weight: UIFont.Weight = .medium) -> UIFont { + UIFont.systemFont(ofSize: size, weight: weight) + } + } +} diff --git a/Navigation/TabBarCoordinator/TabBarCoordinator.swift b/Navigation/TabBarCoordinator/TabBarCoordinator.swift new file mode 100644 index 0000000..8f790cb --- /dev/null +++ b/Navigation/TabBarCoordinator/TabBarCoordinator.swift @@ -0,0 +1,96 @@ +// +// TabBarCoordinator.swift +// Navigation +// +// Created by MAXIM GORNOSTAEV on 1/23/26. +// + +import UIKit + +/// Таббар +final class TabBarCoordinator: Coordinator { + + let tabBarController = UITabBarController() + private var homeCoordinator: HomeCoordinator? + private var chatsCoordinator: ChatsCoordinator? + private var clipsCoordinator: ClipsCoordinator? + private var menuCoordinator: MenuCoordinator? + + func start() { + configureAppearance() + + let homeNav = UINavigationController() + let homeCoordinator = HomeCoordinator(navigationController: homeNav) + homeCoordinator.start() + self.homeCoordinator = homeCoordinator + homeNav.tabBarItem = UITabBarItem( + title: L10n.tr("tab.home"), + image: TabBarIconFactory.icon(for: .home, selected: false), + selectedImage: TabBarIconFactory.icon(for: .home, selected: true) + ) + + let chatsNav = UINavigationController() + let chatsCoordinator = ChatsCoordinator(navigationController: chatsNav) + chatsCoordinator.start() + self.chatsCoordinator = chatsCoordinator + chatsNav.tabBarItem = UITabBarItem( + title: L10n.tr("tab.chats"), + image: TabBarIconFactory.icon(for: .chats, selected: false), + selectedImage: TabBarIconFactory.icon(for: .chats, selected: true) + ) + + let clipsNav = UINavigationController() + let clipsCoordinator = ClipsCoordinator(navigationController: clipsNav) + clipsCoordinator.start() + self.clipsCoordinator = clipsCoordinator + clipsNav.tabBarItem = UITabBarItem( + title: L10n.tr("tab.music"), + image: TabBarIconFactory.icon(for: .music, selected: false), + selectedImage: TabBarIconFactory.icon(for: .music, selected: true) + ) + + let menuNav = UINavigationController() + let menuCoordinator = MenuCoordinator(navigationController: menuNav) + menuCoordinator.start() + self.menuCoordinator = menuCoordinator + menuNav.tabBarItem = UITabBarItem( + title: L10n.tr("tab.menu"), + image: TabBarIconFactory.icon(for: .menu, selected: false), + selectedImage: TabBarIconFactory.icon(for: .menu, selected: true) + ) + + tabBarController.viewControllers = [ + homeNav, + chatsNav, + clipsNav, + menuNav + ] + } + + private func configureAppearance() { + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = StyleGuide.Colors.backgroundPrimary + + let normalAttrs: [NSAttributedString.Key: Any] = [ + .font: StyleGuide.Fonts.caption(10, weight: .medium), + .foregroundColor: StyleGuide.Colors.textSecondary + ] + let selectedAttrs: [NSAttributedString.Key: Any] = [ + .font: StyleGuide.Fonts.caption(10, weight: .medium), + .foregroundColor: StyleGuide.Colors.accent + ] + + appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttrs + appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttrs + appearance.stackedLayoutAppearance.normal.iconColor = StyleGuide.Colors.textSecondary + appearance.stackedLayoutAppearance.selected.iconColor = StyleGuide.Colors.accent + appearance.stackedLayoutAppearance.normal.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 2) + appearance.stackedLayoutAppearance.selected.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 2) + + tabBarController.tabBar.standardAppearance = appearance + tabBarController.tabBar.scrollEdgeAppearance = appearance + tabBarController.tabBar.tintColor = StyleGuide.Colors.accent + tabBarController.tabBar.unselectedItemTintColor = StyleGuide.Colors.textSecondary + } +} diff --git a/Navigation/TabBarCoordinator/TabBarIconFactory.swift b/Navigation/TabBarCoordinator/TabBarIconFactory.swift new file mode 100644 index 0000000..748a3fd --- /dev/null +++ b/Navigation/TabBarCoordinator/TabBarIconFactory.swift @@ -0,0 +1,80 @@ +import UIKit + +enum VKTabIcon { + case home + case chats + case music + case menu +} + +enum TabBarIconFactory { + static func icon(for type: VKTabIcon, selected: Bool) -> UIImage { + let size = CGSize(width: 26, height: 26) + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.image { context in + let cg = context.cgContext + cg.setLineWidth(selected ? 2.3 : 2.0) + cg.setStrokeColor(UIColor.black.cgColor) + cg.setFillColor(UIColor.black.cgColor) + + switch type { + case .home: + drawHome(in: cg, size: size, filled: selected) + case .chats: + drawChats(in: cg, size: size, filled: selected) + case .music: + drawMusic(in: cg, size: size, filled: selected) + case .menu: + drawMenu(in: cg, size: size, filled: selected) + } + }.withRenderingMode(.alwaysTemplate) + } + + private static func drawHome(in cg: CGContext, size: CGSize, filled: Bool) { + let rect = CGRect(x: 5, y: 11, width: 16, height: 11) + let roof = UIBezierPath() + roof.move(to: CGPoint(x: 4, y: 12)) + roof.addLine(to: CGPoint(x: 13, y: 4)) + roof.addLine(to: CGPoint(x: 22, y: 12)) + roof.stroke() + + let body = UIBezierPath(roundedRect: rect, cornerRadius: 2) + if filled { body.fill() } else { body.stroke() } + } + + private static func drawChats(in cg: CGContext, size: CGSize, filled: Bool) { + let first = UIBezierPath(roundedRect: CGRect(x: 3, y: 5, width: 13, height: 10), cornerRadius: 4) + let second = UIBezierPath(roundedRect: CGRect(x: 10, y: 11, width: 13, height: 10), cornerRadius: 4) + if filled { + first.fill() + second.fill() + } else { + first.stroke() + second.stroke() + } + } + + private static func drawMusic(in cg: CGContext, size: CGSize, filled: Bool) { + let noteHead = UIBezierPath(ovalIn: CGRect(x: 7, y: 15, width: 6, height: 6)) + filled ? noteHead.fill() : noteHead.stroke() + + let noteHead2 = UIBezierPath(ovalIn: CGRect(x: 15, y: 13, width: 6, height: 6)) + filled ? noteHead2.fill() : noteHead2.stroke() + + let stem = UIBezierPath() + stem.move(to: CGPoint(x: 12, y: 17)) + stem.addLine(to: CGPoint(x: 12, y: 6)) + stem.addLine(to: CGPoint(x: 20, y: 8)) + stem.addLine(to: CGPoint(x: 20, y: 15)) + stem.stroke() + } + + private static func drawMenu(in cg: CGContext, size: CGSize, filled: Bool) { + let yValues: [CGFloat] = [7, 13, 19] + yValues.forEach { y in + let path = UIBezierPath(roundedRect: CGRect(x: 5, y: y, width: 16, height: filled ? 3 : 2), cornerRadius: 1) + path.fill() + } + } +} diff --git a/Navigation/ViewController.swift b/Navigation/ViewController.swift index 3f8b4d3..81026c3 100644 --- a/Navigation/ViewController.swift +++ b/Navigation/ViewController.swift @@ -7,13 +7,133 @@ import UIKit -class ViewController: UIViewController { +final class ViewController: UIViewController { + + private let tableView = UITableView() + private var items: [URL] = [] + + private var documentsURL: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + } override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view. + title = L10n.tr("documents.title") + view.backgroundColor = StyleGuide.Colors.backgroundPrimary + + setupTableView() + loadFiles() + + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addPhotoTapped) + ) + } +} + +// MARK: - Setup +private extension ViewController { + + func setupTableView() { + tableView.frame = view.bounds + tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + view.addSubview(tableView) + } + + func loadFiles() { + do { + items = try FileManager.default.contentsOfDirectory( + at: documentsURL, + includingPropertiesForKeys: nil + ) + tableView.reloadData() + } catch { + let errorTitle = L10n.tr("documents.error.load") + print("\(errorTitle): \(error)") + } + } +} + +// MARK: - Actions +private extension ViewController { + + @objc func addPhotoTapped() { + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.delegate = self + present(picker, animated: true) + } +} + +// MARK: - UITableViewDataSource & Delegate +extension ViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items.count } + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + let fileURL = items[indexPath.row] + + cell.textLabel?.text = fileURL.lastPathComponent + cell.imageView?.image = UIImage(contentsOfFile: fileURL.path) + + return cell + } + + //Свайп для удаления + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + + let deleteAction = UIContextualAction(style: .destructive, title: L10n.tr("common.delete")) { [weak self] _, _, completion in + guard let self = self else { return } + + let fileURL = self.items[indexPath.row] + do { + try FileManager.default.removeItem(at: fileURL) + self.items.remove(at: indexPath.row) + tableView.deleteRows(at: [indexPath], with: .automatic) + completion(true) + } catch { + let errorTitle = L10n.tr("documents.error.delete") + print("\(errorTitle): \(error)") + completion(false) + } + } + + return UISwipeActionsConfiguration(actions: [deleteAction]) + } } +// MARK: - Image Picker +extension ViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] + ) { + picker.dismiss(animated: true) + + guard let image = info[.originalImage] as? UIImage else { return } + + let fileName = UUID().uuidString + ".jpg" + let fileURL = documentsURL.appendingPathComponent(fileName) + + if let data = image.jpegData(compressionQuality: 0.9) { + try? data.write(to: fileURL) + loadFiles() + } + } +} diff --git a/Navigation/ViewModels/Feed/SocialFeedViewModel.swift b/Navigation/ViewModels/Feed/SocialFeedViewModel.swift new file mode 100644 index 0000000..197090a --- /dev/null +++ b/Navigation/ViewModels/Feed/SocialFeedViewModel.swift @@ -0,0 +1,62 @@ +import Foundation + +final class SocialFeedViewModel { + enum State { + case idle + case loading + case content([SocialFeedPost]) + case error(String) + } + + private let service: FeedServiceProtocol + private let cacheRepository: FeedCacheRepositoryProtocol + + private(set) var state: State = .idle { + didSet { onStateChange?(state) } + } + + var onStateChange: ((State) -> Void)? + + init( + service: FeedServiceProtocol, + cacheRepository: FeedCacheRepositoryProtocol + ) { + self.service = service + self.cacheRepository = cacheRepository + } + + func setGenre(_ genre: FeedGenre) { + (service as? FeedGenreConfigurable)?.setGenre(genre) + } + + @MainActor + func loadInitial() async { + state = .loading + + if let cached = try? cacheRepository.loadPosts(limit: 20).filter({ $0.id.hasPrefix("catapi_") }), + !cached.isEmpty { + state = .content(cached) + } + + await refresh() + } + + @MainActor + func refresh() async { + do { + let posts = try await service.fetchPosts(limit: 20) + try? cacheRepository.save(posts: posts) + state = posts.isEmpty + ? .error(L10n.tr("home.state.empty")) + : .content(posts) + } catch { + if let cached = try? cacheRepository.loadPosts(limit: 20).filter({ $0.id.hasPrefix("catapi_") }), + !cached.isEmpty { + state = .content(cached) + return + } + let message = (error as? LocalizedError)?.errorDescription ?? L10n.tr("api.error.network") + state = .error(message) + } + } +} diff --git a/Navigation/en.lproj/Localizable.strings b/Navigation/en.lproj/Localizable.strings new file mode 100644 index 0000000..6ffa2f7 --- /dev/null +++ b/Navigation/en.lproj/Localizable.strings @@ -0,0 +1,199 @@ +"tab.home" = "Home"; +"tab.search" = "Search"; +"tab.chats" = "Chats"; +"tab.clips" = "Clips"; +"tab.music" = "Music"; +"tab.menu" = "Menu"; + +"common.ok" = "OK"; +"common.cancel" = "Cancel"; +"common.save" = "Save"; +"common.delete" = "Delete"; +"common.edit" = "Edit"; +"common.apply" = "Apply"; +"common.retry" = "Retry"; +"common.error" = "Error"; +"common.you" = "You"; + +"home.state.loading" = "Loading feed..."; +"home.state.empty" = "Feed is empty"; +"home.post.new_title" = "New post"; +"home.post.new_message" = "Add a description"; +"home.post.description_placeholder" = "Post description"; +"home.post.publish" = "Publish"; +"home.post.create_source" = "New Post"; +"home.post.from_gallery" = "From Gallery"; +"home.post.edit_title" = "Edit post"; +"home.post.sheet_title" = "Post"; +"home.post.edit_description" = "Edit description"; +"home.post.delete" = "Delete post"; +"home.post.comment_title" = "New comment"; +"home.post.comment_placeholder" = "Type a comment"; +"home.error.save_photo" = "Failed to save post photo"; +"home.error.load_photo" = "Failed to load photo"; +"home.error.long_no_internet" = "No internet connection for a while. Please check your network and try again."; +"home.genre.button" = "Genre"; +"home.genre.title" = "Select genre"; +"home.genre.humor" = "Humor"; +"home.genre.animals" = "Animals"; +"home.genre.cinema" = "Cinema"; +"home.genre.travel" = "Travel"; + +"search.state.loading" = "Searching posts..."; +"search.state.empty" = "Nothing found"; +"search.state.error" = "Failed to load data"; +"search.segment.celebrities" = "Celebrities"; +"search.segment.music" = "Music"; +"search.placeholder" = "Search"; +"search.player.play" = "Play"; +"search.player.pause" = "Pause"; +"search.celebrity.no_portfolio" = "Portfolio is empty"; +"search.track.preview" = "Preview"; + +"music.title" = "My Music"; +"music.profile.entry" = "My Music"; +"music.profile.subtitle" = "50 popular tracks of 2026"; +"music.state.loading" = "Loading tracks..."; +"music.state.empty" = "No tracks found"; +"music.state.error" = "Failed to load music"; +"music.control.play" = "Play"; +"music.control.pause" = "Pause"; +"music.control.stop" = "Stop"; + + +"clips.item_format" = "Clip %d"; +"clips.state.loading" = "Selecting clips..."; +"clips.state.empty" = "No clips yet"; +"clips.opening" = "Opening clip"; +"clips.remote" = "Online"; + +"chat.item.netology.name" = "Netology"; +"chat.item.netology.message" = "New iOS module is already available"; +"chat.item.team.message" = "Sync today at 18:00"; +"chat.item.friends.name" = "Friends"; +"chat.item.friends.message" = "Let's go to clips"; +"chat.item.maxim.message" = "Check the new screen"; +"chat.time.yesterday" = "Yesterday"; +"chats.state.loading" = "Loading chats..."; +"chats.state.empty" = "No chats yet"; +"chat.detail.placeholder" = "Chat will appear here"; +"chat.input.placeholder" = "Message"; +"chat.placeholder.no_messages" = "No messages yet"; +"chat.send" = "Send"; +"chat.emoji.pick" = "Pick an emoji"; +"chat.message.default1" = "Hi! How are you?"; +"chat.message.default2" = "Great, let's call in the evening 👌"; + +"menu.profile" = "Profile"; +"menu.favorites" = "Favorites"; +"menu.files" = "Files"; +"menu.settings" = "Settings"; +"menu.posts" = "Posts"; +"menu.info" = "Info"; +"menu.state.loading" = "Opening sections..."; +"menu.state.empty" = "No sections available"; + + +"settings.title" = "Settings"; +"settings.theme" = "Theme"; +"settings.sort" = "Sort A–Z"; +"settings.change_password" = "Change password"; +"settings.logout" = "Log out"; +"settings.logout.confirm_message" = "Are you sure you want to log out?"; +"theme.system" = "System"; +"theme.light" = "Light"; +"theme.dark" = "Dark"; + +"password.enter" = "Enter password"; +"password.create" = "Create password"; +"password.repeat" = "Repeat password"; +"password.error.too_short" = "Password must be at least 4 characters"; +"password.error.mismatch" = "Passwords do not match"; +"password.error.wrong" = "Wrong password"; +"password.log.saved" = "Password was saved to Keychain"; +"password.log.correct" = "Password is correct"; + +"login.email" = "Email"; +"login.password" = "Password"; +"login.submit" = "Sign in"; +"login.register" = "Sign up"; +"login.error.empty_credentials" = "Enter email and password"; +"login.error.signup_failed" = "Sign up failed"; +"login.error.internal" = "Internal login error. Restart the app."; + +"feed.title" = "Feed"; +"feed.enter_word" = "Enter word"; +"feed.check" = "Check"; +"feed.enter_word_required" = "Enter a word!"; +"feed.correct" = "Correct!"; +"feed.incorrect" = "Incorrect!"; + +"favorites.title" = "Favorites"; +"favorites.search.title" = "Search by author"; +"favorites.search.message" = "Enter author name"; +"favorites.search.placeholder" = "Author"; + +"documents.title" = "Documents"; +"documents.error.load" = "Failed to load files"; +"documents.error.delete" = "Failed to delete file"; + +"profile.title" = "Profile"; +"profile.my_title" = "My Profile"; +"profile.friends" = "friends"; +"profile.followers" = "followers"; +"profile.status.new_placeholder" = "Enter new status"; +"profile.status.update" = "Update status"; +"profile.status.firebase" = "Signed in with Firebase"; +"profile.edit.title" = "Edit profile"; +"profile.edit.name" = "Name"; +"profile.edit.status" = "Status"; +"profile.post.1" = "There is always work to do!"; +"profile.post.2" = "Gym time"; +"profile.post.3" = "Gift from Philipp Plein!"; +"profile.post.4" = "As usual where nobody is around"; + +"photos.title" = "Photos"; +"photos.logs.show" = "Show logs"; +"photos.logs.hide" = "Hide logs"; +"photos.logs.placeholder" = "Logs will appear here...\n"; +"photos.error.no_images" = "No images to process"; +"photos.qos.done" = "All QoS levels are tested"; +"photos.qos.done_log" = "All QoS levels are tested"; +"photos.qos.start_log" = "Starting QoS test: %@"; +"photos.qos.testing_status" = "Testing QoS: %@\nPlease wait..."; +"photos.qos.process_start_log" = "=== Processing started with QoS: %@ ==="; +"photos.qos.finish_log" = "Processing %@ completed"; +"photos.filter.chrome" = "Filter: Chrome"; +"photos.images_count" = "Images: %d"; +"photos.duration_seconds" = "Duration: %@ sec"; +"photos.success_ratio" = "Success: %d/%d"; +"photos.status.multiline" = "QoS: %@\nDuration: %@ sec\nImages: %d"; +"photos.status.log" = "Status: %@"; + +"post.title" = "Post"; +"post.likes" = "Likes: %d"; +"post.views" = "Views: %d"; +"post.author.netology" = "Netology"; +"post.author.media" = "Media"; +"post.author.friends" = "Friends"; +"post.author.design" = "Design"; +"post.sample.first" = "First post"; +"post.sample.second" = "Second post"; +"post.sample.third" = "Third post"; + +"info.loading_film" = "Loading film..."; +"info.loading_planet" = "Loading planet..."; +"info.show_alert" = "Show alert"; +"info.tatooine_period" = "Orbital period of Tatooine: %@"; +"info.alert.title" = "Info"; +"info.alert.message" = "This is a test alert"; + +"notifications.latest_updates.body" = "Check the latest updates"; +"notifications.latest_updates.open_action" = "Open updates"; +"notifications.latest_updates.action_triggered_log" = "Open updates action triggered"; + +"api.error.bad_url" = "Request error"; +"api.error.network" = "Network is unavailable"; +"api.error.invalid_response" = "Service is temporarily unavailable"; +"api.error.not_found" = "Product not found"; +"api.error.decoding" = "Failed to parse response data"; diff --git a/Navigation/ru.lproj/Localizable.strings b/Navigation/ru.lproj/Localizable.strings new file mode 100644 index 0000000..5fb58ea --- /dev/null +++ b/Navigation/ru.lproj/Localizable.strings @@ -0,0 +1,199 @@ +"tab.home" = "Главная"; +"tab.search" = "Поиск"; +"tab.chats" = "Чаты"; +"tab.clips" = "Клипы"; +"tab.music" = "Музыка"; +"tab.menu" = "Меню"; + +"common.ok" = "OK"; +"common.cancel" = "Отмена"; +"common.save" = "Сохранить"; +"common.delete" = "Удалить"; +"common.edit" = "Изменить"; +"common.apply" = "Применить"; +"common.retry" = "Повторить"; +"common.error" = "Ошибка"; +"common.you" = "Вы"; + +"home.state.loading" = "Загружаем ленту..."; +"home.state.empty" = "Лента пока пустая"; +"home.post.new_title" = "Новая публикация"; +"home.post.new_message" = "Добавьте описание к посту"; +"home.post.description_placeholder" = "Описание поста"; +"home.post.publish" = "Опубликовать"; +"home.post.create_source" = "Новая публикация"; +"home.post.from_gallery" = "Из галереи"; +"home.post.edit_title" = "Изменить публикацию"; +"home.post.sheet_title" = "Публикация"; +"home.post.edit_description" = "Изменить описание"; +"home.post.delete" = "Удалить пост"; +"home.post.comment_title" = "Новый комментарий"; +"home.post.comment_placeholder" = "Введите комментарий"; +"home.error.save_photo" = "Не удалось сохранить фото публикации"; +"home.error.load_photo" = "Не удалось загрузить фото"; +"home.error.long_no_internet" = "Долго нет интернет-соединения. Проверьте сеть и попробуйте снова."; +"home.genre.button" = "Жанр"; +"home.genre.title" = "Выберите жанр"; +"home.genre.humor" = "Юмор"; +"home.genre.animals" = "Животные"; +"home.genre.cinema" = "Кино"; +"home.genre.travel" = "Путешествия"; + +"search.state.loading" = "Ищем публикации..."; +"search.state.empty" = "Ничего не найдено"; +"search.state.error" = "Не удалось загрузить данные"; +"search.segment.celebrities" = "Знаменитости"; +"search.segment.music" = "Музыка"; +"search.placeholder" = "Поиск"; +"search.player.play" = "Слушать"; +"search.player.pause" = "Пауза"; +"search.celebrity.no_portfolio" = "Портфолио пока пусто"; +"search.track.preview" = "Превью"; + +"music.title" = "Моя музыка"; +"music.profile.entry" = "Моя музыка"; +"music.profile.subtitle" = "50 популярных треков 2026"; +"music.state.loading" = "Загружаем треки..."; +"music.state.empty" = "Треки не найдены"; +"music.state.error" = "Не удалось загрузить музыку"; +"music.control.play" = "Плей"; +"music.control.pause" = "Пауза"; +"music.control.stop" = "Стоп"; + + +"clips.item_format" = "Клип %d"; +"clips.state.loading" = "Подбираем клипы..."; +"clips.state.empty" = "Клипов пока нет"; +"clips.opening" = "Открытие клипа"; +"clips.remote" = "Онлайн"; + +"chat.item.netology.name" = "Нетология"; +"chat.item.netology.message" = "Новый модуль по iOS уже доступен"; +"chat.item.team.message" = "Синк сегодня в 18:00"; +"chat.item.friends.name" = "Друзья"; +"chat.item.friends.message" = "Погнали в клипы"; +"chat.item.maxim.message" = "Проверь новый экран"; +"chat.time.yesterday" = "Вчера"; +"chats.state.loading" = "Загружаем чаты..."; +"chats.state.empty" = "Чатов пока нет"; +"chat.detail.placeholder" = "Здесь будет переписка"; +"chat.input.placeholder" = "Сообщение"; +"chat.placeholder.no_messages" = "Пока нет сообщений"; +"chat.send" = "Отправить"; +"chat.emoji.pick" = "Выберите эмодзи"; +"chat.message.default1" = "Привет! Как дела?"; +"chat.message.default2" = "Отлично, давай созвонимся вечером 👌"; + +"menu.profile" = "Профиль"; +"menu.favorites" = "Избранное"; +"menu.files" = "Файлы"; +"menu.settings" = "Настройки"; +"menu.posts" = "Посты"; +"menu.info" = "Инфо"; +"menu.state.loading" = "Открываем разделы..."; +"menu.state.empty" = "Разделы отсутствуют"; + + +"settings.title" = "Настройки"; +"settings.theme" = "Тема"; +"settings.sort" = "Сортировка A–Z"; +"settings.change_password" = "Поменять пароль"; +"settings.logout" = "Выйти из аккаунта"; +"settings.logout.confirm_message" = "Вы уверены, что хотите выйти из аккаунта?"; +"theme.system" = "Система"; +"theme.light" = "Светлая"; +"theme.dark" = "Тёмная"; + +"password.enter" = "Введите пароль"; +"password.create" = "Создать пароль"; +"password.repeat" = "Повторите пароль"; +"password.error.too_short" = "Пароль должен быть минимум 4 символа"; +"password.error.mismatch" = "Пароли не совпадают"; +"password.error.wrong" = "Неверный пароль"; +"password.log.saved" = "Пароль сохранён в Keychain"; +"password.log.correct" = "Пароль верный"; + +"login.email" = "Email"; +"login.password" = "Пароль"; +"login.submit" = "Войти"; +"login.register" = "Зарегистрироваться"; +"login.error.empty_credentials" = "Введите email и пароль"; +"login.error.signup_failed" = "Не удалось зарегистрироваться"; +"login.error.internal" = "Внутренняя ошибка входа. Перезапустите приложение."; + +"feed.title" = "Лента"; +"feed.enter_word" = "Введите слово"; +"feed.check" = "Проверить"; +"feed.enter_word_required" = "Введите слово!"; +"feed.correct" = "Верно!"; +"feed.incorrect" = "Неверно!"; + +"favorites.title" = "Избранное"; +"favorites.search.title" = "Поиск по автору"; +"favorites.search.message" = "Введите имя автора"; +"favorites.search.placeholder" = "Автор"; + +"documents.title" = "Документы"; +"documents.error.load" = "Ошибка загрузки файлов"; +"documents.error.delete" = "Ошибка удаления файла"; + +"profile.title" = "Профиль"; +"profile.my_title" = "Мой профиль"; +"profile.friends" = "друзья"; +"profile.followers" = "подписчики"; +"profile.status.new_placeholder" = "Введите новый статус"; +"profile.status.update" = "Обновить статус"; +"profile.status.firebase" = "Авторизован через Firebase"; +"profile.edit.title" = "Редактировать профиль"; +"profile.edit.name" = "Имя"; +"profile.edit.status" = "Статус"; +"profile.post.1" = "На работе тоже есть чем заняться!"; +"profile.post.2" = "Банка"; +"profile.post.3" = "Philipp Plein подарил)))!"; +"profile.post.4" = "Как обычно там , где нет никого)))"; + +"photos.title" = "Фотографии"; +"photos.logs.show" = "Показать логи"; +"photos.logs.hide" = "Скрыть логи"; +"photos.logs.placeholder" = "Логи начнут появляться здесь...\n"; +"photos.error.no_images" = "Нет изображений — нечего обрабатывать"; +"photos.qos.done" = "Все QoS протестированы"; +"photos.qos.done_log" = "Все QoS протестированы"; +"photos.qos.start_log" = "Начинаем тест QoS: %@"; +"photos.qos.testing_status" = "Тестируем QoS: %@\nОжидайте..."; +"photos.qos.process_start_log" = "=== Начало обработки с QoS: %@ ==="; +"photos.qos.finish_log" = "Обработка %@ завершена"; +"photos.filter.chrome" = "Фильтр: Chrome"; +"photos.images_count" = "Изображений: %d"; +"photos.duration_seconds" = "Время: %@ секунд"; +"photos.success_ratio" = "Успешно: %d/%d"; +"photos.status.multiline" = "QoS: %@\nВремя: %@ сек\nИзображений: %d"; +"photos.status.log" = "Статус: %@"; + +"post.title" = "Пост"; +"post.likes" = "Лайки: %d"; +"post.views" = "Просмотры: %d"; +"post.author.netology" = "Нетология"; +"post.author.media" = "Медиа"; +"post.author.friends" = "Друзья"; +"post.author.design" = "Дизайн"; +"post.sample.first" = "Первый пост"; +"post.sample.second" = "Второй пост"; +"post.sample.third" = "Третий пост"; + +"info.loading_film" = "Загрузка фильма..."; +"info.loading_planet" = "Загрузка планеты..."; +"info.show_alert" = "Показать алерт"; +"info.tatooine_period" = "Период обращения Татуина: %@"; +"info.alert.title" = "Инфо"; +"info.alert.message" = "Это тестовый алерт"; + +"notifications.latest_updates.body" = "Посмотрите последние обновления"; +"notifications.latest_updates.open_action" = "Открыть обновления"; +"notifications.latest_updates.action_triggered_log" = "Сработало действие открытия обновлений"; + +"api.error.bad_url" = "Ошибка запроса"; +"api.error.network" = "Нет соединения с сетью"; +"api.error.invalid_response" = "Сервис временно недоступен"; +"api.error.not_found" = "Продукт не найден"; +"api.error.decoding" = "Ошибка обработки данных"; diff --git a/NavigationTests/CheckerServiceTests.swift b/NavigationTests/CheckerServiceTests.swift new file mode 100644 index 0000000..510c180 --- /dev/null +++ b/NavigationTests/CheckerServiceTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import Navigation + +final class CheckerServiceTests: XCTestCase { + private final class AuthServiceMock: FirebaseAuthServiceProtocol { + var signInError: Error? + var signUpError: Error? + + func signIn(email: String, password: String) async throws -> FirebaseAuthSession { + if let signInError { throw signInError } + return FirebaseAuthSession( + idToken: "id-token", + refreshToken: "refresh-token", + user: FirebaseAuthenticatedUser(email: email, displayName: "Tester", photoURL: nil) + ) + } + + func signUp(email: String, password: String) async throws -> FirebaseAuthSession { + if let signUpError { throw signUpError } + return FirebaseAuthSession( + idToken: "id-token", + refreshToken: "refresh-token", + user: FirebaseAuthenticatedUser(email: email, displayName: "Tester", photoURL: nil) + ) + } + } + + private final class ProfileServiceMock: FirebaseUserProfileServiceProtocol { + func upsertUserProfile(user: FirebaseAuthenticatedUser, idToken: String) async throws {} + func fetchUserEmails(idToken: String, excluding email: String?) async throws -> [String] { [] } + } + + func testCheckCredentials_whenEmailAndPasswordAreNotEmpty_returnsSuccess() { + let service = CheckerService( + authService: AuthServiceMock(), + userProfileService: ProfileServiceMock(), + sessionStorage: FirebaseSessionStorage(defaults: UserDefaults()) + ) + let exp = expectation(description: "checkCredentials") + + service.checkCredentials(email: "user@example.com", password: "1234") { result in + if case .failure(let error) = result { + XCTFail("Expected success, got error: \(error)") + } + exp.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testCheckCredentials_whenEmailIsEmpty_returnsFailure() { + let service = CheckerService( + authService: AuthServiceMock(), + userProfileService: ProfileServiceMock(), + sessionStorage: FirebaseSessionStorage(defaults: UserDefaults()) + ) + let exp = expectation(description: "checkCredentials") + + service.checkCredentials(email: "", password: "1234") { result in + if case .success = result { + XCTFail("Expected failure") + } + exp.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testCheckCredentials_whenPasswordIsEmpty_returnsFailure() { + let service = CheckerService( + authService: AuthServiceMock(), + userProfileService: ProfileServiceMock(), + sessionStorage: FirebaseSessionStorage(defaults: UserDefaults()) + ) + let exp = expectation(description: "checkCredentials") + + service.checkCredentials(email: "user@example.com", password: "") { result in + if case .success = result { + XCTFail("Expected failure") + } + exp.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testSignUp_returnsSuccess() { + let service = CheckerService( + authService: AuthServiceMock(), + userProfileService: ProfileServiceMock(), + sessionStorage: FirebaseSessionStorage(defaults: UserDefaults()) + ) + let exp = expectation(description: "signUp") + + service.signUp(email: "user@example.com", password: "1234") { result in + if case .failure(let error) = result { + XCTFail("Expected success, got error: \(error)") + } + exp.fulfill() + } + + waitForExpectations(timeout: 1.0) + } +} diff --git a/NavigationTests/CurrentUserServiceTests.swift b/NavigationTests/CurrentUserServiceTests.swift new file mode 100644 index 0000000..4caeb29 --- /dev/null +++ b/NavigationTests/CurrentUserServiceTests.swift @@ -0,0 +1,69 @@ +import XCTest +import UIKit +@testable import Navigation + +final class CurrentUserServiceTests: XCTestCase { + private let avatarKey = "currentUser.avatarFileName" + private let avatarFileName = "current_avatar.jpg" + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: avatarKey) + FirebaseSessionStorage.shared.clear() + removeAvatarFileIfNeeded() + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: avatarKey) + FirebaseSessionStorage.shared.clear() + removeAvatarFileIfNeeded() + super.tearDown() + } + + func testGetUser_withUnknownLogin_returnsNil() { + let service = CurrentUserService() + + XCTAssertNil(service.getUser(login: "unknown")) + } + + func testGetUser_withKnownLogin_returnsExpectedIdentity() { + let service = CurrentUserService() + + let user = service.getUser(login: "Wowgorno") + + XCTAssertNotNil(user) + XCTAssertEqual(user?.login, "Wowgorno") + XCTAssertEqual(user?.fullName, "Maxim Gornostayev") + XCTAssertEqual(user?.status, "iOS Developer") + } + + func testUpdateAvatar_savesFileNameAndPersistsImage() { + let service = CurrentUserService() + let image = makeImage(color: .red, size: CGSize(width: 48, height: 48)) + + service.updateAvatar(image) + + XCTAssertEqual(UserDefaults.standard.string(forKey: avatarKey), avatarFileName) + + let user = service.getUser(login: "Wowgorno") + XCTAssertNotNil(user) + XCTAssertGreaterThan(user?.avatar.size.width ?? 0, 0) + XCTAssertGreaterThan(user?.avatar.size.height ?? 0, 0) + } + + private func removeAvatarFileIfNeeded() { + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + let fileURL = url.appendingPathComponent(avatarFileName) + try? FileManager.default.removeItem(at: fileURL) + } + + private func makeImage(color: UIColor, size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } +} diff --git a/NavigationTests/FeedViewModelTests.swift b/NavigationTests/FeedViewModelTests.swift new file mode 100644 index 0000000..950f542 --- /dev/null +++ b/NavigationTests/FeedViewModelTests.swift @@ -0,0 +1,54 @@ +// +// FeedViewModelTests.swift +// NavigationTests +// +// Created by Codex on 19.02.2026. +// + +import XCTest +@testable import Navigation + +final class FeedViewModelTests: XCTestCase { + + private final class FeedModelMock: WordValidationServiceProtocol { + var checkResult: Bool = false + private(set) var lastWord: String? + + func check(word: String) -> Bool { + lastWord = word + return checkResult + } + } + + func testCheck_withEmptyInput_setsEmptyInputState() { + let model = FeedModelMock() + let viewModel = FeedViewModel(model: model) + + viewModel.check(word: " ") + + XCTAssertEqual(viewModel.state, .emptyInput) + XCTAssertNil(model.lastWord) + } + + func testCheck_withCorrectWord_setsCheckedTrueState() { + let model = FeedModelMock() + model.checkResult = true + let viewModel = FeedViewModel(model: model) + + viewModel.check(word: "кот") + + XCTAssertEqual(viewModel.state, .checked(isCorrect: true)) + XCTAssertEqual(model.lastWord, "кот") + } + + func testCheck_withIncorrectWord_setsCheckedFalseState() { + let model = FeedModelMock() + model.checkResult = false + let viewModel = FeedViewModel(model: model) + + viewModel.check(word: "собака") + + XCTAssertEqual(viewModel.state, .checked(isCorrect: false)) + XCTAssertEqual(model.lastWord, "собака") + } +} diff --git a/NavigationTests/LoginViewModelTests.swift b/NavigationTests/LoginViewModelTests.swift new file mode 100644 index 0000000..838e027 --- /dev/null +++ b/NavigationTests/LoginViewModelTests.swift @@ -0,0 +1,36 @@ +// +// LoginViewModelTests.swift +// NavigationTests +// +// Created by Codex on 19.02.2026. +// + +import XCTest +@testable import Navigation + +final class LoginViewModelTests: XCTestCase { + + func testSubmit_withEmptyEmail_setsErrorState() { + let viewModel = LoginViewModel() + + viewModel.submit(email: " ", password: "pass") + + XCTAssertEqual(viewModel.state, .errorEmpty) + } + + func testSubmit_withEmptyPassword_setsErrorState() { + let viewModel = LoginViewModel() + + viewModel.submit(email: "user@example.com", password: "") + + XCTAssertEqual(viewModel.state, .errorEmpty) + } + + func testSubmit_withValidCredentials_trimsAndSetsReadyState() { + let viewModel = LoginViewModel() + + viewModel.submit(email: " user@example.com ", password: " secret ") + + XCTAssertEqual(viewModel.state, .ready(email: "user@example.com", password: "secret")) + } +} diff --git a/NavigationTests/NetworkServiceTests.swift b/NavigationTests/NetworkServiceTests.swift new file mode 100644 index 0000000..3e46d13 --- /dev/null +++ b/NavigationTests/NetworkServiceTests.swift @@ -0,0 +1,81 @@ +// +// NetworkServiceTests.swift +// NavigationTests +// +// Created by Codex on 19.02.2026. +// + +import XCTest +@testable import Navigation + +final class NetworkServiceTests: XCTestCase { + + private final class NetworkSessionFake: NetworkSessionProtocol { + var data: Data? + var response: URLResponse? + var error: Error? + + func dataTask(with url: URL, + completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + completion(data, response, error) + } + } + + func testRequest_withSuccess_returnsSuccessResult() { + let fake = NetworkSessionFake() + let url = URL(string: "https://example.com")! + fake.data = "OK".data(using: .utf8) + fake.response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + fake.error = nil + + let expectation = expectation(description: "Completion called") + var captured: NetworkServiceResult? + + NetworkService.request(for: .people(url.absoluteString), session: fake) { result in + captured = result + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + XCTAssertEqual(captured, .success(data: "OK", statusCode: 200)) + } + + func testRequest_withError_returnsFailureResult() { + let fake = NetworkSessionFake() + let url = URL(string: "https://example.com")! + fake.data = nil + fake.response = nil + fake.error = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "failure"]) + + let expectation = expectation(description: "Completion called") + var captured: NetworkServiceResult? + + NetworkService.request(for: .people(url.absoluteString), session: fake) { result in + captured = result + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + XCTAssertEqual(captured, .failure("failure")) + } + + func testRequest_withEmptyData_returnsEmptyResult() { + let fake = NetworkSessionFake() + let url = URL(string: "https://example.com")! + fake.data = nil + fake.response = nil + fake.error = nil + + let expectation = expectation(description: "Completion called") + var captured: NetworkServiceResult? + + NetworkService.request(for: .people(url.absoluteString), session: fake) { result in + captured = result + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + XCTAssertEqual(captured, .empty) + } +} + diff --git a/NavigationTests/SearchViewModelTests.swift b/NavigationTests/SearchViewModelTests.swift new file mode 100644 index 0000000..abf74c1 --- /dev/null +++ b/NavigationTests/SearchViewModelTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import Navigation + +final class SearchViewModelTests: XCTestCase { + private final class MusicMock: MusicCatalogServiceProtocol { + var result: [MusicTrack] = [] + + func fetchTracks(query: String, limit: Int) async throws -> [MusicTrack] { + Array(result.prefix(limit)) + } + } + + func testLoad_populatesTracks() { + let music = MusicMock() + music.result = [ + MusicTrack(id: 10, title: "Believer", artist: "Imagine Dragons", previewURL: URL(string: "https://example.com/a.mp3")!, artworkURL: nil) + ] + + let viewModel = SearchViewModel(musicService: music) + let exp = expectation(description: "items") + viewModel.onItemsChange = { + exp.fulfill() + } + + viewModel.load() + waitForExpectations(timeout: 1.0) + + XCTAssertEqual(viewModel.items.count, 1) + if case .track(let track) = viewModel.items[0] { + XCTAssertEqual(track.artist, "Imagine Dragons") + } else { + XCTFail("Expected track item") + } + } + + func testUpdateSearch_filtersTracks() { + let music = MusicMock() + music.result = [ + MusicTrack(id: 10, title: "Believer", artist: "Imagine Dragons", previewURL: URL(string: "https://example.com/a.mp3")!, artworkURL: nil), + MusicTrack(id: 11, title: "Numb", artist: "Linkin Park", previewURL: URL(string: "https://example.com/b.mp3")!, artworkURL: nil) + ] + + let viewModel = SearchViewModel(musicService: music) + let loadExp = expectation(description: "loaded") + viewModel.onItemsChange = { loadExp.fulfill() } + viewModel.load() + waitForExpectations(timeout: 1.0) + + viewModel.updateSearch(text: "linkin") + + XCTAssertEqual(viewModel.items.count, 1) + if case .track(let track) = viewModel.items[0] { + XCTAssertEqual(track.title, "Numb") + } else { + XCTFail("Expected filtered track") + } + } +} diff --git a/NavigationTests/SettingsStorageTests.swift b/NavigationTests/SettingsStorageTests.swift new file mode 100644 index 0000000..222c3ad --- /dev/null +++ b/NavigationTests/SettingsStorageTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import Navigation + +final class SettingsStorageTests: XCTestCase { + private let key = "sortAscending" + private let themeKey = "themeMode" + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: key) + UserDefaults.standard.removeObject(forKey: themeKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: key) + UserDefaults.standard.removeObject(forKey: themeKey) + super.tearDown() + } + + func testIsAscending_whenValueNotStored_returnsTrueByDefault() { + XCTAssertTrue(SettingsStorage.shared.isAscending) + } + + func testIsAscending_whenSetFalse_persistsFalse() { + SettingsStorage.shared.isAscending = false + + XCTAssertFalse(SettingsStorage.shared.isAscending) + XCTAssertEqual(UserDefaults.standard.object(forKey: key) as? Bool, false) + } + + func testIsAscending_whenSetTrue_persistsTrue() { + SettingsStorage.shared.isAscending = true + + XCTAssertTrue(SettingsStorage.shared.isAscending) + XCTAssertEqual(UserDefaults.standard.object(forKey: key) as? Bool, true) + } + + func testThemeMode_defaultIsSystem() { + XCTAssertEqual(SettingsStorage.shared.themeMode, .system) + } + + func testThemeMode_whenSetDark_persistsDarkRawValue() { + SettingsStorage.shared.themeMode = .dark + + XCTAssertEqual(SettingsStorage.shared.themeMode, .dark) + XCTAssertEqual(UserDefaults.standard.integer(forKey: themeKey), AppThemeMode.dark.rawValue) + } +} diff --git a/NavigationTests/SocialFeedViewModelTests.swift b/NavigationTests/SocialFeedViewModelTests.swift new file mode 100644 index 0000000..76740aa --- /dev/null +++ b/NavigationTests/SocialFeedViewModelTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import Navigation + +@MainActor +final class SocialFeedViewModelTests: XCTestCase { + private final class FeedServiceMock: FeedServiceProtocol { + var result: Result<[SocialFeedPost], Error> = .success([]) + + func fetchPosts(limit: Int) async throws -> [SocialFeedPost] { + try result.get() + } + } + + private final class CacheMock: FeedCacheRepositoryProtocol { + var cached: [SocialFeedPost] = [] + + func save(posts: [SocialFeedPost]) throws { + cached = posts + } + + func loadPosts(limit: Int) throws -> [SocialFeedPost] { + Array(cached.prefix(limit)) + } + } + + func testRefresh_usesCacheWhenNetworkFails() async { + let service = FeedServiceMock() + service.result = .failure(APIError.network) + + let cache = CacheMock() + cache.cached = [ + SocialFeedPost( + id: "1", + username: "cached_user", + avatarURL: URL(string: "https://example.com/avatar.jpg"), + photoURL: URL(string: "https://example.com/photo.jpg"), + caption: "cached", + date: Date() + ) + ] + + let viewModel = SocialFeedViewModel(service: service, cacheRepository: cache) + await viewModel.refresh() + + if case .content(let posts) = viewModel.state { + XCTAssertEqual(posts.count, 1) + XCTAssertEqual(posts.first?.username, "cached_user") + } else { + XCTFail("Expected content from cache") + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b10b82 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Navigation iOS App + +## О проекте +`Navigation` — учебное приложение в стиле VK на `UIKit` с полностью кодовой версткой (`Auto Layout`, без storyboard для экранов), поддержкой `iPhone/iPad`, темной темы и модульной навигации через `Coordinator`. + +## Что реализовано +- Авторизация через Firebase (REST API Identity Toolkit) + сохранение сессии. +- Отображение данных пользователя в профиле (имя, статус, аватар, редактирование и сохранение). +- VK-подобный `TabBar`: Главная, Поиск, Чаты, Клипы, Меню. +- Лента, сторис, взаимодействие с постами (лайк/комментарий/репост), создание и редактирование пользовательских публикаций. +- Экран `Поиск` с API-данными: + - База знаменитостей и мини-портфолио (`TVMaze API`). + - База музыки для бесплатного прослушивания (`iTunes Search API`) + встроенный плеер превью. +- Клипы с онлайн-стримингом. +- Локальные уведомления с категорией `updates` и кастомным действием. +- Локализация `ru/en` и централизованный `StyleGuide` (цвета/шрифты). + +## Архитектура +- `Coordinator` для навигации: `AppCoordinator`, `TabBarCoordinator`, feature-координаторы. +- `MVVM` для экранов с бизнес-логикой (`Login`, `Feed`, `Search`, `Profile`). +- `Services` для инфраструктуры и данных: + - `Services/API/*` — внешние API. + - `Services/Firebase/*` — Firebase auth + сессия. + - `Services/CoreData/*` — избранное. + +> Legacy-след из ДЗ (`FeedModel`) удален: проверка слова перенесена в сервис `WordValidationService` внутри MVVM-потока. + +## Технологии и зависимости +- `Swift`, `UIKit`, `Auto Layout` +- `SPM`: + - `SnapKit` + - `Firebase iOS SDK` + - `KeychainAccess` + - `iOSIntPackage` + +## Скриншоты +- Лента: `docs/screenshots/home-feed.jpg` +- Профиль: `docs/screenshots/profile.jpg` +- Поиск/музыка: `docs/screenshots/search-music.jpg` + +## Сборка +```bash +xcodebuild -project Navigation.xcodeproj -scheme Navigation -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +## Тесты +```bash +xcodebuild -project Navigation.xcodeproj -scheme Navigation -only-testing:NavigationTests test +``` diff --git a/StorageService/StorageService.swift b/StorageService/StorageService.swift new file mode 100644 index 0000000..f428bb8 --- /dev/null +++ b/StorageService/StorageService.swift @@ -0,0 +1,9 @@ +// +// StorageService.swift +// StorageService +// +// Created by MAXIM GORNOSTAEV on 28.09.2025. +// + +import Foundation + diff --git a/docs/screenshots/.gitkeep b/docs/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/screenshots/home-feed.jpg b/docs/screenshots/home-feed.jpg new file mode 100644 index 0000000..962cdc0 Binary files /dev/null and b/docs/screenshots/home-feed.jpg differ diff --git a/docs/screenshots/profile.jpg b/docs/screenshots/profile.jpg new file mode 100644 index 0000000..5c3d092 Binary files /dev/null and b/docs/screenshots/profile.jpg differ diff --git a/docs/screenshots/search-music.jpg b/docs/screenshots/search-music.jpg new file mode 100644 index 0000000..82a7b51 Binary files /dev/null and b/docs/screenshots/search-music.jpg differ