diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index b4f858a..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 2db1862..cabf387 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Created by https://www.toptal.com/developers/gitignore/api/xcode,swift +# Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift + +### Swift ### # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore @@ -5,6 +9,23 @@ ## User settings xcuserdata/ +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + ## Obj-C/Swift specific *.hmap @@ -18,13 +39,11 @@ timeline.xctimeline playground.xcworkspace # Swift Package Manager -# # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins # Package.resolved # *.xcodeproj -# # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project # .swiftpm @@ -32,25 +51,24 @@ playground.xcworkspace .build/ # CocoaPods -# # We recommend against adding the Pods directory to your .gitignore. However # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# # Pods/ -# # Add this line if you want to avoid checking in source code from the Xcode workspace # *.xcworkspace # Carthage -# # Add this line if you want to avoid checking in source code from Carthage dependencies. # Carthage/Checkouts Carthage/Build/ +# Accio dependency management +Dependencies/ +.accio/ + # fastlane -# # It is recommended to not store the screenshots in the git repo. # Instead, use fastlane to re-generate the screenshots whenever they are needed. # For more information about the recommended setup visit: @@ -58,32 +76,29 @@ Carthage/Build/ fastlane/report.xml fastlane/Preview.html - fastlane/screenshots/**/*.png fastlane/test_output -# Mac OS X -*.DS_Store +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode -# Xcode -*.pbxuser -*.mode1v3 -*.mode2v3 -*.perspectivev3 -*.xcuserstate -project.xcworkspace/ -xcuserdata/ -project.pbxproj +iOSInjectionProject/ -# Generated files -*.o -*.pyc +### Xcode ### +## Xcode 8 and earlier -#Python modules -MANIFEST -dist/ -build/ +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.toptal.com/developers/gitignore/api/xcode,swift -# Backup files -*~.nib +.DS_Store +.vscode/ \ No newline at end of file diff --git a/Friends/Friends.xcodeproj/project.pbxproj b/Friends.xcodeproj/project.pbxproj similarity index 62% rename from Friends/Friends.xcodeproj/project.pbxproj rename to Friends.xcodeproj/project.pbxproj index 6b11b51..062fc90 100644 --- a/Friends/Friends.xcodeproj/project.pbxproj +++ b/Friends.xcodeproj/project.pbxproj @@ -6,8 +6,22 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 3917E29B2D94778400B51000 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3917E29A2D94778400B51000 /* SnapKit */; }; + B46C74CA2D9E84FE0021BF26 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = B46C74C92D9E84FE0021BF26 /* FirebaseAuth */; }; + B46C74CC2D9E84FE0021BF26 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = B46C74CB2D9E84FE0021BF26 /* FirebaseFirestore */; }; + B46C74CE2D9E84FE0021BF26 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = B46C74CD2D9E84FE0021BF26 /* FirebaseMessaging */; }; + B46C74D02D9E84FE0021BF26 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = B46C74CF2D9E84FE0021BF26 /* FirebaseStorage */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ + 394D942D2D9EB71C00187B5D /* Friends.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Friends.xcodeproj; sourceTree = ""; }; + 394D95562D9EB92D00187B5D /* Friends.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Friends.xcodeproj; sourceTree = ""; }; + 39BBDF862D9DA89200DDCDDA /* Friends.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Friends.xcodeproj; sourceTree = ""; }; 39E396AA2D932EA300C43CC4 /* Friends.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Friends.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 39EE0B9E2D99B301005E6AF4 /* Friends.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Friends.xcodeproj; sourceTree = ""; }; + B4B142D02D9F50E00089D84F /* Friends.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Friends.xcodeproj; sourceTree = ""; }; + B4B143A82D9F59B50089D84F /* Friends.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Friends.xcodeproj; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -36,12 +50,36 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B46C74CE2D9E84FE0021BF26 /* FirebaseMessaging in Frameworks */, + 3917E29B2D94778400B51000 /* SnapKit in Frameworks */, + B46C74CA2D9E84FE0021BF26 /* FirebaseAuth in Frameworks */, + B46C74D02D9E84FE0021BF26 /* FirebaseStorage in Frameworks */, + B46C74CC2D9E84FE0021BF26 /* FirebaseFirestore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 394D942E2D9EB71C00187B5D /* Products */ = { + isa = PBXGroup; + children = ( + ); + name = Products; + sourceTree = ""; + }; + 394D95572D9EB92D00187B5D /* Products */ = { + isa = PBXGroup; + children = ( + ); + name = Products; + sourceTree = ""; + }; + 39BBDF872D9DA89200DDCDDA /* Products */ = { + isa = PBXGroup; + name = Products; + sourceTree = ""; + }; 39E396A12D932EA300C43CC4 = { isa = PBXGroup; children = ( @@ -58,6 +96,27 @@ name = Products; sourceTree = ""; }; + 39EE0B9F2D99B301005E6AF4 /* Products */ = { + isa = PBXGroup; + children = ( + ); + name = Products; + sourceTree = ""; + }; + B4B142D12D9F50E00089D84F /* Products */ = { + isa = PBXGroup; + children = ( + ); + name = Products; + sourceTree = ""; + }; + B4B143A92D9F59B50089D84F /* Products */ = { + isa = PBXGroup; + children = ( + ); + name = Products; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -65,6 +124,7 @@ isa = PBXNativeTarget; buildConfigurationList = 39E396BD2D932EA500C43CC4 /* Build configuration list for PBXNativeTarget "Friends" */; buildPhases = ( + 395402EA2D95CED400E6DBEE /* SwiftLint */, 39E396A62D932EA300C43CC4 /* Sources */, 39E396A72D932EA300C43CC4 /* Frameworks */, 39E396A82D932EA300C43CC4 /* Resources */, @@ -78,6 +138,11 @@ ); name = Friends; packageProductDependencies = ( + 3917E29A2D94778400B51000 /* SnapKit */, + B46C74C92D9E84FE0021BF26 /* FirebaseAuth */, + B46C74CB2D9E84FE0021BF26 /* FirebaseFirestore */, + B46C74CD2D9E84FE0021BF26 /* FirebaseMessaging */, + B46C74CF2D9E84FE0021BF26 /* FirebaseStorage */, ); productName = Friends; productReference = 39E396AA2D932EA300C43CC4 /* Friends.app */; @@ -107,9 +172,39 @@ ); mainGroup = 39E396A12D932EA300C43CC4; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 3917E2992D94778400B51000 /* XCRemoteSwiftPackageReference "SnapKit" */, + B46C74C82D9E84FE0021BF26 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 39E396AB2D932EA300C43CC4 /* Products */; projectDirPath = ""; + projectReferences = ( + { + ProductGroup = 394D95572D9EB92D00187B5D /* Products */; + ProjectRef = 394D95562D9EB92D00187B5D /* Friends.xcodeproj */; + }, + { + ProductGroup = 39BBDF872D9DA89200DDCDDA /* Products */; + ProjectRef = 39BBDF862D9DA89200DDCDDA /* Friends.xcodeproj */; + }, + { + ProductGroup = 394D942E2D9EB71C00187B5D /* Products */; + ProjectRef = 394D942D2D9EB71C00187B5D /* Friends.xcodeproj */; + }, + { + ProductGroup = B4B142D12D9F50E00089D84F /* Products */; + ProjectRef = B4B142D02D9F50E00089D84F /* Friends.xcodeproj */; + }, + { + ProductGroup = B4B143A92D9F59B50089D84F /* Products */; + ProjectRef = B4B143A82D9F59B50089D84F /* Friends.xcodeproj */; + }, + { + ProductGroup = 39EE0B9F2D99B301005E6AF4 /* Products */; + ProjectRef = 39EE0B9E2D99B301005E6AF4 /* Friends.xcodeproj */; + }, + ); projectRoot = ""; targets = ( 39E396A92D932EA300C43CC4 /* Friends */, @@ -127,6 +222,28 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 395402EA2D95CED400E6DBEE /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftLint; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if test -d \"/opt/homebrew/bin/\"; then\n PATH=\"/opt/homebrew/bin/:${PATH}\"\nfi\n\nexport PATH\n\nif which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 39E396A62D932EA300C43CC4 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -146,13 +263,14 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = W9P5JL4539; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Friends/App/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -176,13 +294,14 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = W9P5JL4539; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Friends/App/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -339,6 +458,53 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 3917E2992D94778400B51000 /* XCRemoteSwiftPackageReference "SnapKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SnapKit/SnapKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.7.1; + }; + }; + B46C74C82D9E84FE0021BF26 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 11.11.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 3917E29A2D94778400B51000 /* SnapKit */ = { + isa = XCSwiftPackageProductDependency; + package = 3917E2992D94778400B51000 /* XCRemoteSwiftPackageReference "SnapKit" */; + productName = SnapKit; + }; + B46C74C92D9E84FE0021BF26 /* FirebaseAuth */ = { + isa = XCSwiftPackageProductDependency; + package = B46C74C82D9E84FE0021BF26 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; + }; + B46C74CB2D9E84FE0021BF26 /* FirebaseFirestore */ = { + isa = XCSwiftPackageProductDependency; + package = B46C74C82D9E84FE0021BF26 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestore; + }; + B46C74CD2D9E84FE0021BF26 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = B46C74C82D9E84FE0021BF26 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; + B46C74CF2D9E84FE0021BF26 /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = B46C74C82D9E84FE0021BF26 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 39E396A22D932EA300C43CC4 /* Project object */; } diff --git a/Friends/Friends.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Friends.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from Friends/Friends.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to Friends.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/Friends.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Friends.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..85e38f3 --- /dev/null +++ b/Friends.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,132 @@ +{ + "originHash" : "aa73f07cf7c5409b0099b3da4bf85deb87a052f87dcb787e09ad2b609eaae1d4", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "d1f7c7e8eaa74d7e44467184dc5f592268247d33", + "version" : "11.11.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "dd89fc79a77183830742a16866d87e4e54785734", + "version" : "11.11.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", + "version" : "8.0.2" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", + "version" : "1.69.0" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35", + "version" : "4.4.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "snapkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SnapKit/SnapKit.git", + "state" : { + "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", + "version" : "5.7.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", + "version" : "1.29.0" + } + } + ], + "version" : 3 +} diff --git a/Friends/Friends.xcodeproj/xcshareddata/xcschemes/Friends.xcscheme b/Friends.xcodeproj/xcshareddata/xcschemes/Friends.xcscheme similarity index 100% rename from Friends/Friends.xcodeproj/xcshareddata/xcschemes/Friends.xcscheme rename to Friends.xcodeproj/xcshareddata/xcschemes/Friends.xcscheme diff --git a/Friends/.DS_Store b/Friends/.DS_Store deleted file mode 100644 index 398c87a..0000000 Binary files a/Friends/.DS_Store and /dev/null differ diff --git a/Friends/.swiftlint.yml b/Friends/.swiftlint.yml new file mode 100644 index 0000000..e3fc744 --- /dev/null +++ b/Friends/.swiftlint.yml @@ -0,0 +1,79 @@ +excluded: + - Pods + - UnitTests + - UITests + - DerivedData + + +analyzer_rules: + - unused_import + - unused_declaration + +disabled_rules: + - todo + - cyclomatic_complexity + - function_body_length + - unused_setter_value + - weak_delegate + +opt_in_rules: + - overridden_super_call + - prohibited_super_call + - empty_count + - explicit_init + - force_unwrapping + - operator_usage_whitespace + - array_init + - block_based_kvo +# - closure_body_length + - closure_spacing + +large_tuple: 4 + +line_length: + warning: 160 + +function_parameter_count: + warning: 10 + error: 15 + +file_length: + warning: 500 + +type_body_length: + warning: 400 + error: 450 + +nesting: + type_level: + warning: 3 + +identifier_name: + min_length: 1 + max_length: 65 + +type_name: + min_length: 3 + max_length: 65 + +custom_rules: + vertical_whitespace_before_mark: + regex: '[A-Za-z0-9а-яА-ЯёЁ\/]\n([ \t]*)\/\/[\s*]MARK' + name: "Vertical Whitespace before MARK" + message: "Include vertical whitespace (empty line) before MARK." + + vertical_whitespace_after_mark: + included: ".*\\.swift" + regex: '\/\/[\s*]MARK[^\n\r]*\n[ \t]*?[A-Za-z0-9а-яА-ЯёЁ\/]' + name: "Vertical Whitespace after MARK" + message: "Include vertical whitespace (empty line) after MARK." + severity: warning + + wrong_letter: + included: ".*\\.swift" + regex: '[а-яА-Я][a-zA-Z]' + name: "Wrong russian or english letter" + message: "Replace latter to correct" + severity: warning + + diff --git a/Friends/Friends/App/AppDelegate.swift b/Friends/App/AppDelegate.swift similarity index 53% rename from Friends/Friends/App/AppDelegate.swift rename to Friends/App/AppDelegate.swift index 6dd27c2..dd7a367 100644 --- a/Friends/Friends/App/AppDelegate.swift +++ b/Friends/App/AppDelegate.swift @@ -5,32 +5,41 @@ // Created by тимур on 25.03.2025. // +import Firebase import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { // Override point for customization after application launch. + FirebaseApp.configure() + return true } // MARK: UISceneSession Lifecycle - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + 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) + return UISceneConfiguration( + name: "Default Configuration", sessionRole: connectingSceneSession.role) } - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + 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. } - } - diff --git a/Friends/Friends/App/Assets.xcassets/AccentColor.colorset/Contents.json b/Friends/App/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Friends/Friends/App/Assets.xcassets/AccentColor.colorset/Contents.json rename to Friends/App/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Friends/Friends/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Friends/App/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 95% rename from Friends/Friends/App/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Friends/App/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..c68da6c 100644 --- a/Friends/Friends/App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Friends/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "icon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Friends/App/Assets.xcassets/AppIcon.appiconset/icon.png b/Friends/App/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 0000000..5633f56 Binary files /dev/null and b/Friends/App/Assets.xcassets/AppIcon.appiconset/icon.png differ diff --git a/Friends/Friends/App/Assets.xcassets/Contents.json b/Friends/App/Assets.xcassets/Contents.json similarity index 100% rename from Friends/Friends/App/Assets.xcassets/Contents.json rename to Friends/App/Assets.xcassets/Contents.json diff --git a/Friends/App/Assets.xcassets/accept.imageset/Contents.json b/Friends/App/Assets.xcassets/accept.imageset/Contents.json new file mode 100644 index 0000000..d11515a --- /dev/null +++ b/Friends/App/Assets.xcassets/accept.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "accept.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/accept.imageset/accept.svg b/Friends/App/Assets.xcassets/accept.imageset/accept.svg new file mode 100644 index 0000000..1fa3953 --- /dev/null +++ b/Friends/App/Assets.xcassets/accept.imageset/accept.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Friends/App/Assets.xcassets/background.colorset/Contents.json b/Friends/App/Assets.xcassets/background.colorset/Contents.json new file mode 100644 index 0000000..d6e690b --- /dev/null +++ b/Friends/App/Assets.xcassets/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/decline.imageset/Contents.json b/Friends/App/Assets.xcassets/decline.imageset/Contents.json new file mode 100644 index 0000000..f18179c --- /dev/null +++ b/Friends/App/Assets.xcassets/decline.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "decline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/decline.imageset/decline.svg b/Friends/App/Assets.xcassets/decline.imageset/decline.svg new file mode 100644 index 0000000..e941bff --- /dev/null +++ b/Friends/App/Assets.xcassets/decline.imageset/decline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Friends/App/Assets.xcassets/extraFriendsBackground.colorset/Contents.json b/Friends/App/Assets.xcassets/extraFriendsBackground.colorset/Contents.json new file mode 100644 index 0000000..049a1ef --- /dev/null +++ b/Friends/App/Assets.xcassets/extraFriendsBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEC", + "green" : "0xEC", + "red" : "0xEC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/icon.imageset/Contents.json b/Friends/App/Assets.xcassets/icon.imageset/Contents.json new file mode 100644 index 0000000..1d8330c --- /dev/null +++ b/Friends/App/Assets.xcassets/icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "friendsIcon.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/icon.imageset/friendsIcon.svg b/Friends/App/Assets.xcassets/icon.imageset/friendsIcon.svg new file mode 100644 index 0000000..1c1d775 --- /dev/null +++ b/Friends/App/Assets.xcassets/icon.imageset/friendsIcon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Friends/App/Assets.xcassets/image.imageset/Contents.json b/Friends/App/Assets.xcassets/image.imageset/Contents.json new file mode 100644 index 0000000..2e2d4f6 --- /dev/null +++ b/Friends/App/Assets.xcassets/image.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "male2015108372468665.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/image.imageset/male2015108372468665.jpg b/Friends/App/Assets.xcassets/image.imageset/male2015108372468665.jpg new file mode 100644 index 0000000..f840f75 Binary files /dev/null and b/Friends/App/Assets.xcassets/image.imageset/male2015108372468665.jpg differ diff --git a/Friends/App/Assets.xcassets/image1.imageset/Contents.json b/Friends/App/Assets.xcassets/image1.imageset/Contents.json new file mode 100644 index 0000000..da07bd9 --- /dev/null +++ b/Friends/App/Assets.xcassets/image1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "male1085205810333.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/image1.imageset/male1085205810333.jpg b/Friends/App/Assets.xcassets/image1.imageset/male1085205810333.jpg new file mode 100644 index 0000000..0b461ea Binary files /dev/null and b/Friends/App/Assets.xcassets/image1.imageset/male1085205810333.jpg differ diff --git a/Friends/App/Assets.xcassets/image2.imageset/Contents.json b/Friends/App/Assets.xcassets/image2.imageset/Contents.json new file mode 100644 index 0000000..edcf22b --- /dev/null +++ b/Friends/App/Assets.xcassets/image2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "male1084510707702.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/image2.imageset/male1084510707702.jpg b/Friends/App/Assets.xcassets/image2.imageset/male1084510707702.jpg new file mode 100644 index 0000000..67d8f25 Binary files /dev/null and b/Friends/App/Assets.xcassets/image2.imageset/male1084510707702.jpg differ diff --git a/Friends/App/Assets.xcassets/image3.imageset/Contents.json b/Friends/App/Assets.xcassets/image3.imageset/Contents.json new file mode 100644 index 0000000..058f1ef --- /dev/null +++ b/Friends/App/Assets.xcassets/image3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "female20161025190873199.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/image3.imageset/female20161025190873199.jpg b/Friends/App/Assets.xcassets/image3.imageset/female20161025190873199.jpg new file mode 100644 index 0000000..641600c Binary files /dev/null and b/Friends/App/Assets.xcassets/image3.imageset/female20161025190873199.jpg differ diff --git a/Friends/App/Assets.xcassets/image4.imageset/Contents.json b/Friends/App/Assets.xcassets/image4.imageset/Contents.json new file mode 100644 index 0000000..1cbbcfe --- /dev/null +++ b/Friends/App/Assets.xcassets/image4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "female20161025070367096.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/image4.imageset/female20161025070367096.jpg b/Friends/App/Assets.xcassets/image4.imageset/female20161025070367096.jpg new file mode 100644 index 0000000..2db15d4 Binary files /dev/null and b/Friends/App/Assets.xcassets/image4.imageset/female20161025070367096.jpg differ diff --git a/Friends/App/Assets.xcassets/image5.imageset/Contents.json b/Friends/App/Assets.xcassets/image5.imageset/Contents.json new file mode 100644 index 0000000..7e2a896 --- /dev/null +++ b/Friends/App/Assets.xcassets/image5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "male20171084092511749.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/image5.imageset/male20171084092511749.jpg b/Friends/App/Assets.xcassets/image5.imageset/male20171084092511749.jpg new file mode 100644 index 0000000..271d0f4 Binary files /dev/null and b/Friends/App/Assets.xcassets/image5.imageset/male20171084092511749.jpg differ diff --git a/Friends/App/Assets.xcassets/location.imageset/Contents.json b/Friends/App/Assets.xcassets/location.imageset/Contents.json new file mode 100644 index 0000000..be50dad --- /dev/null +++ b/Friends/App/Assets.xcassets/location.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "location.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Friends/App/Assets.xcassets/location.imageset/location.svg b/Friends/App/Assets.xcassets/location.imageset/location.svg new file mode 100644 index 0000000..4f1de87 --- /dev/null +++ b/Friends/App/Assets.xcassets/location.imageset/location.svg @@ -0,0 +1,3 @@ + + + diff --git a/Friends/Friends/App/Base.lproj/LaunchScreen.storyboard b/Friends/App/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from Friends/Friends/App/Base.lproj/LaunchScreen.storyboard rename to Friends/App/Base.lproj/LaunchScreen.storyboard diff --git a/Friends/App/GoogleService-Info.plist b/Friends/App/GoogleService-Info.plist new file mode 100644 index 0000000..3f159e2 --- /dev/null +++ b/Friends/App/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyAoUO9mvpy3p6XC4mZU2Q5z_Z4B5hF4idI + GCM_SENDER_ID + 689388207896 + PLIST_VERSION + 1 + BUNDLE_ID + sirius.Friends + PROJECT_ID + friends-e42f4 + STORAGE_BUCKET + friends-b6eb7.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:689388207896:ios:8e2b31a57bde0e96e1a86d + + diff --git a/Friends/Friends/App/Info.plist b/Friends/App/Info.plist similarity index 100% rename from Friends/Friends/App/Info.plist rename to Friends/App/Info.plist diff --git a/Friends/Friends/App/SceneDelegate.swift b/Friends/App/SceneDelegate.swift similarity index 97% rename from Friends/Friends/App/SceneDelegate.swift rename to Friends/App/SceneDelegate.swift index 49f4337..23a2ba8 100644 --- a/Friends/Friends/App/SceneDelegate.swift +++ b/Friends/App/SceneDelegate.swift @@ -11,11 +11,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } let window = UIWindow(windowScene: windowScene) - window.rootViewController = ViewController() + window.rootViewController = TabBarController() self.window = window window.makeKeyAndVisible() } @@ -48,6 +47,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // to restore the scene back to its current state. } - } - diff --git a/Friends/App/TabBar.swift b/Friends/App/TabBar.swift new file mode 100644 index 0000000..d53b9de --- /dev/null +++ b/Friends/App/TabBar.swift @@ -0,0 +1,59 @@ +// +// TabBar.swift +// Friends +// +// Created by Савва Пономарев on 27.03.2025. +// + +import Foundation +import UIKit + +class TabBarController: UITabBarController { + + override func viewDidLoad() { + super.viewDidLoad() + checkUserLoginStatus() + } + + private func checkUserLoginStatus() { + DispatchQueue.main.async { [weak self] in + let userDataCache = UserDataCache() + if let userData = userDataCache.retrieveUserInfo() { + self?.setupUI() + } else { + self?.presentAuthController() + } + } + } + + private func presentAuthController() { + let authVC = AuthViewController() + authVC.onAuthSuccess = { [weak self] in + self?.setupUI() + } + let navController = UINavigationController(rootViewController: authVC) + navController.modalPresentationStyle = .overFullScreen + present(navController, animated: true) + } + + private func setupUI() { + let firstViewController = UINavigationController(rootViewController: FinanceViewController()) + firstViewController.tabBarItem = UITabBarItem(title: "Деньги", + image: UIImage(systemName: "creditcard"), + selectedImage: UIImage(systemName: "creditcard.fill")) + + let secondViewController = UINavigationController(rootViewController: EventAssembly.build()) + secondViewController.tabBarItem = UITabBarItem(title: "Встречи", + image: UIImage(systemName: "balloon.2"), + selectedImage: UIImage(systemName: "balloon.2.fill")) + + let thirdViewController = UINavigationController(rootViewController: FriendsViewController()) + thirdViewController.tabBarItem = UITabBarItem(title: "Друзья", + image: UIImage(systemName: "person.2"), + selectedImage: UIImage(systemName: "person.2.fill")) + + viewControllers = [firstViewController, secondViewController, thirdViewController] + + selectedIndex = 0 + } +} diff --git a/Friends/Cache/AppCache.swift b/Friends/Cache/AppCache.swift new file mode 100644 index 0000000..0f93b83 --- /dev/null +++ b/Friends/Cache/AppCache.swift @@ -0,0 +1,77 @@ +// +// AppCache.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import Foundation + +struct AppData: Codable { + var user: Person + var events: [EventModels.Event] + var debts: [Debt] + var friends: [Person] + var icons: [UUID: URL] +} + +class AppCache { + static let shared = AppCache() + + var user: Person? + var appData: AppData? + private var fileManager: FileManager + private var fileName = "friends.json" + private let cacheURL: URL + + private init(fileManager: FileManager = .default) { + self.fileManager = fileManager + let cacheDirectory = FileManager.default.urls( + for: .cachesDirectory, in: .userDomainMask + ).first! + cacheURL = cacheDirectory.appendingPathComponent("app_data.json") + } + + func saveAppData( + _ data: AppData, + completion: @escaping ((Result) -> Void) + ) { + do { + let jsonData = try JSONEncoder().encode(data) + try jsonData.write(to: cacheURL) + user = data.user + completion(.success(true)) + } catch { + completion( + .failure( + .custom(errorCode: 520, description: "Data saving failed"))) + } + } + + func loadAppData( + completion: @escaping ((Result) -> Void) + ) { + do { + let jsonData = try Data(contentsOf: cacheURL) + let data = try JSONDecoder().decode(AppData.self, from: jsonData) + user = data.user + + completion( + .success(data) + ) + } catch { + completion(.failure(.login)) + } + } + func clearCache(completion: @escaping ((Result) -> Void)) { + do { + try FileManager.default.removeItem(at: cacheURL) + completion(.success(true)) + } catch { + completion( + .failure( + .custom(errorCode: 520, description: "Data clearing failed") + )) + } + } +} diff --git a/Friends/Cache/UserDataCache.swift b/Friends/Cache/UserDataCache.swift new file mode 100644 index 0000000..5567e5e --- /dev/null +++ b/Friends/Cache/UserDataCache.swift @@ -0,0 +1,69 @@ +// +// CacheData.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import Foundation +import Security + +class UserDataCache { + + private let service = "sirius.Friends.cacheinfo" + private let account = "userCredentials" + + func saveUserInfo(userInfo: [String: Any]) { + guard + let username = userInfo["username"] as? String, + let password = userInfo["password"] as? String + else { + return + } + let userData: [String: Any] = [ + "username": username, + "password": password + ] + guard let credentialsData = try? JSONSerialization.data(withJSONObject: userData) else { + return + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: credentialsData + ] + SecItemDelete(query as CFDictionary) + + let status = SecItemAdd(query as CFDictionary, nil) + } + + func retrieveUserInfo() -> [String: Any]? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + guard status == errSecSuccess, let retrievedData = dataTypeRef as? Data else { + return nil + } + + return try? JSONSerialization.jsonObject(with: retrievedData, options: []) as? [String: Any] + } + + func deleteUserInfo() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + SecItemDelete(query as CFDictionary) + } +} diff --git a/Friends/Components/SelectFriendsView.swift b/Friends/Components/SelectFriendsView.swift new file mode 100644 index 0000000..0e84290 --- /dev/null +++ b/Friends/Components/SelectFriendsView.swift @@ -0,0 +1,50 @@ +// +// SelectFriendsView.swift +// Friends +// +// Created by тимур on 28.03.2025. +// + +import SwiftUI + +struct SelectFriendsView: View { + @Environment(\.dismiss) var dismiss + let friends: [Person] + @State private var internalSelection: Set = [] + @Binding var selectedFriends: Set + + var body: some View { + VStack { + ZStack(alignment: .trailing) { + HStack { + Spacer() + Text("Add Friends") + .fontWeight(.medium) + Spacer() + } + + Button("Done") { + selectedFriends = Set(friends.filter({ internalSelection.contains($0.id) })) + dismiss() + } + .fontWeight(.bold) + } + .padding([.horizontal, .top]) + + List(friends, selection: $internalSelection) { person in + HStack { + Image(uiImage: person.icon) + .resizable() + .frame(width: 40.0, height: 40.0) + .clipShape(Circle()) + Text(person.name) + } + } + .environment(\.editMode, .constant(.active)) + } + .onAppear { + internalSelection = Set(selectedFriends.map { $0.id }) + } + .background(Color.background) + } +} diff --git a/Friends/Components/TimeGrid.swift b/Friends/Components/TimeGrid.swift new file mode 100644 index 0000000..f68a482 --- /dev/null +++ b/Friends/Components/TimeGrid.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct TimeGrid: View { + struct Cell: Hashable, Codable { + let row: Int + let column: Int + } + + private enum Mode { + case select + case deselect + } + + var cellsOpacity = [Cell: Double]() + @Binding var selectedCells: Set + let rows: Int + let columns: Int + var onCellTapAction: (Cell) -> Void = {_ in } + var isEditable = true + @State private var tempSelectedCells: Set = [] + @State private var tempDeselectedCells: Set = [] + @State private var startCell: Cell? + @State private var mode: Mode = .select + @State private var isDragging = false + private let generator = UIImpactFeedbackGenerator(style: .medium) + + var body: some View { + GeometryReader { geometry in + let cellSize = CGSize( + width: geometry.size.width / CGFloat(columns), + height: geometry.size.height / CGFloat(rows) + ) + + VStack(spacing: 1) { + ForEach(0..() + for row in Int(minRow)...Int(maxRow) { + for column in Int(minColumn)...Int(maxColumn) { + let cell = Cell(row: row, column: column) + cells.insert(cell) + } + } + + switch mode { + case .select: + tempSelectedCells = cells + case .deselect: + tempDeselectedCells = cells + } + } + + private func isHighlighted(_ cell: Cell) -> Bool { + return !tempDeselectedCells.contains(cell) && (tempSelectedCells.contains(cell) || selectedCells.contains(cell)) + } + + private func getFillColor(for cell: Cell) -> Color { + if isHighlighted(cell) { + return .green + } else if let opacity = cellsOpacity[cell] { + return .green.opacity(opacity) + } + + return .gray.opacity(0.2) + } + + private func toggleCell(_ cell: Cell) { + if selectedCells.contains(cell) { + selectedCells.remove(cell) + } else { + selectedCells.insert(cell) + } + } + + private func getCell(at location: CGPoint, cellSize: CGSize) -> Cell { + let column = max(0, min(Int(location.x / cellSize.width), columns - 1)) + let row = max(0, min(Int(location.y / cellSize.height), rows - 1)) + return Cell(row: row, column: column) + } +} diff --git a/Friends/Errors/AuthError.swift b/Friends/Errors/AuthError.swift new file mode 100644 index 0000000..a2725ff --- /dev/null +++ b/Friends/Errors/AuthError.swift @@ -0,0 +1,25 @@ +// +// AuthificactionError.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import Foundation + +enum AuthError: Error { + case auth + case login + case custom(errorCode: Int, description: String) + + var localizedDescription: String { + switch self { + case .auth: + return "Authentication failed" + case .login: + return "Login failed" + case .custom(_, let description): + return description + } + } +} diff --git a/Friends/Errors/FileError.swift b/Friends/Errors/FileError.swift new file mode 100644 index 0000000..81d8d7a --- /dev/null +++ b/Friends/Errors/FileError.swift @@ -0,0 +1,25 @@ +// +// FileError.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import Foundation + +enum File: Error { + case download + case upload + case custom(errorCode: Int, description: String) + + var localizedDescription: String { + switch self { + case .download: + return "Download failed" + case .upload: + return "Upload failed" + case .custom(_, let description): + return description + } + } +} diff --git a/Friends/Errors/NetworkError.swift b/Friends/Errors/NetworkError.swift new file mode 100644 index 0000000..a58162d --- /dev/null +++ b/Friends/Errors/NetworkError.swift @@ -0,0 +1,25 @@ +// +// NetworkErrors.swift +// Friends +// +// Created by Савва Пономарев on 02.04.2025. +// + +import Foundation + +enum NetworkError: Error { + case download + case upload + case custom(errorCode: Int, description: String) + + var localizedDescription: String { + switch self { + case .download: + return "Download failed" + case .upload: + return "Upload failed" + case .custom(_, let description): + return description + } + } +} diff --git a/Friends/Friends.xcodeproj/project.xcworkspace/xcuserdata/timur.xcuserdatad/UserInterfaceState.xcuserstate b/Friends/Friends.xcodeproj/project.xcworkspace/xcuserdata/timur.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 1bf3ea5..0000000 Binary files a/Friends/Friends.xcodeproj/project.xcworkspace/xcuserdata/timur.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Friends/Friends.xcodeproj/xcuserdata/timur.xcuserdatad/xcschemes/xcschememanagement.plist b/Friends/Friends.xcodeproj/xcuserdata/timur.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 389c600..0000000 --- a/Friends/Friends.xcodeproj/xcuserdata/timur.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - SchemeUserState - - Friends.xcscheme_^#shared#^_ - - orderHint - 0 - - - SuppressBuildableAutocreation - - 39E396A92D932EA300C43CC4 - - primary - - - - - diff --git a/Friends/Friends/.DS_Store b/Friends/Friends/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/Friends/Friends/.DS_Store and /dev/null differ diff --git a/Friends/Managers/DataManager.swift b/Friends/Managers/DataManager.swift new file mode 100644 index 0000000..39c41b8 --- /dev/null +++ b/Friends/Managers/DataManager.swift @@ -0,0 +1,70 @@ +// +// DataManager.swift +// Friends +// +// Created by Алексей on 29.03.2025. +// +import MapKit + +final class DataManager: DataManagerProtocol { + // MARK: - Properties + + private var eventNetworkManager: EventsNetworkCommunications + private var events: [EventModels.Event] = [] + private var archive: [EventModels.Event] = [] + + // MARK: - Initialization + + init(eventNetworkManager: EventsNetworkCommunications) { + self.eventNetworkManager = eventNetworkManager + loadEventsFromNetwork() + } + + // MARK: - Functions + + func loadEvents() -> [EventModels.Event] { + return events + } + + func loadEventsFromNetwork() { + eventNetworkManager.loadEvents { [weak self] loadedEvents in + self?.events = loadedEvents + } + } + + func addEvent(_ event: EventModels.Event) { + events.append(event) + } + + func updateEventAttendanceStatus(status: EventModels.AttendanceStatus, at eventIndex: Int) { + guard eventIndex >= 0 && eventIndex < events.count else { + return + } + events[eventIndex].attendiesInfo[0].status = status + eventNetworkManager.updateEvent(events[eventIndex]) + } + + func loadArchive() -> [EventModels.Event] { + return archive + } + + func moveToArchive(eventIndex: Int) { + guard eventIndex >= 0 && eventIndex < events.count else { + return + } + var event = events.remove(at: eventIndex) + if !event.attendiesInfo.isEmpty { + event.attendiesInfo[0].status = .declined + } + archive.append(event) + } + + func restoreFromArchive(eventIndex: Int) { + guard eventIndex >= 0 && eventIndex < archive.count else { + return + } + var event = archive.remove(at: eventIndex) + event.attendiesInfo[0].status = .attending + events.append(event) + } +} diff --git a/Friends/Managers/DataManagerProtocol.swift b/Friends/Managers/DataManagerProtocol.swift new file mode 100644 index 0000000..d082457 --- /dev/null +++ b/Friends/Managers/DataManagerProtocol.swift @@ -0,0 +1,15 @@ +// +// DataManagerProtocol.swift +// Friends +// +// Created by Алексей on 29.03.2025. +// + +protocol DataManagerProtocol { + func loadEvents() -> [EventModels.Event] + func addEvent(_ event: EventModels.Event) + func updateEventAttendanceStatus(status: EventModels.AttendanceStatus, at index: Int) + func loadArchive() -> [EventModels.Event] + func moveToArchive(eventIndex: Int) + func restoreFromArchive(eventIndex: Int) +} diff --git a/Friends/Models/Debt.swift b/Friends/Models/Debt.swift new file mode 100644 index 0000000..cf0013f --- /dev/null +++ b/Friends/Models/Debt.swift @@ -0,0 +1,27 @@ +// +// Debt.swift +// Friends +// +// Created by Савва Пономарев on 27.03.2025. +// + +import UIKit + +enum DebtColor { + case red + case green + + static func getColor(isDebitor: Bool) -> UIColor { + return isDebitor ? .systemGreen : .systemRed + } +} +struct Debt: Identifiable, Hashable, Codable { + var personTo: Person + var personFrom: Person + var debt: Double + var id = UUID() +} +enum DebtType { + case from + case to +} diff --git a/Friends/Models/EventModels.swift b/Friends/Models/EventModels.swift new file mode 100644 index 0000000..759bf8c --- /dev/null +++ b/Friends/Models/EventModels.swift @@ -0,0 +1,46 @@ +// +// Event.swift +// Friends +// +// Created by тимур on 29.03.2025. +// + +import UIKit + +enum EventModels { + struct Event: Codable { + var id: UUID + var title: String + var description: String + var address: String + var hostId: UUID + var pickedCells: Set + var attendiesInfo: [AttendeeInfo] + var isTimeFixed: Bool + var creationDate: Date + var startTime: Date? + var endTime: Date? + var location: Location? + } + + struct AttendeeInfo: Codable { + var id: UUID + var status: AttendanceStatus + var pickedCells: Set? + } + + struct Location: Codable { + var latitude: Float + var longitude: Float + } + + enum AttendanceStatus: String, Codable { + case attending + case declined + case noReply + } +} + +extension EventModels.Event { + static let empty: EventModels.Event = .init(id: UUID(), title: "", description: "", address: "", hostId: UUID(), pickedCells: Set(), attendiesInfo: [], isTimeFixed: false, creationDate: Date()) +} diff --git a/Friends/Models/GroupModels.swift b/Friends/Models/GroupModels.swift new file mode 100644 index 0000000..5404605 --- /dev/null +++ b/Friends/Models/GroupModels.swift @@ -0,0 +1,16 @@ +// +// GroupModels.swift +// Friends +// +// Created by Алексей on 02.04.2025. +// + +import UIKit + +enum GroupModels { + struct Group: Hashable { + var id: UUID + var title: String + var friends: [Person] + } +} diff --git a/Friends/Models/Person.swift b/Friends/Models/Person.swift new file mode 100644 index 0000000..9390931 --- /dev/null +++ b/Friends/Models/Person.swift @@ -0,0 +1,199 @@ +// +// Person.swift +// Friends +// +// Created by Савва Пономарев on 27.03.2025. + +import UIKit + +struct Person: Codable { + var id: UUID + var name: String + var username: String + var password: String + var imageURL: URL? + var friends: [UUID] = [] + var debts: [Debt] + var groups: [UUID] = [] + + var icon: UIImage { + if let imageURL = imageURL, + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData) { + return image + } + + return UIImage(systemName: "person.circle")! + } +} + +// MARK: - Equatable + +extension Person: Identifiable, Hashable {} + +//MARK: - Debt Functions +extension Person { + func getDebts() -> [Debt] { + debts.filter { $0.personFrom == self } + } + func getDebitors() -> [Debt] { + debts.filter { $0.personTo == self } + } + mutating func addDebt(_ debt: Debt) { + debts.append(debt) + } + mutating func removeDebt(_ debt: Debt) { + debts.removeAll { $0.id == debt.id } + } + mutating func updateDebt(_ debt: Debt) { + debts.removeAll { $0.id == debt.id } + debts.append(debt) + } +} + +// TODO: remove container + +class PersonContainer { + let user: Person + + static let shared: PersonContainer = PersonContainer() + + private var debtFrom: [Debt] = [] + private var debtTo: [Debt] + + // TODO: убрать временную реализацию + private init() { + self.user = Person(id: UUID(), name: "TestUser", username: "testUser", password: "12435-adsfa-34141234", debts: []) + self.debtFrom = [ + Debt(personTo: Person(id: UUID(), name: "", username: "", password: "", debts: []), personFrom: Person(id: UUID(), name: "Соня", username: "", password: "", debts: []), debt: 346), + Debt(personTo: Person(id: UUID(), name: "", username: "", password: "", debts: []), personFrom: Person(id: UUID(), name: "Миша", username: "", password: "", debts: []), debt: 200) + ] + self.debtTo = [ + Debt(personTo: Person(id: UUID(), name: "Алекс", username: "", password: "", debts: []), personFrom: Person(id: UUID(), name: "", username: "", password: "", debts: []), debt: 1005), + Debt(personTo: Person(id: UUID(), name: "Сергей", username: "", password: "", debts: []), personFrom: Person(id: UUID(), name: "", username: "", password: "", debts: []), debt: 55) + ] + } + + public func getDebts(dest: DebtType) -> [Debt] { + switch dest { + case .from: + return debtFrom + case .to: + return debtTo + } + } + + public func addDebt(_ debt: Double, dest: DebtType, person: Person?) { + guard let person = person else { return } + + switch dest { + case .from: + debtFrom.append( + Debt( + personTo: person, personFrom: PersonContainer.shared.user, + debt: debt)) + case .to: + debtTo.append( + Debt( + personTo: PersonContainer.shared.user, personFrom: person, + debt: debt)) + + } + } + + public func isDebitor(_ person: Person) -> Bool { + if debtTo.contains(where: { $0.personTo.id == person.id }) { + return true + } + return false + } + + public func getPeople() -> [Person] { + var people: [Person] = [] + + debtTo.forEach { person in + people.append(person.personTo) + } + + debtFrom.forEach { person in + people.append(person.personFrom) + } + return people + } + + public func editDebt(_ debt: Double, dest: DebtType, person: Person?) { + guard let person = person else { return } + + guard debt > 0 else { return } + + switch dest { + case .from: + if let index = debtFrom.firstIndex(where: { + $0.personFrom.id == person.id + }) { + debtFrom[index].debt += debt + } else if let index = debtTo.firstIndex(where: { + $0.personTo.id == person.id + }) { + if debtTo[index].debt > debt { + debtTo[index].debt -= debt + } else { + let remainingDebt = debt - debtTo[index].debt + debtTo.remove(at: index) + if remainingDebt > 0 { + debtFrom.append( + Debt( + personTo: person, personFrom: user, + debt: remainingDebt)) + } + } + } else { + debtFrom.append( + Debt(personTo: person, personFrom: user, debt: debt)) + } + + case .to: + if let index = debtTo.firstIndex(where: { + $0.personTo.id == person.id + }) { + debtTo[index].debt += debt + } else if let index = debtFrom.firstIndex(where: { + $0.personFrom.id == person.id + }) { + if debtFrom[index].debt > debt { + debtFrom[index].debt -= debt + } else { + let remainingDebt = debt - debtFrom[index].debt + debtFrom.remove(at: index) + if remainingDebt > 0 { + debtTo.append( + Debt( + personTo: user, personFrom: person, + debt: remainingDebt)) + } + } + } else { + debtTo.append( + Debt(personTo: user, personFrom: person, debt: debt)) + } + } + } + + public func getDebt(of person: Person) -> Double { + if isDebitor(person) { + return debtTo.first(where: { $0.personTo.id == person.id })?.debt + ?? 0 + } + return debtFrom.first(where: { $0.personFrom.id == person.id })?.debt + ?? 0 + } + + public func getDebtsSum(dest: DebtType) -> Double { + switch dest { + case .from: + return debtFrom.reduce(0) { $0 + $1.debt } + case .to: + return debtTo.reduce(0) { $0 + $1.debt } + } + } +} diff --git a/Friends/Network/Debts/DebtsNetwork.swift b/Friends/Network/Debts/DebtsNetwork.swift new file mode 100644 index 0000000..80abe9d --- /dev/null +++ b/Friends/Network/Debts/DebtsNetwork.swift @@ -0,0 +1,99 @@ +// +// DebtsNetwork.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import Foundation +import FirebaseFirestore + +class DebtsNetwork: DebtsNetworkProtocol { + private let firestore = Firestore.firestore() + private let debtsCollection = "debts" + private let usersCollection = "users" + + func loadDebts(for person: Person, completion: @escaping (Result<[Debt], NetworkError>) -> Void) { + firestore.collection(debtsCollection) + .whereField("participants", arrayContains: person.id.uuidString) + .getDocuments { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 421, description: error.localizedDescription))) + return + } + let debts = snapshot?.documents.compactMap { document -> Debt? in + try? document.data(as: Debt.self) + } ?? [] + completion(.success(debts)) + } + } + + func addDebt(_ person: Person, to debt: Debt, completion: @escaping (Result) -> Void) { + let batch = firestore.batch() + + let debtRef = firestore.collection(debtsCollection).document(debt.id.uuidString) + do { + try batch.setData(from: debt, forDocument: debtRef) + } catch { + completion(.failure(.download)) + return + } + + let fromUserRef = firestore.collection(usersCollection).document(debt.personFrom.id.uuidString) + let toUserRef = firestore.collection(usersCollection).document(debt.personTo.id.uuidString) + + batch.updateData([ + "debts": FieldValue.arrayUnion([debt.id.uuidString]) + ], forDocument: fromUserRef) + + batch.updateData([ + "debts": FieldValue.arrayUnion([debt.id.uuidString]) + ], forDocument: toUserRef) + + batch.commit { error in + if let error = error { + completion(.failure(.custom(errorCode: 422, description: error.localizedDescription))) + return + } + completion(.success(())) + } + } + + func removeDebt(_ person: Person, from debtId: UUID, completion: @escaping (Result) -> Void) { + let batch = firestore.batch() + let debtRef = firestore.collection(debtsCollection).document(debtId.uuidString) + + debtRef.getDocument { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 423, description: error.localizedDescription))) + return + } + + guard let debt = try? snapshot?.data(as: Debt.self) else { + completion(.failure(.custom(errorCode: 424, description: "Debt not found"))) + return + } + + batch.deleteDocument(debtRef) + + let fromUserRef = self.firestore.collection(self.usersCollection).document(debt.personFrom.id.uuidString) + let toUserRef = self.firestore.collection(self.usersCollection).document(debt.personTo.id.uuidString) + + batch.updateData([ + "debts": FieldValue.arrayRemove([debtId.uuidString]) + ], forDocument: fromUserRef) + + batch.updateData([ + "debts": FieldValue.arrayRemove([debtId.uuidString]) + ], forDocument: toUserRef) + + batch.commit { error in + if let error = error { + completion(.failure(.custom(errorCode: 425, description: error.localizedDescription))) + return + } + completion(.success(())) + } + } + } +} diff --git a/Friends/Network/Debts/DebtsNetworkProtocol.swift b/Friends/Network/Debts/DebtsNetworkProtocol.swift new file mode 100644 index 0000000..60afada --- /dev/null +++ b/Friends/Network/Debts/DebtsNetworkProtocol.swift @@ -0,0 +1,14 @@ +// +// DebtsNetworkProtocol.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import Foundation + +protocol DebtsNetworkProtocol { + func loadDebts(for person: Person, completion: @escaping (Result<[Debt], NetworkError>) -> Void) + func addDebt(_ person: Person, to debt: Debt, completion: @escaping (Result) -> Void) + func removeDebt(_ person: Person, from debtId: UUID, completion: @escaping (Result) -> Void) +} diff --git a/Friends/Network/Events/EventsNetworkCommunications.swift b/Friends/Network/Events/EventsNetworkCommunications.swift new file mode 100644 index 0000000..ede37ac --- /dev/null +++ b/Friends/Network/Events/EventsNetworkCommunications.swift @@ -0,0 +1,109 @@ +// +// EventsNetworkCommunications.swift +// Friends +// +// Created by Савва Пономарев on 31.03.2025. +// + +import FirebaseFirestore +import Foundation + +class EventsNetworkCommunications: EventsNetworkCommunicationsProtocol { + private let firestore = Firestore.firestore() + private let eventsCollection = "events" + + // MARK: - Events Logic + + func loadEvents(completion: @escaping ([EventModels.Event]) -> Void) { + firestore.collection(eventsCollection).getDocuments { snapshot, error in + if let error = error { + print("Error loading events: \(error.localizedDescription)") + completion([]) + return + } + let events = snapshot?.documents.compactMap { document -> EventModels.Event? in + try? document.data(as: EventModels.Event.self) + } ?? [] + completion(events) + } + } + func addEvent(_ event: EventModels.Event) { + let eventRef = firestore.collection(eventsCollection).document( + event.id.uuidString) + do { + try eventRef.setData(from: event) + sendInvitations(for: event) + } catch { + print("Error adding event: \(error.localizedDescription)") + } + } + func updateAttendance( + eventId: String, userId: String, status: EventModels.AttendanceStatus + ) { + let eventRef = firestore.collection(eventsCollection).document(eventId) + eventRef.updateData([ + "invitedFriends.\(userId).status": status.rawValue + ]) { error in + if let error = error { + print( + "Error updating attendance: \(error.localizedDescription)") + } else { + self.notifyHost( + eventId: eventId, userId: userId, status: status) + } + } + } + func updateEvent(_ event: EventModels.Event) { + let eventRef = firestore.collection(eventsCollection).document( + event.id.uuidString) + do { + try eventRef.setData(from: event, merge: true) + sendEditNotification(for: event) + } catch { + print("Error updating event: \(error.localizedDescription)") + } + } + func deleteEvent(eventId: String) { + firestore.collection(eventsCollection).document(eventId).delete { error in + if let error = error { + print("Error deleting event: \(error.localizedDescription)") + } else { + print("Event deleted successfully") + } + } + } + + // MARK: - Notifications + + private func notifyHost( + eventId: String, userId: String, status: EventModels.AttendanceStatus + ) { + let message = + "User \(userId) has \(status == .attending ? "accepted" : "declined") your event." + sendPushNotification(to: eventId, message: message) + } + private func sendInvitations(for event: EventModels.Event) { + event.attendiesInfo.forEach { info in + let message = "You have been invited to the event \(event.title)." + sendPushNotification(to: info.id.uuidString, message: message) + } + } + private func sendEditNotification(for event: EventModels.Event) { + event.attendiesInfo.forEach { info in + let message = "You have been invited to the event \(event.title)." + sendPushNotification(to: info.id.uuidString, message: message) + } + } + private func sendPushNotification(to userId: String, message: String) { + let payload: [String: Any] = [ + "to": "/topics/\(userId)", + "notification": [ + "title": "Event Update", + "body": message + ] + ] + // TODO: make temp realization via local notifications + print("Sending push notification: \(payload)") + } + +} diff --git a/Friends/Network/Events/EventsNetworkCommunicationsProtocol.swift b/Friends/Network/Events/EventsNetworkCommunicationsProtocol.swift new file mode 100644 index 0000000..7f1d787 --- /dev/null +++ b/Friends/Network/Events/EventsNetworkCommunicationsProtocol.swift @@ -0,0 +1,17 @@ +// +// EventsNetworkCommunicationsProtocol.swift +// Friends +// +// Created by Савва Пономарев on 31.03.2025. +// + +import Foundation + +protocol EventsNetworkCommunicationsProtocol { + func loadEvents(completion: @escaping ([EventModels.Event]) -> Void) + func addEvent(_ event: EventModels.Event) + func updateAttendance( + eventId: String, userId: String, status: EventModels.AttendanceStatus) + func updateEvent(_ event: EventModels.Event) + func deleteEvent(eventId: String) +} diff --git a/Friends/Network/Friends/FriendsNetwork.swift b/Friends/Network/Friends/FriendsNetwork.swift new file mode 100644 index 0000000..cec269a --- /dev/null +++ b/Friends/Network/Friends/FriendsNetwork.swift @@ -0,0 +1,110 @@ +// +// FriendsNetwork.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import FirebaseFirestore +import Foundation + +class FriendsNetwork: FriendsNetworkProtocol { + private let firestore = Firestore.firestore() + private let usersCollection = "users" + private let friendRequestsCollection = "friendRequests" + + func sendFriendRequest(id: UUID, to friendId: UUID, completion: @escaping (Result) -> Void) { + let batch = firestore.batch() + let requestId = UUID().uuidString + + let requestRef = firestore.collection(friendRequestsCollection).document(requestId) + let requestData: [String: Any] = [ + "id": requestId, + "fromUserId": id.uuidString, + "toUserId": friendId.uuidString, + "status": "pending", + "createdAt": Timestamp(date: Date()) + ] + batch.setData(requestData, forDocument: requestRef) + + let userRef = firestore.collection(usersCollection).document(id.uuidString) + let friendRef = firestore.collection(usersCollection).document(friendId.uuidString) + + batch.updateData([ + "friends": FieldValue.arrayUnion([friendId.uuidString]) + ], forDocument: userRef) + + batch.updateData([ + "friends": FieldValue.arrayUnion([id.uuidString]) + ], forDocument: friendRef) + + batch.commit { error in + if let error = error { + completion(.failure(.custom(errorCode: 427, description: error.localizedDescription))) + return + } + completion(.success(())) + } + } + + func loadFriends(id: UUID, completion: @escaping (Result<[Person], NetworkError>) -> Void) { + firestore.collection(usersCollection).document(id.uuidString) + .getDocument { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 433, description: error.localizedDescription))) + return + } + + guard let data = snapshot?.data(), + let friendsIds = data["friends"] as? [String] else { + completion(.success([])) + return + } + let group = DispatchGroup() + var friends: [Person] = [] + + for friendId in friendsIds { + group.enter() + self.firestore.collection(self.usersCollection).document(friendId).getDocument { friendSnapshot, error in + if error != nil { + completion(.failure(.download)) + group.leave() + return + } + if let friendData = friendSnapshot?.data(), + let friend = try? Firestore.Decoder().decode(Person.self, from: friendData) { + friends.append(friend) + } + group.leave() + } + } + + group.notify(queue: .main) { + completion(.success(friends)) + } + } + } + + func removeFriend(id: UUID, with friendId: UUID, completion: @escaping (Result) -> Void) { + let batch = firestore.batch() + + let userRef = firestore.collection(usersCollection).document(id.uuidString) + let friendRef = firestore.collection(usersCollection).document(friendId.uuidString) + + batch.updateData([ + "friends": FieldValue.arrayRemove([friendId.uuidString]) + ], forDocument: userRef) + + batch.updateData([ + "friends": FieldValue.arrayRemove([id.uuidString]) + ], forDocument: friendRef) + + batch.commit { error in + if let error = error { + completion(.failure(.custom(errorCode: 434, description: error.localizedDescription))) + return + } + completion(.success(())) + } + } +} diff --git a/Friends/Network/Friends/FriendsNetworkProtocol.swift b/Friends/Network/Friends/FriendsNetworkProtocol.swift new file mode 100644 index 0000000..195d4ef --- /dev/null +++ b/Friends/Network/Friends/FriendsNetworkProtocol.swift @@ -0,0 +1,14 @@ +// +// FriendsNetworkProtocol.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import Foundation + +protocol FriendsNetworkProtocol { + func sendFriendRequest(id: UUID, to friendId: UUID, completion: @escaping (Result) -> Void) + func loadFriends(id: UUID, completion: @escaping (Result<[Person], NetworkError>) -> Void) + func removeFriend(id: UUID, with friendId: UUID, completion: @escaping (Result) -> Void) +} diff --git a/Friends/Network/Groups/GroupsNetwork.swift b/Friends/Network/Groups/GroupsNetwork.swift new file mode 100644 index 0000000..bd20457 --- /dev/null +++ b/Friends/Network/Groups/GroupsNetwork.swift @@ -0,0 +1,74 @@ +// +// GroupsNetwork.swift +// Friends +// +// Created by Савва Пономарев on 04.04.2025. +// + +import Foundation +import FirebaseFirestore +import FirebaseStorage + + +class GroupsNetwork: GroupsNetworkProtocol { + private let firestore = Firestore.firestore() + private let storage = Storage.storage() + private let usersCollection = "users" + private let groupsCollection = "groups" + + // MARK: - Group Logic + func createGroup(name: String, members: [Person], completion: @escaping (Result) -> Void) { + let groupID = UUID().uuidString + let groupData: [String: Any] = [ + "id": groupID, + "name": name, + "members": members.map { $0.id.uuidString } + ] + + firestore.collection(groupsCollection).document(groupID).setData(groupData) { error in + if let error = error { + completion(.failure(.custom(errorCode: 500, description: error.localizedDescription))) + } else { + completion(.success(groupID)) + } + } + } + + func addPersonToGroup(groupID: String, person: Person, completion: @escaping (Result) -> Void) { + let groupRef = firestore.collection(groupsCollection).document(groupID) + + groupRef.updateData([ + "members": FieldValue.arrayUnion([person.id.uuidString]) + ]) { error in + if let error = error { + completion(.failure(.custom(errorCode: 501, description: error.localizedDescription))) + } else { + completion(.success(())) + } + } + } + + func removePersonFromGroup(groupID: String, person: Person, completion: @escaping (Result) -> Void) { + let groupRef = firestore.collection(groupsCollection).document(groupID) + + groupRef.updateData([ + "members": FieldValue.arrayRemove([person.id.uuidString]) + ]) { error in + if let error = error { + completion(.failure(.custom(errorCode: 502, description: error.localizedDescription))) + } else { + completion(.success(())) + } + } + } + + func deleteGroup(groupID: String, completion: @escaping (Result) -> Void) { + firestore.collection(groupsCollection).document(groupID).delete { error in + if let error = error { + completion(.failure(.custom(errorCode: 503, description: error.localizedDescription))) + } else { + completion(.success(())) + } + } + } +} diff --git a/Friends/Network/Groups/GroupsNetworkProtocol.swift b/Friends/Network/Groups/GroupsNetworkProtocol.swift new file mode 100644 index 0000000..5b0ce9c --- /dev/null +++ b/Friends/Network/Groups/GroupsNetworkProtocol.swift @@ -0,0 +1,15 @@ +// +// GroupsNetworkProtocol.swift +// Friends +// +// Created by Савва Пономарев on 04.04.2025. +// + +import Foundation + +protocol GroupsNetworkProtocol { + func createGroup(name: String, members: [Person], completion: @escaping (Result) -> Void) + func addPersonToGroup(groupID: String, person: Person, completion: @escaping (Result) -> Void) + func removePersonFromGroup(groupID: String, person: Person, completion: @escaping (Result) -> Void) + func deleteGroup(groupID: String, completion: @escaping (Result) -> Void) +} diff --git a/Friends/Network/Notifications.swift b/Friends/Network/Notifications.swift new file mode 100644 index 0000000..42ab469 --- /dev/null +++ b/Friends/Network/Notifications.swift @@ -0,0 +1,77 @@ +// +// Notifications.swift +// Friends +// +// Created by Савва Пономарев on 31.03.2025. +// + +import Foundation +import Firebase + +class Notifications { + + // MARK: - User Account Interactions + + func saveFCMToken(for userId: String, token: String) { + let db = Firestore.firestore() + db.collection("users").document(userId).setData(["fcmToken": token], merge: true) { error in + if let error = error { + print("Error saving FCM token: \(error.localizedDescription)") + } else { + print("FCM token saved successfully") + } + } + } + func getFCMTokens(for userIds: [String], completion: @escaping ([String]) -> Void) { + let db = Firestore.firestore() + var tokens: [String] = [] + + let group = DispatchGroup() + + userIds.forEach { userId in + group.enter() + db.collection("users").document(userId).getDocument { document, _ in + if let token = document?.data()?["fcmToken"] as? String { + tokens.append(token) + } + group.leave() + } + } + + group.notify(queue: .main) { + completion(tokens) + } + } + + // MARK: - Send Notification + + func sendFCMMessage(to tokens: [String], title: String, body: String) { +// TODO: paste url + guard let url = URL(string: "") else { return } + + let payload: [String: Any] = [ + "tokens": tokens, + "title": title, + "body": body + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: .prettyPrinted) + } catch { + print("Error encoding payload: \(error.localizedDescription)") + return + } + + URLSession.shared.dataTask(with: request) { _, _, error in + if let error = error { + print("Error sending notification: \(error.localizedDescription)") + } else { + print("Notification sent successfully") + } + }.resume() + } +} diff --git a/Friends/Network/People/AuthNetwork.swift b/Friends/Network/People/AuthNetwork.swift new file mode 100644 index 0000000..5e8322a --- /dev/null +++ b/Friends/Network/People/AuthNetwork.swift @@ -0,0 +1,148 @@ +// +// AuthNetwork.swift +// Friends +// +// Created by Савва Пономарев on 03.04.2025. +// + +import FirebaseFirestore +import FirebaseStorage +import Foundation +import CryptoKit + +class AuthNetwork { + private let firestore = Firestore.firestore() + private let storage = Storage.storage() + private let usersCollection = "users" + private let authCollection = "auth" + +// MARK: - Account Creation + + func createAccount(name: String, username: String, password: String, completion: @escaping (Result) -> Void) { + firestore.collection(authCollection) + .whereField("username", isEqualTo: username) + .getDocuments { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 500, description: error.localizedDescription))) + return + } +// MARK: - check for unique username + + if let snapshot = snapshot, !snapshot.documents.isEmpty { + completion(.failure(.custom(errorCode: 409, description: "Username already exists"))) + return + } +// MARK: - create new person + + let personId = UUID() + let person = Person( + id: personId, + name: name, + username: username, + password: password, + imageURL: nil, + friends: [], + debts: [] + ) +// MARK: - create auth document + + let authData: [String: Any] = [ + "userId": personId.uuidString, + "name": name, + "username": username, + "password": password + ] + + let batch = self.firestore.batch() + let userRef = self.firestore.collection(self.usersCollection).document(personId.uuidString) + let authRef = self.firestore.collection(self.authCollection).document(personId.uuidString) + + do { + try batch.setData(from: person, forDocument: userRef) + batch.setData(authData, forDocument: authRef) + + batch.commit { error in + if let error = error { + completion(.failure(.custom(errorCode: 500, description: error.localizedDescription))) + return + } + self.cacheUserData(person: person) + completion(.success(person)) + } + } catch { + completion(.failure(.custom(errorCode: 500, description: "Failed to create user"))) + } + } + } + +// MARK: - Login + func login(username: String, password: String, completion: @escaping (Result) -> Void) { + let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + + firestore.collection(authCollection) + .whereField("username", isEqualTo: trimmedName) + .whereField("password", isEqualTo: trimmedPassword) + .getDocuments { snapshot, error in + print(trimmedName, trimmedPassword) + if let error = error { + completion(.failure(.custom(errorCode: 500, description: error.localizedDescription))) + return + } + + guard let document = snapshot?.documents.first, + let userId = document.data()["userId"] as? String else { + completion(.failure(.custom(errorCode: 401, description: "Invalid credentials"))) + return + } + self.firestore.collection(self.usersCollection).document(userId).getDocument { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 500, description: error.localizedDescription))) + return + } + guard let snapshot = snapshot, + let person = try? snapshot.data(as: Person.self) else { + completion(.failure(.custom(errorCode: 404, description: "User not found"))) + return + } + self.cacheUserData(person: person) + + completion(.success(person)) + } + } + } + +// MARK: - Cache Account (placeholder) + + private func cacheUserData(person: Person) { + let cache = UserDataCache() + let userInfo: [String: Any] = [ + "username": person.username.trimmingCharacters(in: .whitespacesAndNewlines), + "password": person.password.trimmingCharacters(in: .whitespacesAndNewlines) + ] + cache.saveUserInfo(userInfo: userInfo) + } + +// MARK: - Name Unique check + + func checkNameAvailability(username: String, completion: @escaping (Result) -> Void) { + firestore.collection(authCollection) + .whereField("username", isEqualTo: username) + .getDocuments { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 500, description: error.localizedDescription))) + return + } + + let isAvailable = snapshot?.documents.isEmpty ?? true + completion(.success(isAvailable)) + } + } +// MARK: - Hash Password + +// func hashPassword(_ password: String) -> String { +// let data = Data(password.utf8) +// let hash = SHA256.hash(data: data) +// return hash.compactMap { String(format: "%02x", $0) }.joined() +// } +} diff --git a/Friends/Network/People/PersonNetwork.swift b/Friends/Network/People/PersonNetwork.swift new file mode 100644 index 0000000..a5ff777 --- /dev/null +++ b/Friends/Network/People/PersonNetwork.swift @@ -0,0 +1,132 @@ +// +// PersonNetwork.swift +// Friends +// +// Created by Савва Пономарев on 01.04.2025. +// + +import FirebaseFirestore +import FirebaseStorage +import Foundation + +class PersonNetwork: PersonNetworkProtocol { + private let firestore = Firestore.firestore() + private let storage = Storage.storage() + private let usersCollection = "users" + + // MARK: - User Account Logic + + func createAccount(_ person: Person, completion: @escaping (Result) -> Void) { + let userRef = firestore.collection(usersCollection).document(person.id.uuidString) + do { + try userRef.setData(from: person) + completion(.success(())) + } catch { + completion(.failure(.download)) + } + } + + func updateAccount(_ person: Person, completion: @escaping (Result) -> Void) { + let userRef = firestore.collection(usersCollection).document(person.id.uuidString) + do { + try userRef.setData(from: person, merge: true) + completion(.success(())) + } catch { + completion(.failure(.download)) + } + } + + func deleteAccount(with id: UUID, completion: @escaping (Result) -> Void) { + let userRef = firestore.collection(usersCollection).document(id.uuidString) + userRef.delete { error in + if let error = error { + completion(.failure(.custom(errorCode: 420, description: error.localizedDescription))) + return + } + completion(.success(())) + } + } + + func uploadIcon(for person: Person, image: UIImage, completion: @escaping (Result) -> Void) { + guard let imageData = image.pngData() else { + completion(.failure(.custom(errorCode: 412, description: "No image data"))) + return + } + + let userStorage = storage.reference().child("users/\(person.id.uuidString)/icon.jpg") + + userStorage.delete { error in + userStorage.putData(imageData, metadata: nil) { _, error in + if let error = error { + completion(.failure(.custom(errorCode: 412, description: error.localizedDescription))) + return + } + userStorage.downloadURL { url, error in + if error != nil { + completion(.failure(.download)) + return + } + guard let url = url else { + completion(.failure(.custom(errorCode: 412, description: "No download URL"))) + return + } + let userRef = self.firestore.collection("users").document(person.id.uuidString) + userRef.updateData([ + "imageURL": url.absoluteString + ]) { error in + if let error = error { + completion(.failure(.custom(errorCode: 413, description: "Failed to update user profile: \(error.localizedDescription)"))) + return + } + completion(.success(url)) + } + } + } + } + } + + func findUser(by prefix: String, completion: @escaping (Result<[Person], NetworkError>) -> Void) { + let start = prefix + let end = prefix + "\u{f8ff}" + + firestore.collection(usersCollection) + .whereField("username", isGreaterThanOrEqualTo: start) + .whereField("username", isLessThan: end) + .getDocuments { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 435, description: error.localizedDescription))) + return + } + let users = snapshot?.documents.compactMap { document -> Person? in + try? document.data(as: Person.self) + } ?? [] + completion(.success(users)) + } + } + + func findUser(by userId: UUID, completion: @escaping (Result) -> Void) { + firestore.collection(usersCollection).document(userId.uuidString).getDocument { snapshot, error in + if let error = error { + completion(.failure(.custom(errorCode: 436, description: error.localizedDescription))) + return + } + + guard let document = snapshot, document.exists else { + completion(.failure(.custom(errorCode: 437, description: "User not found"))) + return + } + + do { + var person = try document.data(as: Person.self) + + if document.data()?["debts"] == nil { + person.debts = [] + } + + completion(.success(person)) + } catch { + completion(.failure(.custom(errorCode: 439, description: "Failed to decode user: \(error.localizedDescription)"))) + } + } + } +} diff --git a/Friends/Network/People/PersonNetworkProtocol.swift b/Friends/Network/People/PersonNetworkProtocol.swift new file mode 100644 index 0000000..ee814f3 --- /dev/null +++ b/Friends/Network/People/PersonNetworkProtocol.swift @@ -0,0 +1,21 @@ +// +// PeopleNetworkProtocol.swift +// Friends +// +// Created by Савва Пономарев on 01.04.2025. +// + +import Foundation +import UIKit + +protocol PersonNetworkProtocol { + // MARK: - User Account Logic + + func createAccount(_ person: Person, completion: @escaping (Result) -> Void) + func updateAccount(_ person: Person, completion: @escaping (Result) -> Void) + func deleteAccount(with id: UUID, completion: @escaping (Result) -> Void) + func uploadIcon(for person: Person, image: UIImage, completion: @escaping (Result) -> Void) + // MARK: - Find Users + + func findUser(by prefix: String, completion: @escaping (Result<[Person], NetworkError>) -> Void) +} diff --git a/Friends/Screens/AddEventScreen/View/AddEventView.swift b/Friends/Screens/AddEventScreen/View/AddEventView.swift new file mode 100644 index 0000000..bf69e72 --- /dev/null +++ b/Friends/Screens/AddEventScreen/View/AddEventView.swift @@ -0,0 +1,187 @@ +// +// AddEventView.swift +// Friends +// +// Created by тимур on 27.03.2025. +// + +import SwiftUI + +struct AddEventView: View { + @StateObject private var viewModel = AddEventViewModel() + @State var isShowingSelectFriendsView: Bool = false + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack { + Header(viewModel: viewModel) + .padding([.horizontal, .top]) + + List { + Section { + TextField("Название", text: $viewModel.event.title) + TextField("Описание", text: $viewModel.event.description) + } + + Section { + LocationView(addLocation: $viewModel.addLocation, address: $viewModel.event.address) + } + + Section { + FriendsList(viewModel: viewModel, isShowingSelectFriendsView: $isShowingSelectFriendsView) + } header: { + Text("Друзья") + } + + Section { + VStack(alignment: .trailing) { + DaysView(viewModel: viewModel) + .padding(.leading, 20) + + HStack { + HoursView() + + TimeGrid(selectedCells: $viewModel.selectedCells, rows: 16, columns: 7) + } + } + .padding(.vertical) + .listRowBackground(Color.white) + } header: { + HStack { + Text("Время") + Spacer() + Button(action: { + viewModel.selectAllCells() + }, label: { + Text("Выбрать все") + .textCase(.none) + .font(.system(size: 16)) + .fontWeight(.medium) + }) + .padding(.trailing, 10) + Button(action: { + viewModel.clearCells() + }, label: { + Text("Отчистить") + .textCase(.none) + .font(.system(size: 16)) + }) + } + } + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + .sheet(isPresented: $isShowingSelectFriendsView) { + SelectFriendsView(friends: viewModel.friends, selectedFriends: $viewModel.selectedFriends) + } + } + .background(Color.background) + .onAppear { + viewModel.loadFriends() + } + } + + private struct Header: View { + @ObservedObject var viewModel: AddEventViewModel + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack(alignment: .trailing) { + HStack { + Spacer() + Text("Новая встреча") + .fontWeight(.medium) + Spacer() + } + + HStack { + Button("Отмена") { + dismiss() + } + Spacer() + Button("Создать") { + viewModel.addEvent() + dismiss() + } + .disabled(viewModel.event.title.isEmpty || viewModel.selectedCells.isEmpty) + .fontWeight(.bold) + } + } + } + } + + private struct FriendsList: View { + @ObservedObject var viewModel: AddEventViewModel + @Binding var isShowingSelectFriendsView: Bool + + var body: some View { + ForEach(Array(viewModel.selectedFriends)) { person in + HStack { + Image(uiImage: person.icon) + .resizable() + .frame(width: 40.0, height: 40.0) + .clipShape(Circle()) + Text(person.name) + Spacer() + } + } + + Button(viewModel.selectedFriends.isEmpty ? "Добавить друзей" : "Редактировать") { + isShowingSelectFriendsView = true + } + } + } + + private struct LocationView: View { + @Binding var addLocation: Bool + @Binding var address: String + + var body: some View { + HStack { + Image(systemName: "location.square.fill") + .resizable() + .frame(width: 25, height: 25) + .foregroundStyle(Color.blue) + Toggle("Геолокация", isOn: $addLocation) + } + + if addLocation { + TextField("Start typing", text: $address) + } + } + } + + private struct DaysView: View { + @ObservedObject var viewModel: AddEventViewModel + + var body: some View { + HStack { + ForEach(0..<7) { offset in + let date = Date().addingTimeInterval(TimeInterval(86400 * offset)) + Text("\(viewModel.getFormattedDate(from: date))") + .font(.system(size: 16)) + .foregroundStyle(Color.gray) + .frame(maxWidth: .infinity) + } + } + } + } + + private struct HoursView: View { + var body: some View { + VStack { + let hours = Array(8...23) + ForEach(hours, id: \.self) { hour in + Text("\(hour)") + .foregroundStyle(Color.gray) + .font(.system(size: 12)) + .frame(maxHeight: .infinity) + } + } + } + } +} + +#Preview { + AddEventView() +} diff --git a/Friends/Screens/AddEventScreen/ViewController/AddEventViewController.swift b/Friends/Screens/AddEventScreen/ViewController/AddEventViewController.swift new file mode 100644 index 0000000..0406698 --- /dev/null +++ b/Friends/Screens/AddEventScreen/ViewController/AddEventViewController.swift @@ -0,0 +1,34 @@ +// +// AddEventViewController.swift +// Friends +// +// Created by тимур on 28.03.2025. +// + +import UIKit +import SwiftUI +import SnapKit + +final class AddEventViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + let swiftUIView = AddEventView() + let hostingController = UIHostingController(rootView: swiftUIView) + + addChild(hostingController) + view.addSubview(hostingController.view) + + hostingController.view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + hostingController.didMove(toParent: self) + } +} + +@available(iOS 17.0, *) +#Preview { + AddEventViewController() +} diff --git a/Friends/Screens/AddEventScreen/ViewModel/AddEventViewModel.swift b/Friends/Screens/AddEventScreen/ViewModel/AddEventViewModel.swift new file mode 100644 index 0000000..8497ea9 --- /dev/null +++ b/Friends/Screens/AddEventScreen/ViewModel/AddEventViewModel.swift @@ -0,0 +1,71 @@ +// +// AddEventViewModel.swift +// Friends +// +// Created by тимур on 27.03.2025. +// + +import SwiftUI +import MapKit + +final class AddEventViewModel: NSObject, ObservableObject { + @Published var event = EventModels.Event.empty + @Published var friends = [Person]() + @Published var selectedFriends = Set() + @Published var selectedCells: Set = [] + @Published var addLocation: Bool = false + @Published var locationText: String = "" + + let rows = 16 + let columns = 7 + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEE" + return formatter + }() + private let generator = UIImpactFeedbackGenerator(style: .medium) + private let friendsProvider = FriendsNetwork() + private let eventProvider = EventsNetworkCommunications() + let id = AppCache.shared.user!.id // TODO: remove + + func selectAllCells() { + for row in 0.. String { + dateFormatter.string(from: date) + } + + func hapticFeedback() { + generator.impactOccurred() + } +} diff --git a/Friends/Screens/AddFriendScreen/View/AddFriendView.swift b/Friends/Screens/AddFriendScreen/View/AddFriendView.swift new file mode 100644 index 0000000..7b31fed --- /dev/null +++ b/Friends/Screens/AddFriendScreen/View/AddFriendView.swift @@ -0,0 +1,50 @@ +// +// AddFriendView.swift +// Friends +// +// Created by тимур on 03.04.2025. +// + +import SwiftUI + +struct AddFriendView: View { + @StateObject var viewModel = AddFriendViewModel() + + var body: some View { + NavigationStack { + List { + ForEach(viewModel.users) { person in + HStack { + Image(uiImage: person.icon) + .resizable() + .frame(width: 40.0, height: 40.0) + .clipShape(Circle()) + Text(person.name) + Spacer() + Button(action: { + // TODO: Add Friend Action + viewModel.addFriend(friendId: person.id) + }) { + Image(systemName: "plus") + } + } + } + } + .navigationTitle("Добавить друга") + .searchable(text: $viewModel.searchText, prompt: "Поиск по логину") + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onChange(of: viewModel.searchText) { newValue in + if newValue.count > 2 { + viewModel.searchUsers() + } else { + viewModel.users = [] + } + } + } + } +} + +#Preview { + AddFriendView() +} diff --git a/Friends/Screens/AddFriendScreen/ViewModel/AddFriendViewModel.swift b/Friends/Screens/AddFriendScreen/ViewModel/AddFriendViewModel.swift new file mode 100644 index 0000000..bb532b0 --- /dev/null +++ b/Friends/Screens/AddFriendScreen/ViewModel/AddFriendViewModel.swift @@ -0,0 +1,50 @@ +// +// AddFriendViewModel.swift +// Friends +// +// Created by тимур on 03.04.2025. +// + +import Foundation + +final class AddFriendViewModel: ObservableObject { + @Published var searchText: String = "" + @Published var users = [Person]() + @Published var errorMessage = "" + @Published var isLoading: Bool = false + + private let peopleProvider = PersonNetwork() + private let friendProvider = FriendsNetwork() + private let id = AppCache.shared.user!.id + + func searchUsers() { + peopleProvider.findUser(by: searchText) { [weak self] result in + switch result { + case .success(let persons): + self?.users = persons + case .failure(let error): + self?.errorMessage = error.localizedDescription + } + } + } + + func isFriend(friendId: UUID, completion: @escaping (Bool) -> Void) { + friendProvider.loadFriends(id: id) { [weak self] result in + switch result { + case .success(let friends): + if friends.contains(where: { $0.id == friendId }) { + completion(true) + } + + completion(false) + case .failure(let error): + self?.errorMessage = error.localizedDescription + completion(false) + } + } + } + + func addFriend(friendId: UUID) { + friendProvider.sendFriendRequest(id: id, to: friendId) { _ in } + } +} diff --git a/Friends/Screens/AuthScreen/View/AuthView.swift b/Friends/Screens/AuthScreen/View/AuthView.swift new file mode 100644 index 0000000..df92912 --- /dev/null +++ b/Friends/Screens/AuthScreen/View/AuthView.swift @@ -0,0 +1,75 @@ +// +// AuthentificationScreen.swift +// Friends +// +// Created by тимур on 03.04.2025. +// + +import SwiftUI + +struct AuthView: View { + @StateObject var viewModel: AuthViewModel + + var body: some View { + VStack { + HStack { + Image("icon") + .resizable() + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Text("Friends") + .font(.system(size: 42, weight: .bold, design: .rounded)) + .opacity(0.8) + } + .padding(.top, 200) + + List { + if viewModel.mode == .registration { + TextField("Name", text: $viewModel.name) + } + + TextField("Username", text: $viewModel.username) + TextField("Password", text: $viewModel.password) + } + + VStack { + HStack(alignment: .bottom) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.orange) + .font(.system(size: 16)) + + Text(viewModel.errorMessage) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color.orange) + } + .padding(.bottom, 5) + .opacity(viewModel.showErrorAlert ? 1 : 0) + .offset(y: viewModel.showErrorAlert ? 0 : 10) + .animation(.easeInOut, value: viewModel.showErrorAlert) + + Button(viewModel.mode == .login ? "Login" : "Create Account") { + viewModel.showErrorAlert = false + viewModel.authenticate() + } + .frame(width: 170, height: 50) + .background(Color.blue) + .foregroundStyle(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .padding(.bottom, 10) + + Button(viewModel.mode == .login ? "CreateAccount" : "Login") { + viewModel.showErrorAlert = false + if viewModel.mode == .login { + viewModel.mode = .registration + } else { + viewModel.mode = .login + } + } + } + .padding(.bottom, 50) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .background(Color.background) + } +} diff --git a/Friends/Screens/AuthScreen/ViewController/AuthViewController.swift b/Friends/Screens/AuthScreen/ViewController/AuthViewController.swift new file mode 100644 index 0000000..41c2e2a --- /dev/null +++ b/Friends/Screens/AuthScreen/ViewController/AuthViewController.swift @@ -0,0 +1,38 @@ +// +// AuthentificationViewController.swift +// Friends +// +// Created by тимур on 03.04.2025. +// + +import UIKit +import SwiftUI +import SnapKit + +final class AuthViewController: UIViewController { + + var onAuthSuccess: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + + let viewModel = AuthViewModel() + viewModel.onAuthSuccess = { [weak self] in + self?.dismiss(animated: true) { + self?.onAuthSuccess?() // Notify `TabBarController` after closing + } + } + + let swiftUIView = AuthView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: swiftUIView) + + addChild(hostingController) + view.addSubview(hostingController.view) + + hostingController.view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + hostingController.didMove(toParent: self) + } +} diff --git a/Friends/Screens/AuthScreen/ViewModel/AuthViewModel.swift b/Friends/Screens/AuthScreen/ViewModel/AuthViewModel.swift new file mode 100644 index 0000000..704add0 --- /dev/null +++ b/Friends/Screens/AuthScreen/ViewModel/AuthViewModel.swift @@ -0,0 +1,96 @@ +// +// AuthentificationViewModel.swift +// Friends +// +// Created by тимур on 03.04.2025. +// + +import Foundation + +final class AuthViewModel: ObservableObject { + enum Mode { + case login + case registration + } + + @Published var mode: Mode = .login + @Published var name = "" + @Published var username = "" + @Published var password = "" + @Published var errorMessage = "" + @Published var showErrorAlert = false + + private let authProvider = AuthNetwork() + private let userCache = UserDataCache() + + var onAuthSuccess: (() -> Void)? + + func authenticate() { + if username.isEmpty { + errorMessage = "Enter username" + showErrorAlert = true + return + } + + if password.isEmpty { + errorMessage = "Enter password" + showErrorAlert = true + return + } + + if mode == .registration { + if name.isEmpty { + errorMessage = "Enter name" + showErrorAlert = true + return + } + + authProvider.checkNameAvailability(username: username) { [weak self] result in + switch result { + case .success(let isAvailable): + if !isAvailable { + self?.errorMessage = "Username already taken" + self?.showErrorAlert = true + return + } + case .failure(let error): + self?.errorMessage = error.localizedDescription + self?.showErrorAlert = true + return + } + } + + authProvider.createAccount(name: name, username: username, password: password) { [weak self] result in + switch result { + case .success(let person): + print("Account created") + AppCache.shared.user = person + self?.userCache.saveUserInfo(userInfo: ["username": self?.username ?? "", "password": self?.password ?? ""]) + + DispatchQueue.main.async { + self?.onAuthSuccess?() + } + case .failure(let error): + self?.errorMessage = error.localizedDescription + self?.showErrorAlert = true + } + } + } else { + authProvider.login(username: username, password: password) { [weak self] result in + switch result { + case .success(let person): + print("Login success") + AppCache.shared.user = person + self?.userCache.saveUserInfo(userInfo: ["username": self?.username ?? "", "password": self?.password ?? ""]) + + DispatchQueue.main.async { + self?.onAuthSuccess?() + } + case .failure(let error): + self?.errorMessage = error.localizedDescription + self?.showErrorAlert = true + } + } + } + } +} diff --git a/Friends/Screens/EventScreen/Components/Animations/TableViewAnimator.swift b/Friends/Screens/EventScreen/Components/Animations/TableViewAnimator.swift new file mode 100644 index 0000000..9ef6766 --- /dev/null +++ b/Friends/Screens/EventScreen/Components/Animations/TableViewAnimator.swift @@ -0,0 +1,27 @@ +// +// TableViewAnimator.swift +// Friends +// +// Created by Алексей on 26.03.2025. +// + +import Foundation +import UIKit + +final class TableViewAnimator { + // MARK: - Properties + + private let animation: TableCellAnimation + + // MARK: - Initialization + + init(animation: @escaping TableCellAnimation) { + self.animation = animation + } + + // MARK: - Public functions + + func animate(cell: UITableViewCell, at indexPath: IndexPath, for tableView: UITableView) { + animation(cell, indexPath, tableView) + } +} diff --git a/Friends/Screens/EventScreen/EventAssembly.swift b/Friends/Screens/EventScreen/EventAssembly.swift new file mode 100644 index 0000000..a1a7a3b --- /dev/null +++ b/Friends/Screens/EventScreen/EventAssembly.swift @@ -0,0 +1,19 @@ +// +// EventAssembly.swift +// Friends +// +// Created by Алексей on 29.03.2025. +// +import UIKit + +final class EventAssembly { + static func build() -> UIViewController { + let view = EventViewController() + let networkManager: EventsNetworkCommunications = EventsNetworkCommunications() + let dataManager = DataManager(eventNetworkManager: networkManager) + let presenter = EventPresenter(view: view, dataManager: dataManager) + view.presenter = presenter + + return view + } +} diff --git a/Friends/Screens/EventScreen/EventCell.swift b/Friends/Screens/EventScreen/EventCell.swift new file mode 100644 index 0000000..3a56ba1 --- /dev/null +++ b/Friends/Screens/EventScreen/EventCell.swift @@ -0,0 +1,302 @@ +// +// EventCell.swift +// Friends +// +// Created by Алексей on 26.03.2025. +// + +import Foundation +import UIKit +import MapKit + +final class EventCell: UITableViewCell { + // MARK: - Constants + + private enum Constants { + static let wrapOffsetV: CGFloat = 5 + static let wrapRadius: CGFloat = 20 + + static let imageViewWidth: CGFloat = 140 + static let imageViewOffset: CGFloat = 10 + static let imageRadius: CGFloat = 9 + + static let titleOffsetH: CGFloat = 25 + static let titleHeight: CGFloat = 35 + static let titleFont: UIFont = .systemFont(ofSize: 24) + + static let infoLabelHeight: CGFloat = 16 + static let infoLabelFont: UIFont = .systemFont(ofSize: 12) + static let infoRadius: CGFloat = 5 + static let infoOffsetTop: CGFloat = 5 + + static let friendsImagesSize: CGFloat = 25 + static let friendsImagesRadius: CGFloat = 12.5 + + static let counterFont: UIFont = .systemFont(ofSize: 10) + + static let overlapOffset: CGFloat = 12 + + static let awaitingStatusImage: UIImage? = UIImage(systemName: "questionmark.circle.fill") + static let goingStatusImage: UIImage? = UIImage(systemName: "checkmark.circle.fill") + static let declinedStatusImage: UIImage? = UIImage(systemName: "x.circle.fill") + static let statusImageSize: CGFloat = 12 + static let statusImageOffsetBottom: CGFloat = 12 + static let statusImageOffsetRight: CGFloat = 15 + + static let statusLabelOffsetLeft: CGFloat = 2 + } + + // MARK: - Properties + + static let reuseIdentifier: String = "EventCell" + + private let wrapView: UIView = UIView() + private let titleLabel: UILabel = UILabel() + private let addressLabel: UILabel = UILabel() + private let dateLabel: UILabel = UILabel() + private let image: UIImageView = UIImageView() + private let mapView: MKMapView = MKMapView() + private let friendsImageViews: [UIImageView] = [ + UIImageView(), + UIImageView(), + UIImageView() + ] + private let statusLabel: UILabel = UILabel() + private let statusImageView: UIImageView = UIImageView() + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private functions + + private func configureUI() { + backgroundColor = .clear + selectionStyle = .none + configureWrap() + configureImage() + configureTitleLabel() + configureAddressLabel() + configureDateLabel() + configureFriendsImages() + configureStatus() + configureRegionView() + } + + // MARK: - Cell Configuration + + func configure(with event: EventModels.Event) { + titleLabel.text = event.title + addressLabel.text = event.address + dateLabel.text = event.startTime?.description + for ind in 0.. Int { + return dataManager.loadEvents().count + } + + func numberOfArchived() -> Int { + return dataManager.loadArchive().count + } + + func configureEvent(cell: EventCell, at index: Int) { + let event = dataManager.loadEvents()[index] + cell.configure(with: event) + } + + func configureArchived(cell: EventCell, at index: Int) { + let event = dataManager.loadArchive()[index] + cell.configure(with: event) + } + + func didAcceptEvent(at index: Int) { + let event = dataManager.loadEvents()[index] + dataManager.updateEventAttendanceStatus(status: .attending, at: index) + view?.updateEvent(at: index, event: event) + } + + func didDeclineEvent(at index: Int) { + let event = dataManager.loadEvents()[index] + dataManager.moveToArchive(eventIndex: index) + view?.moveEventToArchive(event: event, from: index) + } + + func didRestoreEventFromArchive(at index: Int) { + let event = dataManager.loadArchive()[index] + dataManager.restoreFromArchive(eventIndex: index) + view?.moveEventFromArchive(event: event, from: index) + } + + func didSelectEvent(at index: Int) { + let event = dataManager.loadEvents()[index] + let viewEventVC = ViewEventViewController(event: event) + view?.displayViewEventViewController(viewEventVC) + } + + func addEvent() { + let addEventVC = AddEventViewController() + view?.displayAddEventViewController(addEventVC) + } + + // MARK: - Private functions + + private func loadInitialData() { + // TODO: Здесь должна быть логика загрузки данных + + let events = dataManager.loadEvents() + let archive = dataManager.loadArchive() + view?.showEvents(events: events) + view?.showArchiveEvents(events: archive) + } +} diff --git a/Friends/Screens/EventScreen/EventPresenterProtocol.swift b/Friends/Screens/EventScreen/EventPresenterProtocol.swift new file mode 100644 index 0000000..7a8d54a --- /dev/null +++ b/Friends/Screens/EventScreen/EventPresenterProtocol.swift @@ -0,0 +1,20 @@ +// +// EventPresenterProtocol.swift +// Friends +// +// Created by Алексей on 29.03.2025. +// +import UIKit + +protocol EventPresenterProtocol: AnyObject { + func viewLoaded() + func numberOfEvents() -> Int + func numberOfArchived() -> Int + func configureEvent(cell: EventCell, at index: Int) + func configureArchived(cell: EventCell, at index: Int) + func didAcceptEvent(at index: Int) + func didDeclineEvent(at index: Int) + func didRestoreEventFromArchive(at index: Int) + func didSelectEvent(at index: Int) + func addEvent() +} diff --git a/Friends/Screens/EventScreen/EventViewController.swift b/Friends/Screens/EventScreen/EventViewController.swift new file mode 100644 index 0000000..42457e5 --- /dev/null +++ b/Friends/Screens/EventScreen/EventViewController.swift @@ -0,0 +1,395 @@ +// +// ViewController.swift +// Friends +// +// Created by Алексей on 25.03.2025. +// + +import UIKit +import MapKit +import SnapKit + +typealias TableCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void + +class EventViewController: UIViewController, EventViewProtocol { + // MARK: - Constants + + private enum Constants { + static let backgroundLightHex: String = "F5F5F5" + + static let tableViewTopOffset: CGFloat = 215 + static let tableOffsetH: CGFloat = 20 + static let heightForRow: CGFloat = 170 + static let heightForRowAnimated: CGFloat = 100 + + static let parametersOffsetBottom: CGFloat = 10 + static let parametersOffsetLeft: CGFloat = 20 + + static let navigationBarHeight: CGFloat = 120 + + static let usingSpringWithDampingValue: CGFloat = 0.55 + static let initialSpringVelocityValue: CGFloat = 0.35 + static let cellsAnimationDuration: CGFloat = 0.85 + static let delayFactor: CGFloat = 0.05 + + static let tableAnimateOffsetMultiplier: CGFloat = 2 + + static let segmentedOffsetH: CGFloat = 15 + static let segmentedOffsetBottom: CGFloat = 55 + + static let goingStatusImage: UIImage? = UIImage(systemName: "checkmark.circle.fill") + static let declinedStatusImage: UIImage? = UIImage(systemName: "x.circle.fill") + + static let addButtonTitle: String = "Добавить +" + static let addButtonTitleFont: UIFont = UIFont.systemFont(ofSize: 14, weight: .bold) + static let addButtonOffsetBottom: CGFloat = 10 + static let addButtonWidth: CGFloat = 100 + static let addButtonHeight: CGFloat = 30 + static let addButtonCornerRadius: CGFloat = 15 + } + + // MARK: - Properties + + var presenter: EventPresenterProtocol? + let eventsTable: UITableView = UITableView() + private var eventsTableLeadingConstraint: Constraint? + private var eventsTableTrailingConstraint: Constraint? + let archiveTable: UITableView = UITableView() + let segmented: SegmentedControlView = SegmentedControlView() + let addButton: UIButton = UIButton(type: .system) + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + presenter?.viewLoaded() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setupNavigationBar() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if let navigationBar = navigationController?.navigationBar { + navigationBar.subviews.forEach { subview in + if subview is UILabel { + subview.removeFromSuperview() + } + } + } + } + + // MARK: - Functions + + private func setupNavigationBar() { + navigationController?.navigationBar.prefersLargeTitles = false + + let titleLabel = UILabel() + titleLabel.text = "Встречи" + titleLabel.font = .systemFont(ofSize: 34, weight: .bold) + + if let navigationBar = navigationController?.navigationBar { + navigationBar.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(navigationBar.snp.leading).offset(16) + make.centerY.equalTo(navigationBar.snp.centerY) + make.trailing.lessThanOrEqualTo(navigationBar.snp.trailing).offset(-16) + } + } + + let avatarButton = UIButton(type: .custom) + avatarButton.setImage(UIImage(named: "image"), for: .normal) // TODO: load user image + avatarButton.frame = CGRect(x: 0, y: 0, width: 40, height: 40) + avatarButton.clipsToBounds = true + avatarButton.layer.cornerRadius = 20 + + avatarButton.addAction(UIAction { [weak self] _ in + let profileVC = ProfileViewController() + self?.navigationItem.backButtonTitle = "Назад" + self?.navigationController?.pushViewController(profileVC, animated: true) + }, for: .touchUpInside) + + let buttonContainerView = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 40)) + buttonContainerView.addSubview(avatarButton) + + avatarButton.center = CGPoint(x: buttonContainerView.bounds.midX, y: buttonContainerView.bounds.midY) + + let barButtonItem = UIBarButtonItem(customView: buttonContainerView) + navigationItem.rightBarButtonItem = barButtonItem + + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .background + appearance.shadowColor = nil + + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + navigationController?.navigationBar.compactAppearance = appearance + } + + func showEvents(events: [EventModels.Event]) { + eventsTable.reloadData() + } + + func showArchiveEvents(events: [EventModels.Event]) { + archiveTable.reloadData() + } + + func updateEvent(at index: Int, event: EventModels.Event) { + eventsTable.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + } + + func moveEventToArchive(event: EventModels.Event, from index: Int) { + eventsTable.performBatchUpdates({ + eventsTable.deleteRows(at: [IndexPath(row: index, section: 0)], with: .left) + }, completion: { _ in + self.archiveTable.insertRows(at: [IndexPath( + row: self.archiveTable.numberOfRows(inSection: 0), section: 0 + )], with: .right) + }) + } + + func moveEventFromArchive(event: EventModels.Event, from index: Int) { + archiveTable.performBatchUpdates({ + archiveTable.deleteRows(at: [IndexPath(row: index, section: 0)], with: .left) + }, completion: { _ in + self.eventsTable.insertRows(at: [IndexPath( + row: self.eventsTable.numberOfRows(inSection: 0), section: 0 + )], with: .right) + }) + } + + func displayAddEventViewController(_ viewController: UIViewController) { + self.navigationController?.present(viewController, animated: true) + } + + func displayViewEventViewController(_ viewController: UIViewController) { + self.navigationController?.present(viewController, animated: true) + } + + + // MARK: - Private functions + + private func configureUI() { + view.backgroundColor = UIColor.background + configureEvents() + configureSegmented() + configureArchive() + configureButton() + } + + private func configureEvents() { + eventsTable.register(EventCell.self, forCellReuseIdentifier: EventCell.reuseIdentifier) + eventsTable.delegate = self + eventsTable.dataSource = self + + eventsTable.backgroundColor = .clear + eventsTable.separatorStyle = .none + eventsTable.allowsSelection = true + eventsTable.showsVerticalScrollIndicator = false + + view.addSubview(eventsTable) + + eventsTable.snp.makeConstraints { make in + self.eventsTableLeadingConstraint = make.leading.equalToSuperview() + .inset(Constants.tableOffsetH).constraint + self.eventsTableTrailingConstraint = make.trailing.equalToSuperview() + .inset(Constants.tableOffsetH).constraint + make.top.equalToSuperview().offset(Constants.tableViewTopOffset) + make.bottom.equalToSuperview() + } + } + + private func moveUpBounceAnimation(rowHeight: CGFloat, + duration: TimeInterval, delayFactor: Double) -> TableCellAnimation { + return { cell, indexPath, _ in + cell.transform = CGAffineTransform(translationX: .zero, y: rowHeight) + UIView.animate( + withDuration: duration, + delay: delayFactor * Double(indexPath.row), + usingSpringWithDamping: Constants.usingSpringWithDampingValue, + initialSpringVelocity: Constants.initialSpringVelocityValue, + options: [.curveEaseInOut], + animations: { + cell.transform = CGAffineTransform(translationX: .zero, y: .zero) + } + ) + } + } + + private func configureArchive() { + archiveTable.register(EventCell.self, forCellReuseIdentifier: EventCell.reuseIdentifier) + archiveTable.delegate = self + archiveTable.dataSource = self + + archiveTable.backgroundColor = .clear + archiveTable.separatorStyle = .none + archiveTable.allowsSelection = true + archiveTable.showsVerticalScrollIndicator = false + + view.addSubview(archiveTable) + + archiveTable.snp.makeConstraints { make in + make.leading + .equalTo(eventsTable.snp.trailing) + .offset(Constants.tableAnimateOffsetMultiplier * Constants.tableOffsetH) + + make.width.equalTo(segmented.snp.width) + make.bottom.equalTo(view) + make.top.equalTo(view).offset(Constants.tableViewTopOffset) + } + } + + private func configureSegmented() { + view.addSubview(segmented) + segmented.snp.makeConstraints { make in + make.top.equalTo(self.view.safeAreaLayoutGuide).inset(30) + make.leading.trailing.equalTo(view).inset(Constants.segmentedOffsetH) + } + + segmented.segmentChanged = { [weak self] selectedIndex in + self?.moveTables(to: selectedIndex) + } + } + + private func configureButton() { + view.addSubview(addButton) + addButton.snp.makeConstraints { make in + make.top.equalTo(segmented.snp.bottom).offset(Constants.addButtonOffsetBottom) + make.trailing.equalTo(eventsTable.snp.trailing) + make.width.equalTo(Constants.addButtonWidth) + make.height.equalTo(Constants.addButtonHeight) + } + addButton.layer.cornerRadius = Constants.addButtonCornerRadius + addButton.setTitle(Constants.addButtonTitle, for: .normal) + addButton.setTitleColor(.white, for: .normal) + addButton.contentHorizontalAlignment = .center + addButton.titleLabel?.font = Constants.addButtonTitleFont + addButton.backgroundColor = .systemGreen + addButton.addTarget(self, action: #selector(addButtonPressed), for: .touchUpInside) + } + + private func moveTables(to selectedIndex: Int) { + let leftOffset = selectedIndex == 0 ? Constants.tableOffsetH : -view.frame.width - Constants.tableOffsetH + let rightOffset = selectedIndex == 0 ? -Constants.tableOffsetH : -view.frame.width - Constants.tableOffsetH + + eventsTableLeadingConstraint?.update(offset: leftOffset) + eventsTableTrailingConstraint?.update(offset: rightOffset) + + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut, animations: { + self.view.layoutIfNeeded() + }) + } + + private func triggerSelectionFeedback() { + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + } + + // MARK: - Actions + + @objc func addButtonPressed() { + presenter?.addEvent() + } +} + +// MARK: - UITableViewDelegate + +extension EventViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return Constants.heightForRow + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + triggerSelectionFeedback() + presenter?.didSelectEvent(at: indexPath.row) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let animation = moveUpBounceAnimation( + rowHeight: Constants.heightForRowAnimated, + duration: Constants.cellsAnimationDuration, + delayFactor: Constants.delayFactor + ) + let animator = TableViewAnimator(animation: animation) + animator.animate(cell: cell, at: indexPath, for: tableView) + } +} + +// MARK: - UITableViewDataSource + +extension EventViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch tableView { + case eventsTable: + return presenter?.numberOfEvents() ?? .zero + case archiveTable: + return presenter?.numberOfArchived() ?? .zero + default: + return .zero + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: EventCell.reuseIdentifier, for: indexPath) + + guard let eventCell = cell as? EventCell else { return cell } + switch tableView { + case eventsTable: + presenter?.configureEvent(cell: eventCell, at: indexPath.row) + return eventCell + case archiveTable: + presenter?.configureArchived(cell: eventCell, at: indexPath.row) + return eventCell + default: + return UITableViewCell() + } + } + + func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + switch tableView { + case eventsTable: + let declineAction = UIContextualAction( + style: .destructive, title: nil + ) { [weak self] _, _, completionHandler in + self?.presenter?.didDeclineEvent(at: indexPath.row) + completionHandler(true) + } + + let acceptAction = UIContextualAction( + style: .normal, title: nil + ) { [weak self] _, _, completionHandler in + self?.presenter?.didAcceptEvent(at: indexPath.row) + completionHandler(true) + } + + declineAction.backgroundColor = view.backgroundColor + acceptAction.backgroundColor = view.backgroundColor + declineAction.image = UIImage(named: "decline") + acceptAction.image = UIImage(named: "accept") + + return UISwipeActionsConfiguration(actions: [declineAction, acceptAction]) + + case archiveTable: + let acceptAction = UIContextualAction( + style: .destructive, title: nil + ) { [weak self] _, _, completionHandler in + self?.presenter?.didRestoreEventFromArchive(at: indexPath.row) + completionHandler(true) + } + + acceptAction.backgroundColor = view.backgroundColor + acceptAction.image = UIImage(named: "accept") + + return UISwipeActionsConfiguration(actions: [acceptAction]) + + default: + return nil + } + } +} diff --git a/Friends/Screens/EventScreen/EventViewProtocol.swift b/Friends/Screens/EventScreen/EventViewProtocol.swift new file mode 100644 index 0000000..acf8ac2 --- /dev/null +++ b/Friends/Screens/EventScreen/EventViewProtocol.swift @@ -0,0 +1,17 @@ +// +// EventViewProtocol.swift +// Friends +// +// Created by Алексей on 29.03.2025. +// +import UIKit + +protocol EventViewProtocol: AnyObject { + func showEvents(events: [EventModels.Event]) + func showArchiveEvents(events: [EventModels.Event]) + func updateEvent(at index: Int, event: EventModels.Event) + func moveEventToArchive(event: EventModels.Event, from index: Int) + func moveEventFromArchive(event: EventModels.Event, from index: Int) + func displayAddEventViewController(_ viewController: UIViewController) + func displayViewEventViewController(_ viewController: UIViewController) +} diff --git a/Friends/Screens/EventScreen/SegmentedControlView.swift b/Friends/Screens/EventScreen/SegmentedControlView.swift new file mode 100644 index 0000000..57d9170 --- /dev/null +++ b/Friends/Screens/EventScreen/SegmentedControlView.swift @@ -0,0 +1,61 @@ +// +// SegmentedControlView.swift +// Friends +// +// Created by Алексей on 26.03.2025. +// + +import UIKit + +final class SegmentedControlView: UIView { + // MARK: - Constants + + private enum Constants { + static let segmentedControlHeight: CGFloat = 32 + } + + // MARK: - Properties + + private let segmentedControl: UISegmentedControl = { + let control = UISegmentedControl(items: ["Активные", "Архив"]) + control.selectedSegmentIndex = 0 + control.backgroundColor = UIColor.systemGray5 + control.selectedSegmentTintColor = .white + control.setTitleTextAttributes([.foregroundColor: UIColor.black], for: .selected) + control.setTitleTextAttributes([.foregroundColor: UIColor.gray], for: .normal) + return control + }() + var segmentChanged: ((Int) -> Void)? + + // MARK: - Iniitialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private functions + + private func setupUI() { + addSubview(segmentedControl) + + segmentedControl.snp.makeConstraints { make in + make.leading.trailing.equalTo(self) + make.top.bottom.equalTo(self) + make.height.equalTo(Constants.segmentedControlHeight) + } + + segmentedControl.addTarget(self, action: #selector(segmentValueChanged), for: .valueChanged) + } + + // MARK: - Actions + @objc + private func segmentValueChanged() { + segmentChanged?(segmentedControl.selectedSegmentIndex) + } +} diff --git a/Friends/Screens/FinanceScreen/Controller/FinanceViewController.swift b/Friends/Screens/FinanceScreen/Controller/FinanceViewController.swift new file mode 100644 index 0000000..8a79a58 --- /dev/null +++ b/Friends/Screens/FinanceScreen/Controller/FinanceViewController.swift @@ -0,0 +1,117 @@ +// +// FinanceViewController.swift +// Friends +// +// Created by Савва Пономарев on 26.03.2025. +// + +import UIKit +import SnapKit + +class FinanceViewController: UIViewController { + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + financeView.backgroundColor = .background + addTargets() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setupNavigationBar() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if let navigationBar = navigationController?.navigationBar { + navigationBar.subviews.forEach { subview in + if subview is UILabel { + subview.removeFromSuperview() + } + } + } + } + + override func loadView() { + self.view = FinanceView() + } + + // MARK: - Properties + + private var financeView: FinanceView { + guard let view = view as? FinanceView else { + assertionFailure("Failed to dequeue FinanceView") + return FinanceView() + } + return view + } + + // MARK: - Actions + + @objc func onChange() { + financeView.toogle() + } + + @objc func onAddNewdebt() { + let modalViewController = NewExpenseModalViewController() + let navigationController = UINavigationController(rootViewController: modalViewController) + navigationController.modalPresentationStyle = .pageSheet + navigationController.sheetPresentationController?.prefersGrabberVisible = true + present(navigationController, animated: true) + } + + // MARK: - Setup Methods + + private func addTargets() { + financeView.segmentController.addTarget(self, action: #selector(onChange), for: .valueChanged) + financeView.addDebtButton.addTarget(self, action: #selector(onAddNewdebt), for: .touchUpInside) + } + + private func setupNavigationBar() { + navigationController?.navigationBar.prefersLargeTitles = false + + let titleLabel = UILabel() + titleLabel.text = "Деньги" + titleLabel.font = .systemFont(ofSize: 34, weight: .bold) + + if let navigationBar = navigationController?.navigationBar { + navigationBar.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(navigationBar.snp.leading).offset(16) + make.centerY.equalTo(navigationBar.snp.centerY) + make.trailing.lessThanOrEqualTo(navigationBar.snp.trailing).offset(-16) + } + } + + let avatarButton = UIButton(type: .custom) + avatarButton.setImage(UIImage(named: "image"), for: .normal) // TODO: load user image + avatarButton.frame = CGRect(x: 0, y: 0, width: 40, height: 40) + avatarButton.clipsToBounds = true + avatarButton.layer.cornerRadius = 20 + + avatarButton.addAction(UIAction { [weak self] _ in + let profileVC = ProfileViewController() + self?.navigationItem.backButtonTitle = "Назад" + self?.navigationController?.pushViewController(profileVC, animated: true) + }, for: .touchUpInside) + + let buttonContainerView = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 40)) + buttonContainerView.addSubview(avatarButton) + + avatarButton.center = CGPoint(x: buttonContainerView.bounds.midX, y: buttonContainerView.bounds.midY) + + let barButtonItem = UIBarButtonItem(customView: buttonContainerView) + navigationItem.rightBarButtonItem = barButtonItem + + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .background + appearance.shadowColor = nil + + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + navigationController?.navigationBar.compactAppearance = appearance + } +} diff --git a/Friends/Screens/FinanceScreen/Controller/NewExpenseModalViewController.swift b/Friends/Screens/FinanceScreen/Controller/NewExpenseModalViewController.swift new file mode 100644 index 0000000..7e670f4 --- /dev/null +++ b/Friends/Screens/FinanceScreen/Controller/NewExpenseModalViewController.swift @@ -0,0 +1,322 @@ +// +// NewExpenseModalViewController.swift +// Friends +// +// Created by Алёна Максимова on 01.04.2025. +// + +import UIKit +import SwiftUI + +// MARK: - Protocols + +protocol NewExpenseModalViewControllerDelegate: AnyObject { + func updateSelectedPersonDebt(personId: UUID, debt: Double) +} + +final class NewExpenseModalViewController: UIViewController { + + // MARK: - Properties + + private var isSplitEven: Bool = true + private var debts: [Double] = [] + private var loadedFriends: [Person] = [ + Person(id: UUID(), name: "Алекс", username: "", password: "", debts: []), + Person(id: UUID(), name: "Миша", username: "", password: "", debts: []), + Person(id: UUID(), name: "Сергей", username: "", password: "", debts: []), + Person(id: UUID(), name: "Соня", username: "", password: "", debts: []) + ] + private let friendsProvider = FriendsNetwork() + private let debtsProvider = DebtsNetwork() + private var user: Person? = AppCache.shared.user + private var friends: [Person] = [] { + didSet { + var newDebts: [Double] = [] + for friend in friends { + if let index = oldValue.firstIndex(where: { $0.id == friend.id }) { + newDebts.append(debts[index]) + } else { + newDebts.append(0) + } + } + debts = newDebts + expenseView.tableView.reloadData() + updateDebts() + updateAddButtonState() + } + } + + private var expenseView: NewExpenseModalView { + guard let view = view as? NewExpenseModalView else { + assertionFailure("Failed to dequeue PersonCell") + return NewExpenseModalView() + } + return view + } + + // MARK: - Lifecycle + + override func loadView() { + self.view = NewExpenseModalView() + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupActions() + setupNavigationBar() + updateDebts() + } + + // MARK: - Setup Methods + + private func setupUI() { + expenseView.backgroundColor = .background + expenseView.totalTextField.delegate = self + expenseView.tableView.delegate = self + expenseView.tableView.dataSource = self + } + + private func setupActions() { + setupSplitButton() + } + + private func setupNavigationBar() { + navigationItem.title = "Новая трата" + let cancelButton = UIBarButtonItem(title: "Отмена", + primaryAction: UIAction { [weak self] _ in + self?.dismiss(animated: true) + }) + + let addButton = UIBarButtonItem( + title: "Добавить", + image: nil, + primaryAction: UIAction { [weak self] _ in + self?.dismiss(animated: true) + }) + + navigationItem.leftBarButtonItem = cancelButton + navigationItem.rightBarButtonItem = addButton + + cancelButton.tintColor = .systemRed + addButton.tintColor = .systemGray + addButton.isEnabled = false + } + + private func setupSplitButton() { + let splitEvenAction = UIAction( + title: "Разделить поровну", + image: UIImage(systemName: "person.2.fill"), + state: isSplitEven ? .on : .off + ) { [weak self] _ in + self?.handleSplitEvenSelection() + } + + let enterManuallyAction = UIAction( + title: "Ввести вручную", + image: UIImage(systemName: "pencil"), + state: isSplitEven ? .off : .on + ) { [weak self] _ in + self?.handleManualEntrySelection() + } + + let menu = UIMenu(title: "", children: [splitEvenAction, enterManuallyAction]) + + expenseView.splitButton.showsMenuAsPrimaryAction = true + expenseView.splitButton.menu = menu + } + + private func handleSplitEvenSelection() { + isSplitEven = true + expenseView.totalTextField.isEnabled = true + expenseView.splitButton.setTitle("Разделить поровну", for: .normal) + updateDebts() + setupSplitButton() + expenseView.tableView.reloadData() + } + + private func handleManualEntrySelection() { + isSplitEven = false + expenseView.totalTextField.isEnabled = false + expenseView.splitButton.setTitle("Ввести вручную", for: .normal) + updateDebts() + setupSplitButton() + expenseView.tableView.reloadData() + } + + private func updateDebts() { + guard let totalText = expenseView.totalTextField.text?.replacingOccurrences(of: "₽", with: ""), let totalAmount = Double(totalText) else { return } + + if isSplitEven { + let splitAmount = totalAmount / Double(friends.count) + let roundedSplitAmount = splitAmount.rounded(toPlaces: 1) + debts = Array(repeating: roundedSplitAmount, count: friends.count) + expenseView.tableView.reloadData() + } else { + debts = debts.map { $0.rounded(toPlaces: 1) } + let total = debts.reduce(0, +) + let roundedTotal = total.rounded(toPlaces: 1) + expenseView.totalTextField.text = "\(roundedTotal)₽" + } + } +} + +// MARK: - Extensions + +extension NewExpenseModalViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return friends.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: PersonCell.personCellIdentifier, for: indexPath) as? PersonCell else { + return UITableViewCell() + } + let person = friends[indexPath.row] + cell.configure(with: person, isDebitor: true, isEditable: !isSplitEven, resetTextField: false) + let roundedDebt = debts[indexPath.row].rounded(toPlaces: 1) + cell.debtTextFieldView.text = String(format: "%.1f", roundedDebt) + cell.delegate = self + + if indexPath.row == 0 { + cell.layer.cornerRadius = 10 + cell.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + cell.clipsToBounds = true + } + + return cell + } +} + +extension NewExpenseModalViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let headerView = UIView() + + let titleLabel = UILabel() + titleLabel.text = "Друзья" + titleLabel.font = UIFont.systemFont(ofSize: 13, weight: .bold) + titleLabel.textColor = .systemGray + headerView.addSubview(titleLabel) + + headerView.addSubview(expenseView.splitButton) + + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(10) + make.centerY.equalToSuperview() + } + + expenseView.splitButton.snp.remakeConstraints { make in + make.trailing.equalToSuperview() + make.centerY.equalToSuperview() + make.height.equalTo(30) + } + + return headerView + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return 34 + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + let footerView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: 44)) + footerView.backgroundColor = .white + let addFriendButton = UIButton(type: .system) + addFriendButton.setTitle("Добавить друзей", for: .normal) + addFriendButton.setTitleColor(.systemBlue, for: .normal) + addFriendButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) + addFriendButton.addAction( + UIAction { [weak self] _ in + let selectedFriendsBinding = Binding<[Person]>( + get: { self?.friends ?? [] }, + set: { self?.friends = $0 } + ) + + let selectFriendsView = SelectFriendsViewExpence( + friends: self?.loadedFriends ?? [], + selectedFriends: selectedFriendsBinding + ) + let hostingController = UIHostingController(rootView: selectFriendsView) + self?.present(hostingController, animated: true) + }, + for: .touchUpInside + ) + footerView.addSubview(addFriendButton) + + addFriendButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(20) + make.centerY.equalToSuperview() + } + + footerView.layer.cornerRadius = 10 + + if !friends.isEmpty { + footerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + } + + return footerView + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 44 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + } +} + +extension NewExpenseModalViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + return true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + if textField.text?.last != "₽", let textFieldText = textField.text { + textField.text = textFieldText.isEmpty ? "" : "\(textFieldText)₽" + } + updateAddButtonState() + updateDebts() + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + updateAddButtonState() + updateDebts() + } + + private func updateAddButtonState() { + if let text = expenseView.totalTextField.text, !text.isEmpty, text.count > 1, text != "0₽", !debts.contains(where: { $0 == 0 }), !friends.isEmpty { + navigationItem.rightBarButtonItem?.isEnabled = true + navigationItem.rightBarButtonItem?.tintColor = .systemBlue + } else { + navigationItem.rightBarButtonItem?.isEnabled = false + navigationItem.rightBarButtonItem?.tintColor = .systemGray + } + } +} + +extension NewExpenseModalViewController: NewExpenseModalViewControllerDelegate { + func updateSelectedPersonDebt(personId: UUID, debt: Double) { + guard let person = friends.first(where: { $0.id == personId }), let index = friends.firstIndex(of: person) else { return } + + debts[index] = debt.rounded(toPlaces: 1) + updateDebts() + updateAddButtonState() + } +} + +extension Double { + func rounded(toPlaces places: Int) -> Double { + let divisor = pow(10.0, Double(places)) + return (self * divisor).rounded() / divisor + } +} diff --git a/Friends/Screens/FinanceScreen/PersonCell.swift b/Friends/Screens/FinanceScreen/PersonCell.swift new file mode 100644 index 0000000..3a48e84 --- /dev/null +++ b/Friends/Screens/FinanceScreen/PersonCell.swift @@ -0,0 +1,141 @@ +// +// PersonCell.swift +// Friends +// +// Created by Савва Пономарев on 27.03.2025. +// + +import UIKit +import SnapKit + +class PersonCell: UITableViewCell { + + // MARK: - Properties + + static let personCellIdentifier = "PersonCell" + private var isEditable: Bool = false + private var person: Person? + + weak var delegate: NewExpenseModalViewControllerDelegate? + + private let personImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.layer.cornerRadius = 40 + imageView.clipsToBounds = true + imageView.layer.masksToBounds = true + return imageView + }() + + private lazy var nameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 18, weight: .medium) + label.textColor = .black + return label + }() + + private(set) lazy var debtTextFieldView: UITextField = { + let textField = UITextField() + textField.borderStyle = .roundedRect + textField.textColor = .label + textField.font = .systemFont(ofSize: 16, weight: .regular) + textField.keyboardType = .numberPad + textField.textColor = .gray + textField.borderStyle = .none + + return textField + }() + + private lazy var stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 10 + stack.distribution = .fillEqually + return stack + }() + + private lazy var borderView: UIView = { + let view = UIView() + view.backgroundColor = .systemGray5 + return view + }() + + // MARK: - Initializers + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + contentView.addSubview(stackView) + contentView.addSubview(borderView) + stackView.addArrangedSubview(personImageView) + stackView.addArrangedSubview(nameLabel) + stackView.addArrangedSubview(debtTextFieldView) + + debtTextFieldView.delegate = self + + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(10) + } + + borderView.snp.makeConstraints { make in + make.bottom.trailing.equalToSuperview() + make.width.equalToSuperview().multipliedBy(0.95) + make.height.equalTo(1) + } + + setupTextField() + } + + func configure(with person: Person, isDebitor: Bool, isEditable: Bool = false, resetTextField: Bool = false) { + self.person = person + personImageView.image = person.icon + nameLabel.text = person.name + print(person.id, person.name) + debtTextFieldView.text = resetTextField ? "" : "\(PersonContainer.shared.getDebt(of: person)) ₽" + debtTextFieldView.placeholder = resetTextField ? "Сумма" : "" + let color = DebtColor.getColor(isDebitor: isDebitor) + debtTextFieldView.textColor = color + self.isEditable = isEditable + } + + private func setupTextField() { + let editingAction = UIAction { [weak self] _ in + self?.handleDebtTextChange() + } + debtTextFieldView.addAction(editingAction, for: .editingChanged) + } + + private func handleDebtTextChange() { + guard let person = person else { return } + + let debtText = debtTextFieldView.text? + .replacingOccurrences(of: "₽", with: "") + .trimmingCharacters(in: .whitespaces) ?? "0" + + delegate?.updateSelectedPersonDebt( + personId: person.id, + debt: Double(debtText) ?? 0 + ) + } +} + +// MARK: - Extensions + +extension PersonCell: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + return isEditable + } +} diff --git a/Friends/Screens/FinanceScreen/View/DebitorstView.swift b/Friends/Screens/FinanceScreen/View/DebitorstView.swift new file mode 100644 index 0000000..52b0f0e --- /dev/null +++ b/Friends/Screens/FinanceScreen/View/DebitorstView.swift @@ -0,0 +1,71 @@ +// +// BudgetViewController.swift +// Friends +// +// Created by Савва Пономарев on 27.03.2025. +// + +import UIKit + +class DebitorsView: UIView { + + private var debitors: [Debt] { + return PersonContainer.shared.getDebts(dest: DebtType.to) + } + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.dataSource = self + tableView.delegate = self + tableView.separatorStyle = .none + tableView.backgroundColor = .background + tableView.register(PersonCell.self, forCellReuseIdentifier: PersonCell.personCellIdentifier) + return tableView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configure() { + addSubview(tableView) + + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} + +extension DebitorsView: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return debitors.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: PersonCell.personCellIdentifier, for: indexPath) + + guard let personCell = cell as? PersonCell else { return UITableViewCell() } + + let person = debitors[indexPath.row].personTo + personCell.configure(with: person, isDebitor: true) + + return personCell + } +} + +extension DebitorsView: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + print("Selected: \(debitors[indexPath.row].personTo.name)") + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { +// TODO: вынести в константу + return 80 + } +} diff --git a/Friends/Screens/FinanceScreen/View/DebtsView.swift b/Friends/Screens/FinanceScreen/View/DebtsView.swift new file mode 100644 index 0000000..07931da --- /dev/null +++ b/Friends/Screens/FinanceScreen/View/DebtsView.swift @@ -0,0 +1,71 @@ +// +// Debts.swift +// Friends +// +// Created by Савва Пономарев on 27.03.2025. +// + +import UIKit + +class DebtsView: UIView { + + private var debts: [Debt] { + return PersonContainer.shared.getDebts(dest: DebtType.from) + } + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.dataSource = self + tableView.delegate = self + tableView.separatorStyle = .none + tableView.backgroundColor = .background + tableView.register(PersonCell.self, forCellReuseIdentifier: PersonCell.personCellIdentifier) + return tableView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configure() { + addSubview(tableView) + + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} + +extension DebtsView: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return debts.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: PersonCell.personCellIdentifier, for: indexPath) + + guard let personCell = cell as? PersonCell else { return UITableViewCell() } + + let person = debts[indexPath.row].personFrom + personCell.configure(with: person, isDebitor: false) + + return personCell + } +} + +extension DebtsView: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + print("Selected: \(debts[indexPath.row].personFrom.name)") + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { +// TODO: вынести в константу + return 80 + } +} diff --git a/Friends/Screens/FinanceScreen/View/FinanceView.swift b/Friends/Screens/FinanceScreen/View/FinanceView.swift new file mode 100644 index 0000000..16f0bcc --- /dev/null +++ b/Friends/Screens/FinanceScreen/View/FinanceView.swift @@ -0,0 +1,116 @@ +// +// FinanceView.swift +// Friends +// +// Created by Савва Пономарев on 27.03.2025. +// + +import UIKit +import SnapKit + +class FinanceView: UIView { + + private(set) lazy var segmentController = UISegmentedControl(items: ["Долги", "Должники"]) + private lazy var overallDebt: UILabel = { + let label = UILabel() + label.textAlignment = .center + switch segmentController.selectedSegmentIndex { + case 0: + label.text = PersonContainer.shared.getDebtsSum(dest: .from).description + label.textColor = .systemRed + case 1: + label.text = PersonContainer.shared.getDebtsSum(dest: .to).description + label.textColor = .systemGreen + default: + label.text = "0" + label.textColor = .systemGray + + } + label.font = .systemFont(ofSize: 24, weight: .bold) + return label + }() + + private(set) lazy var addDebtButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Добавить долг", for: .normal) + return button + }() + + let debtTableView = DebtsView() + let budgetTableView = DebitorsView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + addSubview(segmentController) + segmentController.selectedSegmentIndex = 0 + + addSubview(overallDebt) + addSubview(addDebtButton) + addSubview(debtTableView) + addSubview(budgetTableView) + + debtTableView.isHidden = false + budgetTableView.isHidden = true + } + + func toogle() { + debtTableView.isHidden.toggle() + budgetTableView.isHidden.toggle() + updateOverallDebt() + } + + private func updateOverallDebt() { + switch segmentController.selectedSegmentIndex { + case 0: + overallDebt.text = PersonContainer.shared.getDebtsSum(dest: .from).description + overallDebt.textColor = .systemRed + case 1: + overallDebt.text = PersonContainer.shared.getDebtsSum(dest: .to).description + overallDebt.textColor = .systemGreen + default: + overallDebt.text = "0" + overallDebt.textColor = .systemGray + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + segmentController.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(self.safeAreaLayoutGuide).inset(30) + make.leading.trailing.equalToSuperview().inset(20) + make.height.equalTo(33) + } + + overallDebt.snp.makeConstraints { make in + make.top.equalTo(segmentController.snp.bottom).offset(20) + make.leading.equalToSuperview().inset(20) + } + + addDebtButton.snp.makeConstraints { make in + make.top.equalTo(segmentController.snp.bottom).offset(20) + make.trailing.equalToSuperview().inset(20) + } + + debtTableView.snp.makeConstraints { make in + make.top.equalTo(addDebtButton.snp.bottom).offset(20) + make.leading.trailing.bottom.equalTo(self.safeAreaLayoutGuide) + } + + budgetTableView.snp.makeConstraints { make in + make.top.equalTo(addDebtButton.snp.bottom).offset(20) + make.leading.trailing.bottom.equalTo(self.safeAreaLayoutGuide) + } + + } + +} diff --git a/Friends/Screens/FinanceScreen/View/NewExpenseModalView.swift b/Friends/Screens/FinanceScreen/View/NewExpenseModalView.swift new file mode 100644 index 0000000..253d92b --- /dev/null +++ b/Friends/Screens/FinanceScreen/View/NewExpenseModalView.swift @@ -0,0 +1,90 @@ +// +// NewExpenseModalView.swift +// Friends +// +// Created by Алёна Максимова on 01.04.2025. +// + +import UIKit +import SnapKit + +final class NewExpenseModalView: UIView { + + // MARK: - Properties + + private(set) lazy var totalTextField: UITextField = { + let textField = UITextField() + textField.text = "1000₽" + textField.font = UIFont.systemFont(ofSize: 64, weight: .light) + textField.textColor = .systemBlue + textField.textAlignment = .center + textField.borderStyle = .none + textField.backgroundColor = .clear + + return textField + }() + + private(set) lazy var splitButton: UIButton = { + var configuration = UIButton.Configuration.plain() + configuration.title = "Разделить поровну" + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = UIFont.systemFont(ofSize: 15, weight: .bold) + outgoing.foregroundColor = .systemBlue + return outgoing + } + + let chevron = UIImage(systemName: "chevron.up.chevron.down")?.withTintColor(.systemBlue).withConfiguration(UIImage.SymbolConfiguration(pointSize: 15)) + configuration.image = chevron + configuration.imagePlacement = .trailing + configuration.imagePadding = 6 + + let button = UIButton(configuration: configuration, primaryAction: nil) + + return button + }() + + private(set) lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(PersonCell.self, forCellReuseIdentifier: PersonCell.personCellIdentifier) + tableView.keyboardDismissMode = .onDrag + tableView.backgroundColor = .background + tableView.separatorStyle = .none + + return tableView + }() + + // MARK: - Initializers + + public override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + @available(*, unavailable) + public required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setupView() { + [totalTextField, tableView].forEach(self.addSubview) + } + + // MARK: - Constraints + + override func layoutSubviews() { + super.layoutSubviews() + + totalTextField.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview().inset(20) + make.height.equalTo(150) + } + + tableView.snp.makeConstraints { make in + make.top.equalTo(totalTextField.snp.bottom) + make.leading.trailing.equalToSuperview().inset(20) + make.bottom.equalToSuperview().inset(60) + } + } +} diff --git a/Friends/Screens/FinanceScreen/View/SelectFriendsViewExpence.swift b/Friends/Screens/FinanceScreen/View/SelectFriendsViewExpence.swift new file mode 100644 index 0000000..345e288 --- /dev/null +++ b/Friends/Screens/FinanceScreen/View/SelectFriendsViewExpence.swift @@ -0,0 +1,50 @@ +// +// SelectFriendsViewExpence.swift +// Friends +// +// Created by Алёна Максимова on 02.04.2025. +// + +import SwiftUI + +struct SelectFriendsViewExpence: View { + @Environment(\.dismiss) var dismiss + let friends: [Person] + @State private var internalSelection: Set = [] + @Binding var selectedFriends: [Person] + + var body: some View { + VStack { + ZStack(alignment: .trailing) { + HStack { + Spacer() + Text("Добавить друзей") + .fontWeight(.medium) + Spacer() + } + + Button("Готово") { + selectedFriends = friends.filter({ internalSelection.contains($0.id) }) + dismiss() + } + .fontWeight(.bold) + } + .padding([.horizontal, .top]) + + List(friends, selection: $internalSelection) { person in + HStack { + Image(uiImage: person.icon) + .resizable() + .frame(width: 40.0, height: 40.0) + .clipShape(Circle()) + Text(person.name) + } + } + .environment(\.editMode, .constant(.active)) + } + .onAppear { + internalSelection = Set(selectedFriends.map { $0.id }) + } + .background(Color.background) + } +} diff --git a/Friends/Screens/FriendsScreen/View/FriendCell.swift b/Friends/Screens/FriendsScreen/View/FriendCell.swift new file mode 100644 index 0000000..a3fc6cd --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/FriendCell.swift @@ -0,0 +1,46 @@ +// +// FriendCell.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct FriendCell: View { + // MARK: Constants + + private enum Constants { + static let wrapRadius: CGFloat = 20 + static let wrapOffsetV: CGFloat = 5 + + static let itemOffset: CGFloat = 15 + static let titleFontSize: CGFloat = 14 + + static let imageSpacingH: CGFloat = 15 + static let imageSize: CGFloat = 45 + static let imageSubtitleFontSize: CGFloat = 14 + } + + let title: String + + var body: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: Constants.wrapRadius) + .fill(Color.white) + .padding(.top, Constants.wrapOffsetV) + HStack { + Image("image") + .resizable() + .clipShape(Circle()) + .frame(width: Constants.imageSize, height: Constants.imageSize) + .padding(.top, Constants.itemOffset) + .padding(.leading, Constants.itemOffset) + Text(title) + .font(.system(size: Constants.titleFontSize, weight: .medium)) + .padding(.top, Constants.itemOffset) + .padding(.leading, Constants.itemOffset) + } + } + } +} diff --git a/Friends/Screens/FriendsScreen/View/FriendsListView.swift b/Friends/Screens/FriendsScreen/View/FriendsListView.swift new file mode 100644 index 0000000..923083a --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/FriendsListView.swift @@ -0,0 +1,23 @@ +// +// FriendsListView.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct FriendsListView: View { + @Binding var friends: [Person] + + var body: some View { + List { + ForEach(friends, id: \.self) { friend in + FriendCell(title: friend.name) + } + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listStyle(.plain) + } +} diff --git a/Friends/Screens/FriendsScreen/View/FriendsView.swift b/Friends/Screens/FriendsScreen/View/FriendsView.swift new file mode 100644 index 0000000..d382737 --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/FriendsView.swift @@ -0,0 +1,59 @@ +// +// GroupsView.swift +// Friends +// +// Created by Алексей on 31.03.2025. +// + +import SwiftUI + +enum Orientation { + case vertical + case horizontal +} + +struct FriendsView: View { + // MARK: - Constants + + private enum Constants { + static let addButtonTitleFontSize: CGFloat = 14 + static let addButtonWidth: CGFloat = 100 + static let addButtonHeight: CGFloat = 30 + static let addButtonPadding: CGFloat = 20 + } + + @StateObject var viewModel: FriendsViewModel = FriendsViewModel() + @State var showAddFriendScreen = false + + var body: some View { + ZStack { + Color.background + .ignoresSafeArea(.all) + VStack { + SelectedSegmentedControlView(viewModel: viewModel) + HStack { + Spacer() + Button(action: { + showAddFriendScreen = true + }, label: { + Text("Добавить +") + .frame(width: Constants.addButtonWidth, height: Constants.addButtonHeight) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(15) + .font(.system(size: Constants.addButtonTitleFontSize, weight: .bold)) + }) + .padding(.trailing, Constants.addButtonPadding) + } + ListsView(viewModel: viewModel) + } + } + .sheet(isPresented: $showAddFriendScreen) { + AddFriendView() + } + } +} + +#Preview { + FriendsView() +} diff --git a/Friends/Screens/FriendsScreen/View/GroupCell.swift b/Friends/Screens/FriendsScreen/View/GroupCell.swift new file mode 100644 index 0000000..b46a7ce --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/GroupCell.swift @@ -0,0 +1,59 @@ +// +// GroupCell.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct GroupCell: View { + // MARK: Constants + + private enum Constants { + static let wrapRadius: CGFloat = 20 + static let wrapOffsetV: CGFloat = 5 + + static let itemOffset: CGFloat = 15 + static let titleFontSize: CGFloat = 24 + } + + @ObservedObject var viewModel: FriendsViewModel + @Binding var group: GroupModels.Group + @State var orientation: Orientation = .horizontal + @Namespace var animationNamespace + + var body: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: Constants.wrapRadius) + .fill(Color.white) + .padding(.top, Constants.wrapOffsetV) + + VStack(alignment: .leading) { + Text(group.title) + .font(.system(size: Constants.titleFontSize, weight: .medium)) + .padding(.top, Constants.itemOffset) + .padding(.leading, Constants.itemOffset) + Group { + switch orientation { + case .horizontal: + GroupCellPreview(viewModel: viewModel, friends: $group.friends, namespace: animationNamespace) + case .vertical: + GroupCellExpanded(friends: $group.friends, namespace: animationNamespace) + } + } + .animation(.interactiveSpring(response: 0.5, dampingFraction: 0.8), value: orientation) + .padding(.leading, Constants.itemOffset) + } + } + .animation(.easeInOut(duration: 0.2), value: orientation) + .onTapGesture { + switch orientation { + case .vertical: + orientation = .horizontal + case .horizontal: + orientation = .vertical + } + } + } +} diff --git a/Friends/Screens/FriendsScreen/View/GroupCellExpanded.swift b/Friends/Screens/FriendsScreen/View/GroupCellExpanded.swift new file mode 100644 index 0000000..75aa917 --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/GroupCellExpanded.swift @@ -0,0 +1,38 @@ +// +// GroupCellExpanded.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct GroupCellExpanded: View { + // MARK: Constants + + private enum Constants { + static let imageSpacingH: CGFloat = 15 + static let imageSize: CGFloat = 45 + static let imageSubtitleFontSize: CGFloat = 14 + } + + @Binding var friends: [Person] + let namespace: Namespace.ID + + var body: some View { + VStack(alignment: .leading, spacing: Constants.imageSpacingH) { + ForEach(friends.indices, id: \.self) { index in + HStack { + Image("image") + .resizable() + .clipShape(Circle()) + .frame(width: Constants.imageSize, height: Constants.imageSize) + .matchedGeometryEffect(id: "image\(index)", in: namespace) + Text(friends[index].name) + .font(.system(size: Constants.imageSubtitleFontSize, weight: .regular)) + .matchedGeometryEffect(id: "text\(index)", in: namespace) + } + } + } + } +} diff --git a/Friends/Screens/FriendsScreen/View/GroupCellPreview.swift b/Friends/Screens/FriendsScreen/View/GroupCellPreview.swift new file mode 100644 index 0000000..316a386 --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/GroupCellPreview.swift @@ -0,0 +1,51 @@ +// +// GroupCellPreview.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct GroupCellPreview: View { + // MARK: Constants + + private enum Constants { + static let imageSpacingH: CGFloat = 15 + static let imageSize: CGFloat = 45 + static let imageSubtitleFontSize: CGFloat = 14 + + static let extraFriendsBackgroundOpacity: CGFloat = 0.35 + static let previewFriendsLimit: Int = 4 + } + + @ObservedObject var viewModel: FriendsViewModel + @Binding var friends: [Person] + let namespace: Namespace.ID + + var body: some View { + HStack(alignment: .top, spacing: Constants.imageSpacingH) { + ForEach(friends.prefix(Constants.previewFriendsLimit).indices, id: \.self) { index in + VStack { + Image("image") + .resizable() + .clipShape(Circle()) + .frame(width: Constants.imageSize, height: Constants.imageSize) + .matchedGeometryEffect(id: "image\(index)", in: namespace) + Text(friends[index].name) + .font(.system(size: Constants.imageSubtitleFontSize, weight: .regular)) + .matchedGeometryEffect(id: "text\(index)", in: namespace) + } + } + if viewModel.getFriendsAndPreviewDifference(friendsCount: friends.count) > 0 { + ZStack { + Circle() + .fill(Color(.lightGray).opacity(Constants.extraFriendsBackgroundOpacity)) + .frame(width: Constants.imageSize, height: Constants.imageSize) + Text("+" + String(viewModel.getFriendsAndPreviewDifference(friendsCount: friends.count))) + .foregroundStyle(.gray) + } + } + } + } +} diff --git a/Friends/Screens/FriendsScreen/View/GroupsListView.swift b/Friends/Screens/FriendsScreen/View/GroupsListView.swift new file mode 100644 index 0000000..1ef2925 --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/GroupsListView.swift @@ -0,0 +1,23 @@ +// +// GroupsListView.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct GroupsListView: View { + @ObservedObject var viewModel: FriendsViewModel + + var body: some View { + List { + ForEach(viewModel.groups.indices, id: \.self) { index in + GroupCell(viewModel: viewModel, group: $viewModel.groups[index]) + } + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listStyle(.plain) + } +} diff --git a/Friends/Screens/FriendsScreen/View/ListsView.swift b/Friends/Screens/FriendsScreen/View/ListsView.swift new file mode 100644 index 0000000..b3e4488 --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/ListsView.swift @@ -0,0 +1,24 @@ +// +// ListsView.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct ListsView: View { + @ObservedObject var viewModel: FriendsViewModel + + var body: some View { + GeometryReader { geometry in + HStack { + GroupsListView(viewModel: viewModel) + .frame(width: geometry.size.width) + FriendsListView(friends: $viewModel.friends) + .frame(width: geometry.size.width) + } + .offset(x: viewModel.listOffset) + } + } +} diff --git a/Friends/Screens/FriendsScreen/View/SelectedSegmentedControlView.swift b/Friends/Screens/FriendsScreen/View/SelectedSegmentedControlView.swift new file mode 100644 index 0000000..445e35b --- /dev/null +++ b/Friends/Screens/FriendsScreen/View/SelectedSegmentedControlView.swift @@ -0,0 +1,37 @@ +// +// SelectedSegmentedControlView.swift +// Friends +// +// Created by Алексей on 03.04.2025. +// + +import SwiftUI + +struct SelectedSegmentedControlView: View { + // MARK: - Constants + + private enum Constants { + static let segmentedViewTopOffset: CGFloat = 75 + + static let segmentedControlHeight: CGFloat = 40 + static let segmentedControlPadding: CGFloat = 15 + } + + @ObservedObject var viewModel: FriendsViewModel + + let segments = ["Группы", "Друзья"] + + var body: some View { + Picker("", selection: $viewModel.selectedSegment) { + ForEach(0.. Int { + return friendsCount - Constants.previewFriendsLimit + } +} diff --git a/Friends/Screens/Profile/ProfileView.swift b/Friends/Screens/Profile/ProfileView.swift new file mode 100644 index 0000000..e82cabe --- /dev/null +++ b/Friends/Screens/Profile/ProfileView.swift @@ -0,0 +1,122 @@ +// +// ProfileView.swift +// Friends +// +// Created by Алёна Максимова on 03.04.2025. +// + +import UIKit +import SnapKit + +final class ProfileView: UIView { + + private(set) lazy var avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 100 + imageView.image = AppCache.shared.user?.icon + return imageView + }() + + private(set) lazy var nameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 26, weight: .medium) + label.textColor = .label + label.text = AppCache.shared.user?.name + return label + }() + + private(set) lazy var dangerLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .bold) + label.textColor = .systemRed + label.text = "-- Внимание! Опасная зона! --" + return label + }() + + private(set) lazy var deleteButton: UIButton = { + let button = UIButton() + button.backgroundColor = .systemRed + button.layer.cornerRadius = 12 + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium) + button.setTitle("Удалить аккаунт", for: .normal) + button.addAction(UIAction { [weak self] _ in + guard let personID = AppCache.shared.user?.id else { return } + self?.onDeleteAccount(for: personID) + }, for: .touchUpInside) + return button + }() + + func mock() { + print("adadasds") + } + + func onDeleteAccount(for personId: UUID) { + let personNet = PersonNetwork() + print("rfradsfa") + personNet.deleteAccount(with: personId) { result in + DispatchQueue.main.async { + switch result { + case .success: + AppCache.shared.clearCache { _ in } + UserDataCache().deleteUserInfo() + + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let delegate = scene.delegate as? SceneDelegate { + let loginVC = AuthViewController() + let nav = UINavigationController(rootViewController: loginVC) + delegate.window?.rootViewController = nav + } + + case .failure(let error): + return + } + } + } + } + + public override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + @available(*, unavailable) + public required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setupView() { + [avatarImageView, nameLabel, deleteButton, dangerLabel].forEach(self.addSubview) + } + + // MARK: - Constraints + + override func layoutSubviews() { + super.layoutSubviews() + + avatarImageView.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide).inset(16) + make.width.height.equalTo(200) + make.centerX.equalToSuperview() + } + + nameLabel.snp.makeConstraints { make in + make.top.equalTo(avatarImageView.snp.bottom).offset(20) + make.centerX.equalToSuperview() + } + + deleteButton.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.width.equalTo(200) + make.height.equalTo(50) + make.bottom.equalTo(self.safeAreaLayoutGuide).inset(60) + } + + dangerLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(deleteButton.snp.top).inset(-20) + } + } +} diff --git a/Friends/Screens/Profile/ProfileViewController.swift b/Friends/Screens/Profile/ProfileViewController.swift new file mode 100644 index 0000000..6c1427a --- /dev/null +++ b/Friends/Screens/Profile/ProfileViewController.swift @@ -0,0 +1,28 @@ +// +// ProfileViewController.swift +// Friends +// +// Created by Алёна Максимова on 03.04.2025. +// + +import UIKit + +final class ProfileViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + profileView.backgroundColor = .background + } + + override func loadView() { + self.view = ProfileView() + } + + private var profileView: ProfileView { + guard let view = view as? ProfileView else { + assertionFailure("Failed to dequeue FinanceView") + return ProfileView() + } + return view + } +} diff --git a/Friends/Screens/ViewEventScreen/View/ViewEventView.swift b/Friends/Screens/ViewEventScreen/View/ViewEventView.swift new file mode 100644 index 0000000..26be169 --- /dev/null +++ b/Friends/Screens/ViewEventScreen/View/ViewEventView.swift @@ -0,0 +1,182 @@ +// +// EventView.swift +// Friends +// +// Created by тимур on 31.03.2025. +// + +import SwiftUI + +struct ViewEventView: View { + @StateObject var viewModel: ViewEventViewModel + @State var isShowingSelectFriendsView: Bool = false + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack { + Header(viewModel: viewModel) + .padding([.horizontal, .top]) + + List { + Section { + Text(viewModel.event.title) + Text(viewModel.event.description) + } + + Section { + LocationView(address: viewModel.event.address) + } + + Section { + FriendsList(attendiesInfo: viewModel.attendiesInfo) + } header: { + Text("friends") + } + + Section { + VStack(alignment: .trailing) { + DaysView(viewModel: viewModel) + .padding(.leading, 20) + + HStack { + HoursView() + + TimeGrid(selectedCells: $viewModel.selectedCells, rows: 16, columns: 7, isEditable: viewModel.myStatus == .noReply) + } + } + .padding(.vertical) + .listRowBackground(Color.white) + } header: { + HStack { + Text("Time") + Spacer() + Button(action: { + viewModel.selectAllCells() + }) { + Text("Select All") + .textCase(.none) + .font(.system(size: 16)) + .fontWeight(.medium) + } + .padding(.trailing, 10) + Button(action: { + viewModel.clearCells() + }) { + Text("Clear") + .textCase(.none) + .font(.system(size: 16)) + } + } + } + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + } + .background(Color.background) + .onAppear { + viewModel.loadFriends() + } + } + + private struct Header: View { + @ObservedObject var viewModel: ViewEventViewModel + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack(alignment: .trailing) { + HStack { + Spacer() + if viewModel.isHost { + Text(viewModel.event.isTimeFixed ? "View Event" : "Choose Time") + .fontWeight(.medium) + } + Spacer() + } + + HStack { + Button("Cancel") { + dismiss() + } + Spacer() + Button("Create") { + dismiss() + } + .disabled(viewModel.event.title.isEmpty || viewModel.selectedCells.isEmpty) + .fontWeight(.bold) + } + } + } + } + + private struct FriendsList: View { + let attendiesInfo: [(Person, EventModels.AttendanceStatus)] + + var body: some View { + ForEach(attendiesInfo, id: \.0.id) { info in + HStack { + Image(uiImage: info.0.icon) + .resizable() + .frame(width: 40.0, height: 40.0) + .clipShape(Circle()) + Text(info.0.name) + Spacer() + switch info.1 { + case .attending: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.green) + case .declined: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.red) + case .noReply: + Image(systemName: "questionmark.circle.fill") + .foregroundStyle(Color.yellow) + } + } + } + } + } + + private struct LocationView: View { + let address: String + + var body: some View { + HStack { + Image(systemName: "location.square.fill") + .resizable() + .frame(width: 25, height: 25) + .foregroundStyle(Color.blue) + Text(address) + } + } + } + + private struct DaysView: View { + @ObservedObject var viewModel: ViewEventViewModel + + var body: some View { + HStack { + ForEach(0..<7) { offset in + let date = viewModel.event.creationDate.addingTimeInterval(TimeInterval(86400 * offset)) + Text("\(viewModel.getFormattedDate(from: date))") + .font(.system(size: 16)) + .foregroundStyle(Color.gray) + .frame(maxWidth: .infinity) + } + } + } + } + + private struct HoursView: View { + var body: some View { + VStack { + let hours = Array(8...23) + ForEach(hours, id: \.self) { hour in + Text("\(hour)") + .foregroundStyle(Color.gray) + .font(.system(size: 12)) + .frame(maxHeight: .infinity) + } + } + } + } +} diff --git a/Friends/Screens/ViewEventScreen/ViewController/ViewEventViewController.swift b/Friends/Screens/ViewEventScreen/ViewController/ViewEventViewController.swift new file mode 100644 index 0000000..9a5ee43 --- /dev/null +++ b/Friends/Screens/ViewEventScreen/ViewController/ViewEventViewController.swift @@ -0,0 +1,40 @@ +// +// ViewEventViewController.swift +// Friends +// +// Created by тимур on 03.04.2025. +// + +import UIKit +import SwiftUI +import SnapKit + +final class ViewEventViewController: UIViewController { + let event: EventModels.Event + + init(event: EventModels.Event) { + self.event = event + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let viewModel = ViewEventViewModel(event: event) + let swiftUIView = ViewEventView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: swiftUIView) + + addChild(hostingController) + view.addSubview(hostingController.view) + + hostingController.view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + hostingController.didMove(toParent: self) + } +} diff --git a/Friends/Screens/ViewEventScreen/ViewModel/ViewEventViewModel.swift b/Friends/Screens/ViewEventScreen/ViewModel/ViewEventViewModel.swift new file mode 100644 index 0000000..db77a6a --- /dev/null +++ b/Friends/Screens/ViewEventScreen/ViewModel/ViewEventViewModel.swift @@ -0,0 +1,158 @@ +// +// EvenViewModel.swift +// Friends +// +// Created by тимур on 31.03.2025. +// + +import SwiftUI + +enum UserRole { + case attendee + case host +} + +final class ViewEventViewModel: ObservableObject { + // MARK: - Published + + @Published var event: EventModels.Event + @Published var cellsOpacity = [TimeGrid.Cell: Double]() + @Published var cellFriendLists: [TimeGrid.Cell: [Person]] = [:] + @Published var selectedCells = Set() + @Published var attendiesInfo = [(Person, EventModels.AttendanceStatus)]() + + // MARK: - Private properties + + private let rows = 16 + private let columns = 7 + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEE" + return formatter + }() + private let generator = UIImpactFeedbackGenerator(style: .medium) + private let peopleProvider = PersonNetwork() + + private let id = AppCache.shared.user!.id + + // MARK: - Public properties + + var isHost: Bool { + return event.hostId == id + } + + var title: String { + if isHost { + return event.isTimeFixed ? "View Event" : "Choose Time" + } + + return "View Event" + } + + var headerButtonText: String { + "Confirm" + } + + var myStatus: EventModels.AttendanceStatus { + for info in attendiesInfo { + if info.0.id == id { + return info.1 + } + } + + return .noReply + } + + // MARK: - Initializers + + init(event: EventModels.Event) { + self.event = event + } + + func loadFriends() { + let group = DispatchGroup() + var loadedPeople: [Person] = [] + var host = Person(id: UUID(), name: "", username: "", password: "", debts: []) + + group.enter() + peopleProvider.findUser(by: event.hostId) { result in + defer { group.leave() } + switch result { + case .success(let person): + host = person + case .failure(let error): + print(error.localizedDescription) + } + } + + event.attendiesInfo.forEach { info in + group.enter() + peopleProvider.findUser(by: info.id) { result in + defer { group.leave() } + switch result { + case .success(let person): + DispatchQueue.main.async { + loadedPeople.append(person) + self.attendiesInfo.append((person, info.status)) + + if let selectedCells = info.pickedCells { + selectedCells.forEach { cell in + self.cellFriendLists[cell, default: []].append(person) + } + } + } + case .failure(let error): + print("Failed to load user: \(error)") + } + } + } + + group.notify(queue: .main) { + // Add host to selected cells + for cell in self.selectedCells { + self.cellFriendLists[cell, default: []].append(host) + } + + // Calculate max count + let maxCount = self.cellFriendLists.values.map { $0.count }.max() ?? 1 + + // Update opacities + for (cell, people) in self.cellFriendLists { + self.cellsOpacity[cell] = Double(people.count) / Double(maxCount) + } + + print(self.cellsOpacity) + } + } + + func isTimePicked() -> Bool { + for info in attendiesInfo { + if info.0.id == id { + return info.1 != .noReply + } + } + + return false + } + + func selectAllCells() { + for row in 0.. String { + dateFormatter.string(from: date) + } + + func isMe(id: UUID) -> Bool { + return id == UUID(uuidString: "1") + } +}