diff --git a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj index ca0d87b8..a21469ab 100644 --- a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj +++ b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 67138D942974854F00BBF450 /* XCTestCase+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67138D932974854F00BBF450 /* XCTestCase+.swift */; }; 6718BCA42AD5327F002846A6 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6718BCA32AD5327F002846A6 /* SnapshotHelper.swift */; }; 671D7DEC28210D2F0068E728 /* EmptyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671D7DEB28210D2F0068E728 /* EmptyContentView.swift */; }; + 6725E3552D4CB7300070B323 /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6725E3542D4CB7300070B323 /* Date+.swift */; }; 674023402B0BC01600A7311A /* FeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6740233F2B0BC01600A7311A /* FeedbackSender.swift */; }; 67419ACF282E70B9004F5339 /* ParksListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67419ACE282E70B9004F5339 /* ParksListScreen.swift */; }; 6747575628113419002F0A24 /* ChangePasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6747575528113419002F0A24 /* ChangePasswordScreen.swift */; }; @@ -27,7 +28,6 @@ 67515699283FEC3100501346 /* PickedImagesGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67515698283FEC3100501346 /* PickedImagesGrid.swift */; }; 67551C362AEC338600084A35 /* SWAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67551C352AEC338600084A35 /* SWAddress.swift */; }; 6758463F2965B7F2000BA5E0 /* PDFViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6758463E2965B7F2000BA5E0 /* PDFViewRepresentable.swift */; }; - 675E5618297D21D400B4981A /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675E5617297D21D400B4981A /* Array+.swift */; }; 675EC64F2814126800C2E229 /* TextEntryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC64E2814126800C2E229 /* TextEntryScreen.swift */; }; 675EC6572815433600C2E229 /* UsersListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC6562815433600C2E229 /* UsersListScreen.swift */; }; 675EC65F2815532800C2E229 /* EventFormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC65E2815532800C2E229 /* EventFormScreen.swift */; }; @@ -36,7 +36,13 @@ 67627750283A3A54009C203F /* JournalsListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762774F283A3A54009C203F /* JournalsListScreen.swift */; }; 67627755283A4C77009C203F /* JournalEntriesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67627754283A4C77009C203F /* JournalEntriesScreen.swift */; }; 6762775B283A87AD009C203F /* JournalCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762775A283A87AD009C203F /* JournalCell.swift */; }; + 6765B2562D451771006164AB /* UIImage+toMediaFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B2552D451771006164AB /* UIImage+toMediaFile.swift */; }; + 6765B2582D4544C8006164AB /* MainUserProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */; }; + 6765B25B2D455D5C006164AB /* ProfileViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B25A2D455D5C006164AB /* ProfileViews.swift */; }; + 6765B25D2D46B024006164AB /* AvatarPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B25C2D46B024006164AB /* AvatarPickerView.swift */; }; + 6765B2612D46C0F6006164AB /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 6765B2602D46C0F6006164AB /* InfoPlist.xcstrings */; }; 6766A036284603CA0033F1E8 /* TabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6766A035284603CA0033F1E8 /* TabViewModel.swift */; }; + 676D5A612D48BF0700EE5E9E /* String+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 676D5A602D48BF0700EE5E9E /* String+localized.swift */; }; 6773111A2965FFAA003CD13A /* PreviewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 677311192965FFAA003CD13A /* PreviewContent.swift */; }; 67795FB22D1C05D90087132F /* SWModels in Frameworks */ = {isa = PBXBuildFile; productRef = 67795FB12D1C05D90087132F /* SWModels */; }; 67795FB42D1C05D90087132F /* SWNetworkClient in Frameworks */ = {isa = PBXBuildFile; productRef = 67795FB32D1C05D90087132F /* SWNetworkClient */; }; @@ -61,6 +67,7 @@ 67A079F22A758E7D005EAF70 /* PickedPhotoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A079F12A758E7D005EAF70 /* PickedPhotoView.swift */; }; 67A4710D2AEED8F8004D341D /* PastEventStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A4710C2AEED8F8004D341D /* PastEventStorage.swift */; }; 67A64A242AC83A0F00CBDD5F /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 67A64A232AC83A0F00CBDD5F /* Localizable.xcstrings */; }; + 67A85EDE2D4802C600DCFBBD /* SWAlert in Frameworks */ = {isa = PBXBuildFile; productRef = 67A85EDD2D4802C600DCFBBD /* SWAlert */; }; 67A9C90828427DEA005D6A36 /* ParkFilterScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A9C90728427DEA005D6A36 /* ParkFilterScreen.swift */; }; 67AE072A2D1B1E3B0097B59F /* NetworkStatusEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67AE07292D1B1E3B0097B59F /* NetworkStatusEnvironmentKey.swift */; }; 67B78712281D654C008B104F /* DefaultsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B78711281D654C008B104F /* DefaultsService.swift */; }; @@ -100,6 +107,7 @@ 67138D932974854F00BBF450 /* XCTestCase+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+.swift"; sourceTree = ""; }; 6718BCA32AD5327F002846A6 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; 671D7DEB28210D2F0068E728 /* EmptyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyContentView.swift; sourceTree = ""; }; + 6725E3542D4CB7300070B323 /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = ""; }; 6740233F2B0BC01600A7311A /* FeedbackSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackSender.swift; sourceTree = ""; }; 67419ACE282E70B9004F5339 /* ParksListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParksListScreen.swift; sourceTree = ""; }; 6747575528113419002F0A24 /* ChangePasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePasswordScreen.swift; sourceTree = ""; }; @@ -113,7 +121,6 @@ 67515698283FEC3100501346 /* PickedImagesGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickedImagesGrid.swift; sourceTree = ""; }; 67551C352AEC338600084A35 /* SWAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWAddress.swift; sourceTree = ""; }; 6758463E2965B7F2000BA5E0 /* PDFViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFViewRepresentable.swift; sourceTree = ""; }; - 675E5617297D21D400B4981A /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; 675EC64E2814126800C2E229 /* TextEntryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEntryScreen.swift; sourceTree = ""; }; 675EC65528153B8200C2E229 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 675EC6562815433600C2E229 /* UsersListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersListScreen.swift; sourceTree = ""; }; @@ -123,7 +130,13 @@ 6762774F283A3A54009C203F /* JournalsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalsListScreen.swift; sourceTree = ""; }; 67627754283A4C77009C203F /* JournalEntriesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalEntriesScreen.swift; sourceTree = ""; }; 6762775A283A87AD009C203F /* JournalCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalCell.swift; sourceTree = ""; }; + 6765B2552D451771006164AB /* UIImage+toMediaFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+toMediaFile.swift"; sourceTree = ""; }; + 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUserProfileScreen.swift; sourceTree = ""; }; + 6765B25A2D455D5C006164AB /* ProfileViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViews.swift; sourceTree = ""; }; + 6765B25C2D46B024006164AB /* AvatarPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarPickerView.swift; sourceTree = ""; }; + 6765B2602D46C0F6006164AB /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 6766A035284603CA0033F1E8 /* TabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewModel.swift; sourceTree = ""; }; + 676D5A602D48BF0700EE5E9E /* String+localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+localized.swift"; sourceTree = ""; }; 677311192965FFAA003CD13A /* PreviewContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewContent.swift; sourceTree = ""; }; 677717162B36D87200ED90BD /* SwiftUI-WorkoutApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "SwiftUI-WorkoutApp.xctestplan"; sourceTree = ""; }; 67891E37283E947B00B10802 /* ParkFormScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkFormScreen.swift; sourceTree = ""; }; @@ -148,6 +161,7 @@ 67A079F12A758E7D005EAF70 /* PickedPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickedPhotoView.swift; sourceTree = ""; }; 67A4710C2AEED8F8004D341D /* PastEventStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PastEventStorage.swift; sourceTree = ""; }; 67A64A232AC83A0F00CBDD5F /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 67A85EDB2D48021A00DCFBBD /* SWAlert */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SWAlert; sourceTree = ""; }; 67A9C90728427DEA005D6A36 /* ParkFilterScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkFilterScreen.swift; sourceTree = ""; }; 67AE07292D1B1E3B0097B59F /* NetworkStatusEnvironmentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStatusEnvironmentKey.swift; sourceTree = ""; }; 67AE08482D1C05020097B59F /* SWModels */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SWModels; sourceTree = ""; }; @@ -184,6 +198,7 @@ 67D67DE22AE84D9700F7A8B0 /* FileManager991 in Frameworks */, 67795FB42D1C05D90087132F /* SWNetworkClient in Frameworks */, 67F9534F2964A5700077DFDC /* ImagePicker in Frameworks */, + 67A85EDE2D4802C600DCFBBD /* SWAlert in Frameworks */, 67795FB62D1C05D90087132F /* Utils in Frameworks */, 67CB32D1297BE0F8009380DF /* NetworkStatus in Frameworks */, ); @@ -277,9 +292,11 @@ 6758B93A281E74DF001D83D8 /* Extensions */ = { isa = PBXGroup; children = ( - 675E5617297D21D400B4981A /* Array+.swift */, 674DF03D2B11254D00828016 /* Binding+.swift */, 674DF03F2B11257D00828016 /* NavigationLink+.swift */, + 6765B2552D451771006164AB /* UIImage+toMediaFile.swift */, + 676D5A602D48BF0700EE5E9E /* String+localized.swift */, + 6725E3542D4CB7300070B323 /* Date+.swift */, ); path = Extensions; sourceTree = ""; @@ -306,6 +323,7 @@ children = ( 6798AA83280C0F7D00DB76F1 /* EditProfileScreen.swift */, 6747575528113419002F0A24 /* ChangePasswordScreen.swift */, + 6765B25C2D46B024006164AB /* AvatarPickerView.swift */, ); path = EditProfile; sourceTree = ""; @@ -377,6 +395,7 @@ 6798AA4B280AF1C200DB76F1 /* LaunchScreen.storyboard */, 6798AA41280AEDCA00DB76F1 /* Assets.xcassets */, 67A64A232AC83A0F00CBDD5F /* Localizable.xcstrings */, + 6765B2602D46C0F6006164AB /* InfoPlist.xcstrings */, ); path = Resources; sourceTree = ""; @@ -397,12 +416,14 @@ 6798AA69280B23BF00DB76F1 /* Profile */ = { isa = PBXGroup; children = ( + 6765B25A2D455D5C006164AB /* ProfileViews.swift */, 67419AD4282E8E6F004F5339 /* Journals */, 6798AA5A280AF4C700DB76F1 /* ProfileScreen.swift */, 6770A8352834D7B50006B672 /* EditProfile */, 6798AA67280B23B700DB76F1 /* UserDetailsScreen.swift */, 674D061A28280A63007E75C6 /* FriendRequestsView.swift */, 674D0622282A9896007E75C6 /* SearchUsersScreen.swift */, + 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */, ); path = Profile; sourceTree = ""; @@ -444,6 +465,7 @@ 67AE08472D1C049F0097B59F /* Libraries */ = { isa = PBXGroup; children = ( + 67A85EDB2D48021A00DCFBBD /* SWAlert */, 678CE36A2D1C510900F060C6 /* SWNetwork */, 67AE08482D1C05020097B59F /* SWModels */, 67AE08492D1C05020097B59F /* SWNetworkClient */, @@ -505,6 +527,7 @@ 67795FB12D1C05D90087132F /* SWModels */, 67795FB32D1C05D90087132F /* SWNetworkClient */, 67795FB52D1C05D90087132F /* Utils */, + 67A85EDD2D4802C600DCFBBD /* SWAlert */, ); productName = "SwiftUI-WorkoutApp"; productReference = 6798AA3A280AEDC900DB76F1 /* WorkoutApp.app */; @@ -569,6 +592,7 @@ buildActionMask = 2147483647; files = ( 6798AA4C280AF1C200DB76F1 /* LaunchScreen.storyboard in Resources */, + 6765B2612D46C0F6006164AB /* InfoPlist.xcstrings in Resources */, 6798AA7B280BE8E200DB76F1 /* oldParks.json in Resources */, 6798AA45280AEDCA00DB76F1 /* Preview Assets.xcassets in Resources */, 6798AA79280BE8E200DB76F1 /* countries.json in Resources */, @@ -613,6 +637,7 @@ 671D7DEC28210D2F0068E728 /* EmptyContentView.swift in Sources */, 6773111A2965FFAA003CD13A /* PreviewContent.swift in Sources */, 67551C362AEC338600084A35 /* SWAddress.swift in Sources */, + 6725E3552D4CB7300070B323 /* Date+.swift in Sources */, 6798AA59280AF4B100DB76F1 /* ParksMapScreen.swift in Sources */, 6798AA68280B23B700DB76F1 /* UserDetailsScreen.swift in Sources */, 67AE072A2D1B1E3B0097B59F /* NetworkStatusEnvironmentKey.swift in Sources */, @@ -627,10 +652,12 @@ 674D0623282A9896007E75C6 /* SearchUsersScreen.swift in Sources */, 67A4710D2AEED8F8004D341D /* PastEventStorage.swift in Sources */, 675FB8DB2ADDB87200C9671F /* ParksMapScreen+LocationSettingReminderView.swift in Sources */, + 6765B25D2D46B024006164AB /* AvatarPickerView.swift in Sources */, 6747575628113419002F0A24 /* ChangePasswordScreen.swift in Sources */, 67515699283FEC3100501346 /* PickedImagesGrid.swift in Sources */, - 675E5618297D21D400B4981A /* Array+.swift in Sources */, 67FBF64F28338A2E008A7968 /* EventDetailsScreen.swift in Sources */, + 676D5A612D48BF0700EE5E9E /* String+localized.swift in Sources */, + 6765B2562D451771006164AB /* UIImage+toMediaFile.swift in Sources */, 674DF03E2B11254D00828016 /* Binding+.swift in Sources */, 6766A036284603CA0033F1E8 /* TabViewModel.swift in Sources */, 67D916862838F0DD0098D3CB /* DialogScreen.swift in Sources */, @@ -639,10 +666,12 @@ 674D061B28280A63007E75C6 /* FriendRequestsView.swift in Sources */, 6798AA3E280AEDC900DB76F1 /* SwiftUI_WorkoutAppApp.swift in Sources */, 675EC6632815AA4A00C2E229 /* MapSnapshotView.swift in Sources */, + 6765B25B2D455D5C006164AB /* ProfileViews.swift in Sources */, 67D916812838E2460098D3CB /* DialogsListScreen.swift in Sources */, 6798AA53280AF43900DB76F1 /* EventsListScreen.swift in Sources */, 670CA19E280E8F09003914A3 /* SettingsScreen.swift in Sources */, 6762775B283A87AD009C203F /* JournalCell.swift in Sources */, + 6765B2582D4544C8006164AB /* MainUserProfileScreen.swift in Sources */, 6798AA84280C0F7D00DB76F1 /* EditProfileScreen.swift in Sources */, 6798AA73280B43FE00DB76F1 /* LoginScreen.swift in Sources */, 67D9169628396C1E0098D3CB /* SendMessageScreen.swift in Sources */, @@ -771,6 +800,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; }; @@ -830,6 +860,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; @@ -850,7 +881,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; @@ -900,7 +931,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; @@ -1024,6 +1055,10 @@ isa = XCSwiftPackageProductDependency; productName = Utils; }; + 67A85EDD2D4802C600DCFBBD /* SWAlert */ = { + isa = XCSwiftPackageProductDependency; + productName = SWAlert; + }; 67CB32D0297BE0F8009380DF /* NetworkStatus */ = { isa = XCSwiftPackageProductDependency; package = 67CB32CF297BE0F8009380DF /* XCRemoteSwiftPackageReference "NetworkStatus" */; diff --git a/SwiftUI-WorkoutApp/Extensions/Array+.swift b/SwiftUI-WorkoutApp/Extensions/Array+.swift deleted file mode 100644 index 44b8782d..00000000 --- a/SwiftUI-WorkoutApp/Extensions/Array+.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SWModels -import UIKit.UIImage - -extension [UIImage] { - /// Создает список медиафайлов из картинок для отправки на сервер - var toMediaFiles: [MediaFile] { - enumerated().compactMap { index, image in - guard let imageData = image.jpegData(compressionQuality: 1) else { return nil } - return .init(imageData: imageData, forKey: "\(index + 1)") - } - } -} diff --git a/SwiftUI-WorkoutApp/Extensions/Date+.swift b/SwiftUI-WorkoutApp/Extensions/Date+.swift new file mode 100644 index 00000000..1a388214 --- /dev/null +++ b/SwiftUI-WorkoutApp/Extensions/Date+.swift @@ -0,0 +1,12 @@ +import Foundation + +extension Date: @retroactive RawRepresentable { + public var rawValue: String { + String(timeIntervalSinceReferenceDate) + } + + public init?(rawValue: String) { + guard let interval = Double(rawValue) else { return nil } + self = Date(timeIntervalSinceReferenceDate: interval) + } +} diff --git a/SwiftUI-WorkoutApp/Extensions/String+localized.swift b/SwiftUI-WorkoutApp/Extensions/String+localized.swift new file mode 100644 index 00000000..783e2fa3 --- /dev/null +++ b/SwiftUI-WorkoutApp/Extensions/String+localized.swift @@ -0,0 +1,10 @@ +import Foundation + +extension String { + /// Локализованный вариант с использованием `NSLocalizedString` + /// + /// Нужен для особых случаев, когда не работает дефолтная локализация + var localized: String { + NSLocalizedString(self, comment: "") + } +} diff --git a/SwiftUI-WorkoutApp/Extensions/UIImage+toMediaFile.swift b/SwiftUI-WorkoutApp/Extensions/UIImage+toMediaFile.swift new file mode 100644 index 00000000..eb234c47 --- /dev/null +++ b/SwiftUI-WorkoutApp/Extensions/UIImage+toMediaFile.swift @@ -0,0 +1,24 @@ +import SWModels +import UIKit.UIImage + +extension UIImage { + /// Делает медиафайл из картинки + /// + /// Сначала пробуем конвертацию через jpegData для скорости, но в случае проблем обращаемся к `UIGraphicsImageRenderer` для + /// гарантированного результата + func toMediaFile(with key: String = "") -> MediaFile? { + let data = jpegData(compressionQuality: 1) ?? UIGraphicsImageRenderer(size: size).jpegData(withCompressionQuality: 1) { _ in + draw(in: .init(), blendMode: .normal, alpha: 1) + } + return data.isEmpty ? nil : MediaFile(imageData: data, forKey: key) + } +} + +extension [UIImage] { + /// Создает список медиафайлов из картинок для отправки на сервер + var toMediaFiles: [MediaFile] { + enumerated().compactMap { index, image in + image.toMediaFile(with: "\(index + 1)") + } + } +} diff --git a/SwiftUI-WorkoutApp/Libraries/SWAlert/.gitignore b/SwiftUI-WorkoutApp/Libraries/SWAlert/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/SwiftUI-WorkoutApp/Libraries/SWAlert/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/SwiftUI-WorkoutApp/Libraries/SWAlert/Package.swift b/SwiftUI-WorkoutApp/Libraries/SWAlert/Package.swift new file mode 100644 index 00000000..5ff91180 --- /dev/null +++ b/SwiftUI-WorkoutApp/Libraries/SWAlert/Package.swift @@ -0,0 +1,11 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SWAlert", + platforms: [.iOS(.v15)], + products: [.library(name: "SWAlert", targets: ["SWAlert"])], + targets: [.target(name: "SWAlert")] +) diff --git a/SwiftUI-WorkoutApp/Libraries/SWAlert/README.md b/SwiftUI-WorkoutApp/Libraries/SWAlert/README.md new file mode 100644 index 00000000..2d0c8b9a --- /dev/null +++ b/SwiftUI-WorkoutApp/Libraries/SWAlert/README.md @@ -0,0 +1,3 @@ +# SWAlert + +Содержит синглтон для отображения алерта с любого экрана поверх активного `UIWindow`. diff --git a/SwiftUI-WorkoutApp/Libraries/SWAlert/Sources/SWAlert/SWAlert.swift b/SwiftUI-WorkoutApp/Libraries/SWAlert/Sources/SWAlert/SWAlert.swift new file mode 100644 index 00000000..72acd811 --- /dev/null +++ b/SwiftUI-WorkoutApp/Libraries/SWAlert/Sources/SWAlert/SWAlert.swift @@ -0,0 +1,74 @@ +import SwiftUI +import UIKit + +@MainActor +public final class SWAlert { + public static let shared = SWAlert() + private var currentAlert: UIViewController? + + /// Показывает системный алерт с заданными параметрами + /// - Parameters: + /// - title: Заголовок. Если передать `nil`, то сообщение выделится жирным. Если передать текст или пустую строку, будет без + /// заголовка, и сообщение будет со стандартным шрифтом + /// - message: Текст сообщения + /// - closeButtonTitle: Заголовок кнопки для закрытия алерта + /// - closeButtonStyle: Стиль кнопки для закрытия алерта + /// - closeButtonTintColor: Цвет кнопки для закрытия алерта. Если не настроить явно, то при появлении будет системный (синий) цвет, а + /// при нажатии он изменится на `AccentColor` в проекте + public func presentDefaultUIKit( + title: String? = "", + message: String, + closeButtonTitle: String = "Ok", + closeButtonStyle: UIAlertAction.Style = .default, + closeButtonTintColor: UIColor? = .systemGreen + ) { + guard currentAlert == nil, let topMostViewController else { return } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.view.tintColor = closeButtonTintColor + alert.addAction( + .init( + title: closeButtonTitle, + style: closeButtonStyle, + handler: { [weak self] _ in + self?.dismiss() + } + ) + ) + currentAlert = alert + topMostViewController.present(alert, animated: true) + } + + private func dismiss() { + currentAlert?.dismiss(animated: true) + currentAlert = nil + } + + private var topMostViewController: UIViewController? { + UIApplication.shared.firstKeyWindow?.rootViewController?.topMostViewController + } +} + +private extension UIApplication { + var firstKeyWindow: UIWindow? { + connectedScenes + .filter { $0.activationState == .foregroundActive } + .compactMap { $0 as? UIWindowScene } + .first?.windows + .first(where: \.isKeyWindow) + } +} + +private extension UIViewController { + var topMostViewController: UIViewController { + if let presented = presentedViewController { + return presented.topMostViewController + } + if let navigation = self as? UINavigationController { + return navigation.visibleViewController?.topMostViewController ?? navigation + } + if let tab = self as? UITabBarController { + return tab.selectedViewController?.topMostViewController ?? tab + } + return self + } +} diff --git a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/AuthData.swift b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/AuthData.swift index 3b7e82c8..407a67a6 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/AuthData.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/AuthData.swift @@ -1,13 +1,14 @@ /// Используется во всех запросах, где нужна авторизация public struct AuthData: Codable { - public let login, password: String - - public var base64Encoded: String? { - (login + ":" + password).data(using: .utf8)?.base64EncodedString() - } + /// Используем для генерации токена + /// + /// Например, при смене логина нужно сгенерировать новый токен, чтобы не выбросило из аккаунта - вот тут и используем этот пароль + public let password: String + /// Отправляем на сервер + public let token: String? public init(login: String, password: String) { - self.login = login + self.token = (login + ":" + password).data(using: .utf8)?.base64EncodedString() self.password = password } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/DefaultsProtocol.swift b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/DefaultsProtocol.swift index a21f35aa..b1d7481b 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/DefaultsProtocol.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/DefaultsProtocol.swift @@ -2,41 +2,8 @@ import Foundation @MainActor public protocol DefaultsProtocol: AnyObject, Sendable { - var appTheme: AppColorTheme { get } - var mainUserInfo: UserResponse? { get } - var mainUserCountryID: Int { get } - var mainUserCityID: Int { get } - var needUpdateUser: Bool { get } - var isAuthorized: Bool { get } - var friendRequestsList: [UserResponse] { get } - var friendsIdsList: [Int] { get } - var blacklistedUsers: [UserResponse] { get } - var unreadMessagesCount: Int { get } - /// Дефолтная дата - предыдущее ручное обновление файла `countries.json` - var lastCountriesUpdateDate: Date { get } - func setAppTheme(_ theme: AppColorTheme) - func saveAuthData(_ info: AuthData) throws - func basicAuthInfo() throws -> AuthData - func setUserNeedUpdate(_ newValue: Bool) - /// Обновляет `lastCountriesUpdateDate` - func didUpdateCountries() - func saveUserInfo(_ info: UserResponse) throws - func saveFriendsIds(_ ids: [Int]) throws - func saveFriendRequests(_ array: [UserResponse]) throws - func saveUnreadMessagesCount(_ count: Int) - func saveBlacklist(_ array: [UserResponse]) throws - func updateBlacklist(option: BlacklistOption, user: UserResponse) - func setHasJournals(_ hasJournals: Bool) - func setHasParks(_ isAddedPark: Bool) + /// Токен авторизации для запросов к серверу + var authToken: String? { get } + /// Логаут с удалением всех данных пользователя func triggerLogout() } - -extension Date: @retroactive RawRepresentable { - public var rawValue: String { - timeIntervalSinceReferenceDate.description - } - - public init?(rawValue: String) { - self = Date(timeIntervalSinceReferenceDate: Double(rawValue) ?? 0.0) - } -} diff --git a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MainUserForm.swift b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MainUserForm.swift index 2ddb6337..b26694b1 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MainUserForm.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MainUserForm.swift @@ -8,6 +8,7 @@ public struct MainUserForm: Codable, Equatable, Sendable { public var genderCode: Int public var country: Country public var city: City + public var image: MediaFile? public init( userName: String, @@ -17,7 +18,8 @@ public struct MainUserForm: Codable, Equatable, Sendable { birthDate: Date, gender: Int, country: Country, - city: City + city: City, + image: MediaFile? = nil ) { self.userName = userName self.fullName = fullName @@ -27,17 +29,20 @@ public struct MainUserForm: Codable, Equatable, Sendable { self.country = country self.city = city self.genderCode = gender + self.image = image } public init(_ user: UserResponse) { - self.userName = user.userName ?? "" - self.fullName = user.fullName ?? "" - self.email = user.email ?? "" - self.password = "" - self.birthDate = user.birthDate - self.country = .init(cities: [], id: (user.countryID ?? 0).description, name: "") - self.city = .init(id: (user.cityID ?? 0).description) - self.genderCode = user.genderCode ?? 0 + self.init( + userName: user.userName ?? "", + fullName: user.fullName ?? "", + email: user.email ?? "", + password: "", + birthDate: user.birthDate, + gender: user.genderCode ?? 0, + country: .init(cities: [], id: (user.countryID ?? 0).description, name: ""), + city: .init(id: (user.cityID ?? 0).description) + ) } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MediaFile.swift b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MediaFile.swift index 4fec9747..c3f992bd 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MediaFile.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/MediaFile.swift @@ -6,6 +6,10 @@ public struct MediaFile: Codable, Equatable, Sendable { public let data: Data public let mimeType: String + /// Инициализатор для добавления фото площадки/мероприятия + /// - Parameters: + /// - imageData: Данные для картинки + /// - key: Индекс public init(imageData: Data, forKey key: String) { self.key = "photo\(key)" self.mimeType = "image/jpeg" diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift index 61829fc2..737b33dd 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift @@ -39,16 +39,16 @@ public enum BodyMaker { var body = Data() if !parameters.isEmpty { parameters.forEach { element in - body.append("--\(boundary + lineBreak)") + body.append("--\(boundary)\(lineBreak)") body.append("Content-Disposition: form-data; name=\"\(element.key)\"\(lineBreak + lineBreak)") - body.append("\(element.value + lineBreak)") + body.append("\(element.value)\(lineBreak)") } } if let media, !media.isEmpty { media.forEach { photo in - body.append("--\(boundary + lineBreak)") + body.append("--\(boundary)\(lineBreak)") body.append("Content-Disposition: form-data; name=\"\(photo.key)\"; filename=\"\(photo.filename)\"\(lineBreak)") - body.append("Content-Type: \(photo.mimeType + lineBreak + lineBreak)") + body.append("Content-Type: \(photo.mimeType)\(lineBreak + lineBreak)") body.append(photo.data) body.append(lineBreak) } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift index 3fcf824a..bba8e6d8 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift @@ -2,7 +2,7 @@ import Foundation extension Data { var prettyJson: String { - if let object = try? JSONSerialization.jsonObject(with: self, options: [.fragmentsAllowed]), + if let object = try? JSONSerialization.jsonObject(with: self, options: []), let jsonData = try? JSONSerialization.data(withJSONObject: object, options: .prettyPrinted), let json = String(data: jsonData, encoding: .utf8) { json @@ -11,7 +11,7 @@ extension Data { } } - public mutating func append(_ string: String) { + mutating func append(_ string: String) { if let data = string.data(using: .utf8) { append(data) } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift index f56b9223..b30b7a26 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift @@ -6,8 +6,4 @@ public struct HTTPHeaderField: Equatable { self.key = key self.value = value } - - public static func authorizationBasic(_ token: String) -> HTTPHeaderField { - .init(key: "Authorization", value: "Basic \(token)") - } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift index c97b566d..b6911f9b 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift @@ -4,31 +4,30 @@ public struct RequestComponents { let path: String let queryItems: [URLQueryItem] let httpMethod: HTTPMethod - public var headerFields: [HTTPHeaderField] + let hasMultipartFormData: Bool let body: Data? - /// Токен для авторизации - public var token: String? + let token: String? /// Инициализатор /// - Parameters: /// - path: Путь запроса /// - queryItems: Параметры `query`, по умолчанию отсутствуют /// - httpMethod: Метод запроса - /// - headerFields: Параметры хедеров, по умолчанию отсутствуют + /// - hasMultipartFormData: Есть ли в запросе файлы для отправки (в нашем случае картинки), по умолчанию `false` /// - body: Тело запроса, по умолчанию `nil` /// - token: Токен для авторизации, по умолчанию `nil` public init( path: String, queryItems: [URLQueryItem] = [], httpMethod: HTTPMethod, - headerFields: [HTTPHeaderField] = [], + hasMultipartFormData: Bool = false, body: Data? = nil, token: String? = nil ) { self.path = path self.queryItems = queryItems self.httpMethod = httpMethod - self.headerFields = headerFields + self.hasMultipartFormData = hasMultipartFormData self.body = body self.token = token } @@ -36,6 +35,7 @@ public struct RequestComponents { var url: URL? { let scheme = "https" let host = "workout.su/api/v3" + guard path.starts(with: "/") else { return nil } let stringComponents = "\(scheme)://\(host)\(path)" var components = URLComponents(string: stringComponents) if !queryItems.isEmpty { @@ -51,9 +51,16 @@ extension RequestComponents { var request = URLRequest(url: url) request.httpMethod = httpMethod.rawValue request.httpBody = body - var allHeaders = headerFields - if let token { - allHeaders.append(.authorizationBasic(token)) + var allHeaders = [HTTPHeaderField]() + // TODO: генерировать boundary в одном месте (вместо FFF) + if let body { + allHeaders.append(.init(key: "Content-Length", value: "\(body.count)")) + } + if hasMultipartFormData { + allHeaders.append(.init(key: "Content-Type", value: "multipart/form-data; boundary=FFF")) + } + if let token, !token.isEmpty { + allHeaders.append(.init(key: "Authorization", value: "Basic \(token)")) } request.allHTTPHeaderFields = Dictionary(uniqueKeysWithValues: allHeaders.map { ($0.key, $0.value) }) return request diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift index aaa87e54..a38e4c53 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift @@ -75,9 +75,11 @@ extension SWNetworkService: SWNetworkProtocol { guard let response else { throw logUnknownError(request: request, data: data) } - let isSuccess = StatusCodeGroup(code: response.statusCode).isSuccess - guard isSuccess else { - let errorInfo = try decoder.decode(ErrorResponse.self, from: data) + if StatusCodeGroup(code: response.statusCode).isSuccess { + logSuccess(request: request, data: data) + return true + } + if let errorInfo = try? decoder.decode(ErrorResponse.self, from: data) { let apiError = APIError(errorInfo, response.statusCode) log( apiError, @@ -86,10 +88,9 @@ extension SWNetworkService: SWNetworkProtocol { data: data, response: response ) - return false + throw apiError } - logSuccess(request: request, data: data) - return true + return false } catch { throw handleUrlSession(error, request) } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift index c0b77c1a..c6d2dc1b 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift @@ -8,25 +8,53 @@ struct RequestComponentsTests { let path = "/test" let queryItems = [URLQueryItem(name: "key", value: "value")] let httpMethod = HTTPMethod.get - let headerFields = [HTTPHeaderField(key: "Authorization", value: "Bearer token")] let body = Data("test body".utf8) let token = "token" let requestComponents = RequestComponents( path: path, queryItems: queryItems, httpMethod: httpMethod, - headerFields: headerFields, body: body, token: token ) #expect(requestComponents.path == path) #expect(requestComponents.queryItems == queryItems) #expect(requestComponents.httpMethod == httpMethod) - #expect(requestComponents.headerFields == headerFields) #expect(requestComponents.body == body) #expect(requestComponents.token == token) } + @Test(arguments: [HTTPMethod.put, .get, .post, .delete]) + func urlRequestCreationWithSpecificMethod(_ method: HTTPMethod) throws { + let requestComponents = RequestComponents( + path: "/test", + httpMethod: method + ) + let urlRequest = try #require(requestComponents.urlRequest) + #expect(urlRequest.httpMethod == method.rawValue) + } + + @Test("Генерация URL без ведущего слеша в path") + func urlCreationFailure() { + let requestComponents = RequestComponents( + path: "invalidpath", + httpMethod: .get + ) + #expect(requestComponents.url == nil) + } + + @Test + func urlRequestCreationWithEmptyToken() throws { + let requestComponents = RequestComponents( + path: "/test", + httpMethod: .get, + token: "" + ) + let urlRequest = try #require(requestComponents.urlRequest) + let headers = try #require(urlRequest.allHTTPHeaderFields) + #expect(headers["Authorization"] == nil) + } + @Test func urlGeneration() throws { let requestComponents = RequestComponents( @@ -41,55 +69,77 @@ struct RequestComponentsTests { @Test func urlRequestCreationWithAuthToken() throws { - let path = "/test" - let queryItems = [URLQueryItem(name: "key", value: "value")] - let httpMethod = HTTPMethod.post - let headerFields = [HTTPHeaderField(key: "key", value: "value")] let body = Data("test body".utf8) - let token = "token123" let requestComponents = RequestComponents( - path: path, - queryItems: queryItems, - httpMethod: httpMethod, - headerFields: headerFields, + path: "/test", + httpMethod: .post, body: body, - token: token + token: "token123" ) let urlRequest = try #require(requestComponents.urlRequest) - let expectedHeaderFields: [HTTPHeaderField] = [ - .init(key: "key", value: "value"), - .init(key: "Authorization", value: "Basic token123") - ] - let finalHeaderFields = try #require(urlRequest.allHTTPHeaderFields) - #expect(urlRequest.httpMethod == httpMethod.rawValue) + let headers = try #require(urlRequest.allHTTPHeaderFields) #expect(urlRequest.httpBody == body) - #expect(finalHeaderFields.count == expectedHeaderFields.count) - #expect( - expectedHeaderFields.allSatisfy { header in - finalHeaderFields[header.key] == header.value - } - ) + #expect(headers.count == 2) + #expect(headers["Content-Length"] == "\(body.count)") + #expect(headers["Authorization"] == "Basic token123") } @Test func urlRequestCreationWithoutAuthToken() throws { - let path = "/test" - let queryItems = [URLQueryItem(name: "key", value: "value")] - let httpMethod = HTTPMethod.post - let headerFields = [HTTPHeaderField(key: "key", value: "value")] let body = Data("test body".utf8) let requestComponents = RequestComponents( - path: path, - queryItems: queryItems, - httpMethod: httpMethod, - headerFields: headerFields, + path: "/test", + httpMethod: .post, body: body ) let urlRequest = try #require(requestComponents.urlRequest) - let expectedHeaderFields = ["key": "value"] - let finalHeaderFields = try #require(urlRequest.allHTTPHeaderFields) - #expect(urlRequest.httpMethod == httpMethod.rawValue) + let headerFields = try #require(urlRequest.allHTTPHeaderFields) #expect(urlRequest.httpBody == body) - #expect(finalHeaderFields == expectedHeaderFields) + #expect(headerFields == ["Content-Length": "\(body.count)"]) + } + + @Test + func urlRequestCreationWithoutBody() throws { + let requestComponents = RequestComponents( + path: "/test", + httpMethod: .get, + body: nil, + token: "token" + ) + let urlRequest = try #require(requestComponents.urlRequest) + let headers = try #require(urlRequest.allHTTPHeaderFields) + #expect(headers.count == 1) + #expect(headers["Content-Length"] == nil) + #expect(headers["Authorization"] == "Basic token") + } + + @Test + func urlRequestCreationWithMultipartFormData() throws { + let body = Data("test".utf8) + let requestComponents = RequestComponents( + path: "/upload", + httpMethod: .post, + hasMultipartFormData: true, + body: body, + token: "token" + ) + let urlRequest = try #require(requestComponents.urlRequest) + let headers = try #require(urlRequest.allHTTPHeaderFields) + #expect(headers.count == 3) + #expect(headers["Content-Type"] == "multipart/form-data; boundary=FFF") + #expect(headers["Content-Length"] == "\(body.count)") + #expect(headers["Authorization"] == "Basic token") + } + + @Test + func urlGenerationWithSpecialCharacters() throws { + let requestComponents = RequestComponents( + path: "/test path", + queryItems: [URLQueryItem(name: "key", value: "value with space")], + httpMethod: .get + ) + let resultURL = try #require(requestComponents.url) + let expectedString = "https://workout.su/api/v3/test%20path?key=value%20with%20space" + #expect(resultURL.absoluteString == expectedString) } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/ClientError.swift b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/ClientError.swift new file mode 100644 index 00000000..fc232c97 --- /dev/null +++ b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/ClientError.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum ClientError: Error, LocalizedError { + case forceLogout + + public var errorDescription: String? { + switch self { + case .forceLogout: "Для корректной работы приложения нужен повторный вход" + } + } +} diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift index f7dbbcf8..0871ad81 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift @@ -25,7 +25,7 @@ enum Endpoint { // MARK: Удалить профиль текущего пользователя /// **DELETE** ${API}/users/current - case deleteUser(auth: AuthData) + case deleteUser // MARK: Получить профиль пользователя /// **GET** ${API}/users/ @@ -383,11 +383,10 @@ extension Endpoint { } } - var headers: [HTTPHeaderField] { + var hasMultipartFormData: Bool { switch self { - case .createPark, .editPark, .createEvent, .editEvent: - [.init(key: "Content-Type", value: "multipart/form-data; boundary=FFF")] - default: [] + case .createPark, .editPark, .createEvent, .editEvent, .editUser: true + default: false } } @@ -400,9 +399,7 @@ extension Endpoint { } enum ParameterKey: String { - case name, fullname, email, password, - comment, message, title, description, - date, address, latitude, longitude + case name, fullname, email, password, comment, message, title, description, date, address, latitude, longitude, image case areaID = "area_id" case viewAccess = "view_access" case commentAccess = "comment_access" @@ -448,16 +445,30 @@ extension Endpoint { ].map(BodyMaker.Parameter.init) ) case let .editUser(_, form): - return BodyMaker.makeBody( - with: [ - ParameterKey.name: form.userName, - .fullname: form.fullName, - .email: form.email, - .genderCode: form.genderCode.description, - .countryID: form.country.id, - .cityID: form.city.id, - .birthDate: form.birthDateIsoString - ].map(BodyMaker.Parameter.init) + let parameters = [ + ParameterKey.name: form.userName, + .fullname: form.fullName, + .email: form.email, + .genderCode: form.genderCode.description, + .countryID: form.country.id, + .cityID: form.city.id, + .birthDate: form.birthDateIsoString + ] + let mediaFiles: [BodyMaker.MediaFile]? = if let image = form.image { + [ + BodyMaker.MediaFile( + key: ParameterKey.image.rawValue, + filename: "\(UUID().uuidString).jpg", + data: image.data, + mimeType: image.mimeType + ) + ] + } else { + nil + } + return BodyMaker.makeBodyWithMultipartForm( + with: parameters.map(BodyMaker.Parameter.init), + and: mediaFiles ) case let .resetPassword(login): return BodyMaker.makeBody( diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift index bb22fb8a..209ee2b1 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift @@ -24,42 +24,36 @@ public struct SWClient: Sendable { /// поэтому этот запрос не используется /// - Parameter model: необходимые для регистрации данные /// - Returns: Вся информация о пользователе - public func registration(with model: MainUserForm) async throws { + public func registration(with model: MainUserForm) async throws -> Bool { let endpoint = Endpoint.registration(form: model) - let result: UserResponse = try await makeResult(for: endpoint) - try await defaults.saveAuthData(.init(login: model.userName, password: model.password)) - try await defaults.saveUserInfo(result) + return try await makeStatus(for: endpoint) } - /// Запрашивает `id` пользователя для входа в учетную запись - /// - Parameters: - /// - login: логин или email для входа - /// - password: пароль от учетной записи - public func logInWith(_ login: String, _ password: String) async throws { - let authData = AuthData(login: login, password: password) - try await defaults.saveAuthData(authData) + /// Выполняет авторизацию + /// - Parameter token: Токен авторизации + /// - Returns: `id` авторизованного пользователя + public func logIn(with token: String?) async throws -> Int { let endpoint = Endpoint.login - let result: LoginResponse = try await makeResult(for: endpoint) - try await getUserByID(result.userID, loginFlow: true) - await getSocialUpdates(userID: result.userID) + let finalComponents = try await makeComponents(for: endpoint, with: token) + let result: LoginResponse = try await service.requestData(components: finalComponents) + return result.userID } /// Запрашивает обновления списка друзей, заявок в друзья, черного списка /// /// - Вызывается при авторизации и при `scenePhase = active` /// - Список чатов не обновляет - /// - Returns: `true` - все успешно обновилось, `false` - что-то не обновилось - @discardableResult - public func getSocialUpdates(userID: Int?) async -> Bool { - guard let userID else { return false } - do { - try await getFriendsForUser(id: userID) - try await getFriendRequests() - try await getBlacklist() - return true - } catch { - return false - } + /// - Parameter userID: Идентификатор основного пользователя + /// - Returns: Список друзей, заявок в друзья и черный список + public func getSocialUpdates(userID: Int) async -> ( + friends: [UserResponse], + friendRequests: [UserResponse], + blacklist: [UserResponse] + )? { + async let friendsForUser = getFriendsForUser(id: userID) + async let friendRequests = getFriendRequests() + async let blacklist = getBlacklist() + return try? await (friendsForUser, friendRequests, blacklist) } /// Запрашивает данные пользователя по `id` @@ -67,17 +61,11 @@ public struct SWClient: Sendable { /// В случае успеха сохраняет данные главного пользователя в `defaults` и авторизует, если еще не авторизован /// - Parameters: /// - userID: `id` пользователя - /// - loginFlow: `true` - флоу авторизации пользователя, `false` - флоу получения данных другого пользователя /// - Returns: вся информация о пользователе @discardableResult - public func getUserByID(_ userID: Int, loginFlow: Bool = false) async throws -> UserResponse { + public func getUserByID(_ userID: Int) async throws -> UserResponse { let endpoint = Endpoint.getUser(id: userID) - let result: UserResponse = try await makeResult(for: endpoint) - let mainUserID = await defaults.mainUserInfo?.id - if loginFlow || userID == mainUserID { - try await defaults.saveUserInfo(result) - } - return result + return try await makeResult(for: endpoint) } /// Сбрасывает пароль для неавторизованного пользователя с указанным логином @@ -93,14 +81,10 @@ public struct SWClient: Sendable { /// - Parameters: /// - id: `id` пользователя /// - model: данные для изменения - /// - Returns: `true` в случае успеха, `false` при ошибках - public func editUser(_ id: Int, model: MainUserForm) async throws -> Bool { - let authData = try await defaults.basicAuthInfo() + /// - Returns: Актуальные данные пользователя + public func editUser(_ id: Int, model: MainUserForm) async throws -> UserResponse { let endpoint = Endpoint.editUser(id: id, form: model) - let result: UserResponse = try await makeResult(for: endpoint) - try await defaults.saveAuthData(.init(login: model.userName, password: authData.password)) - try await defaults.saveUserInfo(result) - return result.userName == model.userName + return try await makeResult(for: endpoint) } /// Меняет текущий пароль на новый @@ -115,11 +99,9 @@ public struct SWClient: Sendable { #warning("Запрос не используется, т.к. регистрация в приложении отключена") /// Запрашивает удаление профиля текущего пользователя приложения - public func deleteUser() async throws { - let endpoint = try await Endpoint.deleteUser(auth: defaults.basicAuthInfo()) - if try await makeStatus(for: endpoint) { - await defaults.triggerLogout() - } + public func deleteUser() async throws -> Bool { + let endpoint = Endpoint.deleteUser + return try await makeStatus(for: endpoint) } /// Загружает список друзей для выбранного пользователя @@ -127,28 +109,21 @@ public struct SWClient: Sendable { /// Для главного пользователя в случае успеха сохраняет идентификаторы друзей в `defaults` /// - Parameter id: `id` пользователя /// - Returns: Список друзей выбранного пользователя - @discardableResult public func getFriendsForUser(id: Int) async throws -> [UserResponse] { let endpoint = Endpoint.getFriendsForUser(id: id) - let result: [UserResponse] = try await makeResult(for: endpoint) - if await id == defaults.mainUserInfo?.id { - try await defaults.saveFriendsIds(result.map(\.id)) - } - return result + return try await makeResult(for: endpoint) } - /// Загружает список заявок на добавление в друзья, в случае успеха сохраняет в `defaults` - public func getFriendRequests() async throws { + /// Загружает список заявок на добавление в друзья + public func getFriendRequests() async throws -> [UserResponse] { let endpoint = Endpoint.getFriendRequests - let result: [UserResponse] = try await makeResult(for: endpoint) - try await defaults.saveFriendRequests(result) + return try await makeResult(for: endpoint) } - /// Загружает черный список пользователей, в случае успеха сохраняет в `defaults` - public func getBlacklist() async throws { + /// Загружает черный список пользователей + public func getBlacklist() async throws -> [UserResponse] { let endpoint = Endpoint.getBlacklist - let result: [UserResponse] = try await makeResult(for: endpoint) - try await defaults.saveBlacklist(result) + return try await makeResult(for: endpoint) } /// Отвечает на заявку для добавления в друзья @@ -162,14 +137,7 @@ public struct SWClient: Sendable { let endpoint: Endpoint = accept ? .acceptFriendRequest(from: userID) : .declineFriendRequest(from: userID) - let isSuccess = try await makeStatus(for: endpoint) - if isSuccess { - if let mainUserID = await defaults.mainUserInfo?.id, accept { - try await getFriendsForUser(id: mainUserID) - } - try await getFriendRequests() - } - return isSuccess + return try await makeStatus(for: endpoint) } /// Совершает действие со статусом друга/пользователя @@ -181,12 +149,7 @@ public struct SWClient: Sendable { let endpoint: Endpoint = option == .sendFriendRequest ? .sendFriendRequest(to: userID) : .deleteFriend(userID) - let isSuccess = try await makeStatus(for: endpoint) - if let mainUserID = await defaults.mainUserInfo?.id, - isSuccess, option == .removeFriend { - try await getFriendsForUser(id: mainUserID) - } - return isSuccess + return try await makeStatus(for: endpoint) } /// Добавляет или убирает пользователя из черного списка @@ -200,9 +163,7 @@ public struct SWClient: Sendable { let endpoint: Endpoint = option == .add ? .addToBlacklist(user.id) : .deleteFromBlacklist(user.id) - let isSuccess = try await makeStatus(for: endpoint) - await defaults.updateBlacklist(option: option, user: user) - return isSuccess + return try await makeStatus(for: endpoint) } /// Ищет пользователей, чей логин содержит указанный текст @@ -341,9 +302,7 @@ public struct SWClient: Sendable { /// - Returns: `true` в случае успеха, `false` при ошибках public func changeTrainHereStatus(_ trainHere: Bool, for parkID: Int) async throws -> Bool { let endpoint: Endpoint = trainHere ? .postTrainHere(parkID) : .deleteTrainHere(parkID) - let isOk = try await makeStatus(for: endpoint) - await defaults.setHasParks(trainHere) - return isOk + return try await makeStatus(for: endpoint) } /// Запрашивает список мероприятий @@ -448,11 +407,7 @@ public struct SWClient: Sendable { /// - Returns: Список дневников public func getJournals(for userID: Int) async throws -> [JournalResponse] { let endpoint = Endpoint.getJournals(userID: userID) - let result: [JournalResponse] = try await makeResult(for: endpoint) - if await userID == defaults.mainUserInfo?.id { - await defaults.setHasJournals(!result.isEmpty) - } - return result + return try await makeResult(for: endpoint) } #warning("Запрос не используется") @@ -473,16 +428,18 @@ public struct SWClient: Sendable { /// - Parameters: /// - journalID: `id` выбранного дневника /// - title: название дневника + /// - mainUserID: `id` главного пользователя /// - viewAccess: доступ на просмотр /// - commentAccess: доступ на комментирование /// - Returns: `true` в случае успеха, `false` при ошибках public func editJournalSettings( - for journalID: Int, + with journalID: Int, title: String, + for mainUserID: Int?, viewAccess: JournalAccess, commentAccess: JournalAccess ) async throws -> Bool { - guard let mainUserID = await defaults.mainUserInfo?.id else { + guard let mainUserID else { throw APIError.invalidUserID } let endpoint = Endpoint.editJournalSettings( @@ -495,11 +452,12 @@ public struct SWClient: Sendable { return try await makeStatus(for: endpoint) } - /// Создает новый дневник для пользователя - /// - Parameter title: название дневника - /// - Returns: `true` в случае успеха, `false` при ошибках - public func createJournal(with title: String) async throws -> Bool { - guard let mainUserID = await defaults.mainUserInfo?.id else { + /// - Parameters: + /// - title: название дневника + /// - mainUserID: `id` главного пользователя + /// - Returns: Создает новый дневник для пользователя + public func createJournal(with title: String, for mainUserID: Int?) async throws -> Bool { + guard let mainUserID else { throw APIError.invalidUserID } let endpoint = Endpoint.createJournal(userID: mainUserID, title: title) @@ -518,11 +476,11 @@ public struct SWClient: Sendable { /// Удаляет выбранный дневник /// - Parameters: - /// - userID: `id` владельца дневника /// - journalID: `id` дневника для удаления + /// - mainUserID: `id` владельца дневника (главного пользователя) /// - Returns: `true` в случае успеха, `false` при ошибках - public func deleteJournal(journalID: Int) async throws -> Bool { - guard let mainUserID = await defaults.mainUserInfo?.id else { + public func deleteJournal(with journalID: Int, for mainUserID: Int?) async throws -> Bool { + guard let mainUserID else { throw APIError.invalidUserID } let endpoint = Endpoint.deleteJournal(userID: mainUserID, journalID: journalID) @@ -555,33 +513,39 @@ private extension SWClient { return try await service.requestStatus(components: finalComponents) } catch APIError.invalidCredentials { await defaults.triggerLogout() - throw APIError.invalidCredentials + throw ClientError.forceLogout } catch { throw error } } - func makeResult(for endpoint: Endpoint) async throws -> T { + func makeResult( + for endpoint: Endpoint, + with token: String? = nil + ) async throws -> T { do { - let finalComponents = try await makeComponents(for: endpoint) + let finalComponents = try await makeComponents(for: endpoint, with: token) return try await service.requestData(components: finalComponents) } catch APIError.invalidCredentials { await defaults.triggerLogout() - throw APIError.invalidCredentials + throw ClientError.forceLogout } catch { throw error } } - private func makeComponents(for endpoint: Endpoint) async throws -> RequestComponents { - let encodedString = try? await defaults.basicAuthInfo().base64Encoded + func makeComponents( + for endpoint: Endpoint, + with token: String? = nil + ) async throws -> RequestComponents { + let savedToken = await defaults.authToken return .init( path: endpoint.urlPath, queryItems: endpoint.queryItems, httpMethod: endpoint.method, - headerFields: endpoint.headers, + hasMultipartFormData: endpoint.hasMultipartFormData, body: endpoint.httpBody, - token: encodedString + token: token ?? savedToken ) } } diff --git a/SwiftUI-WorkoutApp/Libraries/Utils/Sources/Utils/DateFormatter/DateFormatterService.swift b/SwiftUI-WorkoutApp/Libraries/Utils/Sources/Utils/DateFormatter/DateFormatterService.swift index 9d0a9d58..5bd2ded0 100644 --- a/SwiftUI-WorkoutApp/Libraries/Utils/Sources/Utils/DateFormatter/DateFormatterService.swift +++ b/SwiftUI-WorkoutApp/Libraries/Utils/Sources/Utils/DateFormatter/DateFormatterService.swift @@ -22,10 +22,16 @@ public enum DateFormatterService { } } - public static func stringFromFullDate(_ date: Date, format: DateFormat = .isoDateTimeSec, iso: Bool = true) -> String { + public static func stringFromFullDate( + _ date: Date, + format: DateFormat = .isoDateTimeSec, + timeZone: TimeZone? = nil, + iso: Bool = true + ) -> String { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = format.rawValue + dateFormatter.timeZone = timeZone var string = dateFormatter.string(from: date) if iso { string.append("Z") } return string @@ -43,9 +49,14 @@ public enum DateFormatterService { ISO8601DateFormatter().date(from: string ?? "") ?? .now } - public static func dateFromString(_ string: String?, format: DateFormat) -> Date { + public static func dateFromString( + _ string: String?, + format: DateFormat, + timeZone: TimeZone? = nil + ) -> Date { let formatter = DateFormatter() formatter.dateFormat = format.rawValue + formatter.timeZone = timeZone formatter.locale = Locale(identifier: "en_US_POSIX") return formatter.date(from: string ?? "") ?? .now } @@ -61,7 +72,13 @@ public enum DateFormatterService { /// - date2: Дата 2 /// - Returns: Количество дней между датами public static func days(from date1: Date, to date2: Date) -> Int { - Calendar.current.dateComponents([.day], from: date1, to: date2).day ?? 0 + let calendar = Calendar(identifier: .iso8601) + let components = calendar.dateComponents( + [.day], + from: calendar.startOfDay(for: date1), + to: calendar.startOfDay(for: date2) + ) + return components.day ?? 0 } } diff --git a/SwiftUI-WorkoutApp/Libraries/Utils/Tests/UtilsTests/UtilsTests.swift b/SwiftUI-WorkoutApp/Libraries/Utils/Tests/UtilsTests/UtilsTests.swift index d7f7ed8d..4be9bb5e 100644 --- a/SwiftUI-WorkoutApp/Libraries/Utils/Tests/UtilsTests/UtilsTests.swift +++ b/SwiftUI-WorkoutApp/Libraries/Utils/Tests/UtilsTests/UtilsTests.swift @@ -54,8 +54,13 @@ struct UtilsTests { @Test func stringFromFullDate_serverDateTimeSec() { let date = Date(timeIntervalSinceReferenceDate: 695987883.572933) - let formattedResult = DateFormatterService.stringFromFullDate(date, format: .serverDateTimeSec, iso: false) - let expectedString = "2023-01-21T12:58:03" + let formattedResult = DateFormatterService.stringFromFullDate( + date, + format: .serverDateTimeSec, + timeZone: TimeZone(secondsFromGMT: 0), + iso: false + ) + let expectedString = "2023-01-21T09:58:03" #expect(formattedResult == expectedString) } @@ -68,19 +73,103 @@ struct UtilsTests { } @Test - func dateFromString_isoShortDate() { - let stringDate = "1992-08-12" - let formattedResult = DateFormatterService.dateFromString(stringDate, format: .isoShortDate) - let expectedDate = Date(timeIntervalSinceReferenceDate: -264744000.0) - #expect(formattedResult == expectedDate) + func dateFromString_isoShortDate() throws { + var utcCalendar = Calendar(identifier: .iso8601) + utcCalendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let components = DateComponents( + year: 1992, + month: 8, + day: 12, + hour: 0, + minute: 0, + second: 0 + ) + let expectedDate = try #require(utcCalendar.date(from: components)) + let formattedResult = DateFormatterService.dateFromString( + "1992-08-12", + format: .isoShortDate, + timeZone: TimeZone(secondsFromGMT: 0) + ) + #expect(utcCalendar.isDate(formattedResult, equalTo: expectedDate, toGranularity: .second)) } @Test - func daysBetween() throws { + func daysBetween_fromString_toDate() throws { let firstDateString = "2023-01-12T00:00:00" let secondDateComponents = DateComponents(year: 2023, month: 10, day: 14) let secondDate = try #require(Calendar.current.date(from: secondDateComponents)) let daysBetween = DateFormatterService.days(from: firstDateString, to: secondDate) #expect(daysBetween == 275) } + + @Test + func daysBetween_sameDay_returnsZero() throws { + let calendar = Calendar(identifier: .iso8601) + let date = try #require(calendar.date(from: .init(year: 2024, month: 6, day: 10))) + let result = DateFormatterService.days(from: date, to: date) + #expect(result == 0) + } + + @Test + func daysBetween_nextDay_returnsOneDayDifference() throws { + let calendar = Calendar(identifier: .iso8601) + let date1 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 1))) + let date2 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 2))) + let result = DateFormatterService.days(from: date1, to: date2) + #expect(result == 1) + } + + @Test + func daysBetween_previousDay_returnsNegativeOneDayDifference() throws { + let calendar = Calendar(identifier: .iso8601) + let date1 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 2))) + let date2 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 1))) + let result = DateFormatterService.days(from: date1, to: date2) + #expect(result == -1) + } + + @Test + func daysBetween_crossMonth() throws { + let calendar = Calendar(identifier: .iso8601) + let date1 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 31))) + let date2 = try #require(calendar.date(from: .init(year: 2024, month: 2, day: 1))) + let result = DateFormatterService.days(from: date1, to: date2) + #expect(result == 1) + } + + @Test + func daysBetween_datesWithTime_ignoresTimeComponent() throws { + let calendar = Calendar(identifier: .iso8601) + let date1 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 1, hour: 23, minute: 59))) + let date2 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 2, hour: 0, minute: 0))) + let result = DateFormatterService.days(from: date1, to: date2) + #expect(result == 1) + } + + @Test + func daysBetween_multipleDays() throws { + let calendar = Calendar(identifier: .iso8601) + let date1 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 1))) + let date2 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 6))) + let result = DateFormatterService.days(from: date1, to: date2) + #expect(result == 5) + } + + @Test + func daysBetween_reverseOrder_returnsNegativeDifference() throws { + let calendar = Calendar(identifier: .iso8601) + let date1 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 10))) + let date2 = try #require(calendar.date(from: .init(year: 2024, month: 1, day: 5))) + let result = DateFormatterService.days(from: date1, to: date2) + #expect(result == -5) + } + + @Test + func daysBetween_leapYearTransition() throws { + let calendar = Calendar(identifier: .iso8601) + let date1 = try #require(calendar.date(from: .init(year: 2024, month: 2, day: 28))) + let date2 = try #require(calendar.date(from: .init(year: 2024, month: 3, day: 1))) + let result = DateFormatterService.days(from: date1, to: date2) + #expect(result == 2) + } } diff --git a/SwiftUI-WorkoutApp/Resources/Info.plist b/SwiftUI-WorkoutApp/Resources/Info.plist index bc6dfe03..9424126a 100644 --- a/SwiftUI-WorkoutApp/Resources/Info.plist +++ b/SwiftUI-WorkoutApp/Resources/Info.plist @@ -9,5 +9,7 @@ maps mailto + NSPhotoLibraryUsageDescription + Для выбора фото профиля требуется доступ к галерее diff --git a/SwiftUI-WorkoutApp/Resources/InfoPlist.xcstrings b/SwiftUI-WorkoutApp/Resources/InfoPlist.xcstrings new file mode 100644 index 00000000..ab8233da --- /dev/null +++ b/SwiftUI-WorkoutApp/Resources/InfoPlist.xcstrings @@ -0,0 +1,73 @@ +{ + "sourceLanguage" : "ru", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SW Parks" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "SW Площадки" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "WorkoutApp" + } + } + }, + "shouldTranslate" : false + }, + "NSLocationWhenInUseUsageDescription" : { + "comment" : "Privacy - Location When In Use Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your location is used to find sports parks nearby" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "Для отображения спортивных площадок поблизости" + } + } + } + }, + "NSPhotoLibraryUsageDescription" : { + "comment" : "Privacy - Photo Library Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Needed to choose your profile picture" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "Для выбора фото профиля требуется доступ к галерее" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings b/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings index 1a6e4d86..cf973d5d 100644 --- a/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings +++ b/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings @@ -316,23 +316,6 @@ } } }, - "Oleg991 на boosty" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oleg991 (boosty)" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oleg991 на boosty" - } - } - } - }, "parksCount" : { "extractionState" : "manual", "localizations" : { @@ -601,6 +584,7 @@ } }, "photoSectionHeader" : { + "extractionState" : "stale", "localizations" : { "en" : { "variations" : { @@ -688,24 +672,8 @@ } } }, - "Street Workout на boosty" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Street Workout (boosty)" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Street Workout на boosty" - } - } - } - }, "usersCount" : { + "extractionState" : "stale", "localizations" : { "en" : { "variations" : { @@ -1180,6 +1148,18 @@ } } }, + "Готово" : { + "comment" : "Заголовок для алертов", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, "Дата и время" : { "localizations" : { "en" : { @@ -1230,6 +1210,18 @@ } } }, + "Для корректной работы приложения нужен повторный вход" : { + "comment" : "Ошибка 401", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Re-login required for the app to function properly" + } + } + } + }, "Для отображения твоего местоположения необходимо разрешить доступ к геолокации в настройках" : { "extractionState" : "manual", "localizations" : { @@ -1792,6 +1784,16 @@ } } }, + "Изменить фотографию" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change profile photo" + } + } + } + }, "Имя" : { "extractionState" : "manual", "localizations" : { @@ -2800,6 +2802,18 @@ } } }, + "Ошибка" : { + "comment" : "Заголовок для алертов", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + } + } + }, "Пароли должны совпадать" : { "extractionState" : "manual", "localizations" : { @@ -3155,6 +3169,28 @@ } } }, + "Пользователь добавлен в черный список" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The user was added to your black list" + } + } + } + }, + "Пользователь удален из черного списка" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The user was removed from your black list" + } + } + } + }, "Пользовательское соглашение" : { "extractionState" : "manual", "localizations" : { diff --git a/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift index 632fa23a..a763cf27 100644 --- a/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift +++ b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift @@ -90,14 +90,14 @@ struct PickedImagesGrid: View { private extension PickedImagesGrid { var header: String { String.localizedStringWithFormat( - NSLocalizedString("photoSectionHeader", comment: ""), + "photoSectionHeader".localized, images.count ) } var subtitle: String { guard selectionLimit > 0 else { - return NSLocalizedString("Добавлено максимальное количество фотографий", comment: "") + return "Добавлено максимальное количество фотографий".localized } return images.isEmpty ? String(format: NSLocalizedString("Добавьте фото, максимум %lld", comment: ""), selectionLimit) diff --git a/SwiftUI-WorkoutApp/Screens/Common/SendMessageScreen.swift b/SwiftUI-WorkoutApp/Screens/Common/SendMessageScreen.swift index d5360d30..d0c62bbc 100644 --- a/SwiftUI-WorkoutApp/Screens/Common/SendMessageScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Common/SendMessageScreen.swift @@ -6,15 +6,12 @@ struct SendMessageScreen: View { @Environment(\.isNetworkConnected) private var isNetworkConnected @Environment(\.dismiss) private var dismiss @Binding var text: String - @Binding var showErrorAlert: Bool - @Binding var errorTitle: String @FocusState private var isFocused private let header: LocalizedStringKey private let placeholder: String? private let isLoading: Bool private let isSendButtonDisabled: Bool private let sendAction: () -> Void - private let dismissError: () -> Void init( header: LocalizedStringKey, @@ -22,10 +19,7 @@ struct SendMessageScreen: View { text: Binding, isLoading: Bool, isSendButtonDisabled: Bool, - sendAction: @escaping () -> Void, - showErrorAlert: Binding, - errorTitle: Binding, - dismissError: @escaping () -> Void + sendAction: @escaping () -> Void ) { self.header = header self.placeholder = placeholder @@ -33,9 +27,6 @@ struct SendMessageScreen: View { self.isLoading = isLoading self.isSendButtonDisabled = isSendButtonDisabled self.sendAction = sendAction - self._showErrorAlert = showErrorAlert - self._errorTitle = errorTitle - self.dismissError = dismissError } var body: some View { @@ -50,9 +41,6 @@ struct SendMessageScreen: View { .background(Color.swBackground) .loadingOverlay(if: isLoading) .interactiveDismissDisabled(isLoading) - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("Ok", action: dismissError) - } } } @@ -93,10 +81,7 @@ private extension SendMessageScreen { text: .constant("Текст комментария"), isLoading: false, isSendButtonDisabled: true, - sendAction: {}, - showErrorAlert: .constant(false), - errorTitle: .constant(""), - dismissError: {} + sendAction: {} ) } #endif diff --git a/SwiftUI-WorkoutApp/Screens/Common/TextEntryScreen.swift b/SwiftUI-WorkoutApp/Screens/Common/TextEntryScreen.swift index 3b6f845e..5a757b1b 100644 --- a/SwiftUI-WorkoutApp/Screens/Common/TextEntryScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Common/TextEntryScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SwiftUI import SWNetworkClient @@ -6,11 +7,9 @@ struct TextEntryScreen: View { @EnvironmentObject private var defaults: DefaultsService @State private var isLoading = false @State private var entryText = "" - @State private var showErrorAlert = false - @State private var errorTitle = "" @State private var saveEntryTask: Task? private let mode: Mode - private var oldEntryText: String? + private let oldEntryText: String? private let refreshClbk: () -> Void init(mode: Mode, refreshClbk: @escaping () -> Void) { @@ -21,7 +20,8 @@ struct TextEntryScreen: View { let .editEvent(info), let .editJournalEntry(_, info): self.oldEntryText = info.oldEntry - default: break + default: + self.oldEntryText = nil } } @@ -32,14 +32,11 @@ struct TextEntryScreen: View { text: $entryText, isLoading: isLoading, isSendButtonDisabled: !canSend, - sendAction: sendAction, - showErrorAlert: $showErrorAlert, - errorTitle: $errorTitle, - dismissError: { setupErrorAlert("") } + sendAction: sendAction ) .onAppear { - if let oldEntry = oldEntryText { - entryText = oldEntry + if let oldEntryText { + entryText = oldEntryText } } .onDisappear { saveEntryTask?.cancel() } @@ -127,17 +124,12 @@ private extension TextEntryScreen { } if isSuccess { refreshClbk() } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } } - func setupErrorAlert(_ message: String) { - errorTitle = message - showErrorAlert = !message.isEmpty - } - var canSend: Bool { switch mode { case .newForPark, .newForEvent, .newForJournal: diff --git a/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift b/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift index 988186c8..8d0e081c 100644 --- a/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -11,10 +12,9 @@ struct UsersListScreen: View { @State private var friendRequests = [UserResponse]() @State private var isLoading = false @State private var messagingModel = MessagingModel() - @State private var showErrorAlert = false - @State private var errorTitle = "" @State private var sendMessageTask: Task? @State private var friendRequestTask: Task? + private var client: SWClient { SWClient(with: defaults) } let mode: Mode var body: some View { @@ -44,9 +44,6 @@ struct UsersListScreen: View { .loadingOverlay(if: isLoading) .background(Color.swBackground) .disabled(!isNetworkConnected) - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("Ok") { closeAlert() } - } .task { await askForUsers() } .refreshable { await askForUsers(refresh: true) } .onDisappear { sendMessageTask?.cancel() } @@ -138,10 +135,7 @@ private extension UsersListScreen { text: $messagingModel.message, isLoading: messagingModel.isLoading, isSendButtonDisabled: !messagingModel.canSendMessage, - sendAction: { sendMessage(to: recipient.id) }, - showErrorAlert: $showErrorAlert, - errorTitle: $errorTitle, - dismissError: closeAlert + sendAction: { sendMessage(to: recipient.id) } ) } @@ -149,10 +143,10 @@ private extension UsersListScreen { messagingModel.isLoading = true sendMessageTask = Task { do { - let isSuccess = try await SWClient(with: defaults).sendMessage(messagingModel.message, to: userID) + let isSuccess = try await client.sendMessage(messagingModel.message, to: userID) endMessaging(isSuccess: isSuccess) } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } messagingModel.isLoading = false } @@ -166,54 +160,56 @@ private extension UsersListScreen { } func askForUsers(refresh: Bool = false) async { + guard !isLoading else { return } do { - if !users.isEmpty || isLoading, !refresh { return } switch mode { case let .friends(userID), let .friendsForChat(userID): + if !users.isEmpty, !refresh { return } if !refresh { isLoading = true } - if userID == defaults.mainUserInfo?.id { + let isMainUser = userID == defaults.mainUserInfo?.id + let response = try await client.getFriendsForUser(id: userID) + if isMainUser { + try? defaults.saveFriendsIds(response.map(\.id)) if defaults.friendRequestsList.isEmpty || refresh { - try? await SWClient(with: defaults).getFriendRequests() + friendRequests = try await client.getFriendRequests() + try? defaults.saveFriendRequests(friendRequests) + } else { + friendRequests = defaults.friendRequestsList } - friendRequests = defaults.friendRequestsList } - users = try await SWClient(with: defaults).getFriendsForUser(id: userID) + users = response case let .eventParticipants(list), let .parkParticipants(list): users = list case .blacklist: + if !users.isEmpty, !refresh { return } if !refresh { isLoading = true } - if defaults.blacklistedUsers.isEmpty { - try await SWClient(with: defaults).getBlacklist() + if defaults.blacklistedUsers.isEmpty || refresh { + users = try await client.getBlacklist() + try? defaults.saveBlacklist(users) + } else { + users = defaults.blacklistedUsers } - users = defaults.blacklistedUsers } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } - if !refresh { isLoading = false } + isLoading = false } func respondToFriendRequest(from userID: Int, accept: Bool) { isLoading = true friendRequestTask = Task { do { - if try await SWClient(with: defaults).respondToFriendRequest(from: userID, accept: accept) { - friendRequests = defaults.friendRequestsList - defaults.setUserNeedUpdate(true) + let isSuccess = try await client.respondToFriendRequest(from: userID, accept: accept) + if isSuccess { + await askForUsers(refresh: true) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } } - - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - errorTitle = message - } - - func closeAlert() { errorTitle = "" } } #if DEBUG diff --git a/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift b/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift index 2cd894ed..e8bb4d2a 100644 --- a/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -11,8 +12,6 @@ struct EventDetailsScreen: View { @State private var navigationDestination: NavigationDestination? @State private var sheetItem: SheetItem? @State private var isLoading = false - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var showDeleteDialog = false @State private var goingToEventTask: Task? @State private var deleteCommentTask: Task? @@ -57,15 +56,12 @@ struct EventDetailsScreen: View { .sheet(item: $sheetItem, content: makeSheetContent) .task { await askForInfo() } .refreshable { await askForInfo(refresh: true) } - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .onDisappear(perform: cancelTasks) .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButton(mode: .text) { dismiss() } } - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { if isEventAuthor { toolbarMenuButton } @@ -121,7 +117,7 @@ private extension EventDetailsScreen { onDeletion(event.id) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -232,7 +228,7 @@ private extension EventDetailsScreen { event.trainHere = oldValue } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) event.trainHere = oldValue } isLoading = false @@ -347,7 +343,7 @@ private extension EventDetailsScreen { do { event = try await SWClient(with: defaults).getEvent(by: event.id) } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -360,7 +356,7 @@ private extension EventDetailsScreen { event.comments.removeAll(where: { $0.id == id }) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -376,17 +372,12 @@ private extension EventDetailsScreen { event.photos = event.removePhotoById(id) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - alertMessage = message - } - func reportPhoto() { let complaint = Complaint.eventPhoto(eventTitle: event.formattedTitle) FeedbackSender.sendFeedback( diff --git a/SwiftUI-WorkoutApp/Screens/Events/EventFormScreen.swift b/SwiftUI-WorkoutApp/Screens/Events/EventFormScreen.swift index bc31f0cb..55f07393 100644 --- a/SwiftUI-WorkoutApp/Screens/Events/EventFormScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Events/EventFormScreen.swift @@ -1,4 +1,5 @@ import ImagePicker +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -12,8 +13,6 @@ struct EventFormScreen: View { @State private var eventForm: EventForm @State private var newImages = [UIImage]() @State private var isLoading = false - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var showImagePicker = false @State private var saveEventTask: Task? @FocusState private var focus: FocusableField? @@ -52,9 +51,6 @@ struct EventFormScreen: View { } .loadingOverlay(if: isLoading) .background(Color.swBackground) - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .onDisappear { saveEventTask?.cancel() } .navigationTitle(mode.title) .navigationBarTitleDisplayMode(.inline) @@ -193,7 +189,7 @@ private extension EventFormScreen { dismiss() } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -210,11 +206,6 @@ private extension EventFormScreen { } } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - alertMessage = message - } - /// Не показываем пикер площадок, если `userID` для основного пользователя отсутствует var canShowParkPicker: Bool { guard isNetworkConnected, let userInfo = defaults.mainUserInfo else { return false } diff --git a/SwiftUI-WorkoutApp/Screens/Events/EventsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Events/EventsListScreen.swift index b501d1a1..7e8464ee 100644 --- a/SwiftUI-WorkoutApp/Screens/Events/EventsListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Events/EventsListScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -17,8 +18,6 @@ struct EventsListScreen: View { @State private var selectedEvent: EventResponse? @State private var showEventCreationSheet = false @State private var showEventCreationRule = false - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var eventsTask: Task? private let pastEventStorage = PastEventStorage() @@ -37,9 +36,6 @@ struct EventsListScreen: View { } message: { Text(.init(Constants.Alert.eventCreationRule)) } - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .onChange(of: selectedEventType) { _ in eventsTask = Task { await askForEvents() } } @@ -48,10 +44,10 @@ struct EventsListScreen: View { } .refreshable { await askForEvents(refresh: true) } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { refreshButton } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { rightBarButton } } @@ -192,7 +188,7 @@ private extension EventsListScreen { if selectedEventType == .past { pastEventStorage.loadIfNeeded(&pastEvents) } - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -202,11 +198,6 @@ private extension EventsListScreen { futureEvents.removeAll(where: { $0.id == id }) pastEvents.removeAll(where: { $0.id == id }) } - - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - alertMessage = message - } } #if DEBUG diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift index 174187f6..13f60e43 100644 --- a/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -13,8 +14,6 @@ struct DialogScreen: View { /// `NavigationLink` не работает сам по себе внутри тулбара, /// т.к. тулбар не находится в иерархии `NavigationView` @State private var openAnotherUserProfile = false - @State private var showErrorAlert = false - @State private var errorTitle = "" @State private var sendMessageTask: Task? @State private var refreshDialogTask: Task? @Namespace private var chatScrollView @@ -40,19 +39,16 @@ struct DialogScreen: View { } } .background(Color.swBackground) - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("Ok") { errorTitle = "" } - } .task(priority: .low) { await markAsRead() } .task(priority: .high) { await askForMessages() } .onDisappear { [refreshDialogTask, sendMessageTask].forEach { $0?.cancel() } } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { refreshButton } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { anotherUserProfileButton } } @@ -165,7 +161,7 @@ private extension DialogScreen { markedAsReadClbk(dialog) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } } @@ -175,7 +171,7 @@ private extension DialogScreen { do { messages = try await SWClient(with: defaults).getMessages(for: dialog.id).reversed() } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -190,16 +186,11 @@ private extension DialogScreen { await askForMessages(refresh: true) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } } - - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - errorTitle = message - } } #if DEBUG diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift index b19c6174..4f4240a5 100644 --- a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -10,8 +11,6 @@ struct DialogsListScreen: View { @State private var dialogs = [DialogResponse]() @State private var selectedDialog: DialogResponse? @State private var isLoading = false - @State private var showErrorAlert = false - @State private var errorTitle = "" @State private var indexToDelete: Int? @State private var openFriendList = false @State private var showDeleteConfirmation = false @@ -28,16 +27,13 @@ struct DialogsListScreen: View { isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { deleteDialogButton } - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("Ok") { errorTitle = "" } - } .task { await askForDialogs() } .refreshable { await askForDialogs(refresh: true) } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { refreshButton } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { friendListButton } } @@ -176,7 +172,7 @@ private extension DialogsListScreen { let unreadMessagesCount = dialogs.map(\.unreadMessagesCount).reduce(0, +) defaults.saveUnreadMessagesCount(unreadMessagesCount) } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -196,16 +192,11 @@ private extension DialogsListScreen { dialogs.remove(at: index) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } } - - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - errorTitle = message - } } #if DEBUG diff --git a/SwiftUI-WorkoutApp/Screens/Parks/Map/ParksMapScreen.swift b/SwiftUI-WorkoutApp/Screens/Parks/Map/ParksMapScreen.swift index bfd4365a..c9297377 100644 --- a/SwiftUI-WorkoutApp/Screens/Parks/Map/ParksMapScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Parks/Map/ParksMapScreen.swift @@ -1,5 +1,6 @@ import FileManager991 import MapView991 +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -14,8 +15,6 @@ struct ParksMapScreen: View { @StateObject private var viewModel = ViewModel() @State private var presentation = Presentation.map @State private var isLoading = false - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var sheetItem: SheetItem? @State private var filter = ParkFilterScreen.Model() /// Город для фильтра списка площадок @@ -48,13 +47,10 @@ struct ParksMapScreen: View { .onChange(of: defaults.mainUserCityID) { _ in viewModel.updateUserCountryAndCity(with: defaults.mainUserInfo) } - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .task { await askForParks() } .sheet(item: $sheetItem) { makeContentView(for: $0) } .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { + ToolbarItemGroup(placement: .topBarLeading) { Group { filterButton Button { @@ -65,7 +61,7 @@ struct ParksMapScreen: View { } .disabled(isLoading) } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { rightBarButton } } @@ -196,7 +192,7 @@ private extension ParksMapScreen { do { try parksManager.makeDefaultList() } catch { - setupErrorAlert(error.localizedDescription) + SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } // Если прошло больше одного дня с момента предыдущего обновления, делаем обновление if parksManager.needUpdateDefaultList { @@ -212,7 +208,7 @@ private extension ParksMapScreen { do { try parksManager.deletePark(with: id) } catch { - setupErrorAlert(error.localizedDescription) + SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } } @@ -230,7 +226,7 @@ private extension ParksMapScreen { let updatedParks = try await SWClient(with: defaults).getUpdatedParks(from: dateString) try parksManager.updateDefaultList(with: updatedParks) } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -291,11 +287,6 @@ private extension ParksMapScreen { } } } - - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - alertMessage = message - } } #if DEBUG diff --git a/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift b/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift index f338fa27..3b0b9006 100644 --- a/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift @@ -1,4 +1,5 @@ import OSLog +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -13,8 +14,6 @@ struct ParkDetailScreen: View { @State private var navigationDestination: NavigationDestination? @State private var sheetItem: SheetItem? @State private var isLoading = false - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var dialogs = ConfirmationDialogs() @State private var changeTrainHereTask: Task? @State private var deleteCommentTask: Task? @@ -56,9 +55,6 @@ struct ParkDetailScreen: View { .sheet(item: $sheetItem, content: makeSheetContent) .task { await askForInfo() } .refreshable { await askForInfo(refresh: true) } - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .onChange(of: defaults.isAuthorized) { isAuth in if !isAuth { dismiss() } } @@ -67,7 +63,7 @@ struct ParkDetailScreen: View { ToolbarItem(placement: .topBarLeading) { CloseButton(mode: .text) { dismiss() } } - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { if isParkAuthor { toolbarMenuButton } else { @@ -390,11 +386,6 @@ private extension ParkDetailScreen { } } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - alertMessage = message - } - func reportPhoto() { let complaint = Complaint.parkPhoto(parkTitle: park.shortTitle) FeedbackSender.sendFeedback( @@ -448,7 +439,7 @@ private extension ParkDetailScreen { ) onDeletion(park.id) } else { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } } } diff --git a/SwiftUI-WorkoutApp/Screens/Parks/ParkFormScreen.swift b/SwiftUI-WorkoutApp/Screens/Parks/ParkFormScreen.swift index ff358952..bdfc30fe 100644 --- a/SwiftUI-WorkoutApp/Screens/Parks/ParkFormScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Parks/ParkFormScreen.swift @@ -1,5 +1,6 @@ import CoreLocation.CLLocation import ImagePicker +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -13,8 +14,6 @@ struct ParkFormScreen: View { @State private var isLoading = false @State private var parkForm: ParkForm @State private var newImages = [UIImage]() - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var showImagePicker = false @State private var saveParkTask: Task? @FocusState private var isFocused: Bool @@ -55,9 +54,6 @@ struct ParkFormScreen: View { } .loadingOverlay(if: isLoading) .background(Color.swBackground) - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .onDisappear { saveParkTask?.cancel() } .navigationTitle("Площадка") .navigationBarTitleDisplayMode(.inline) @@ -144,11 +140,6 @@ private extension ParkFormScreen { } } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - alertMessage = message - } - var saveButton: some View { Button("Сохранить") { isFocused = false @@ -162,7 +153,7 @@ private extension ParkFormScreen { refreshClbk() } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } diff --git a/SwiftUI-WorkoutApp/Screens/Parks/ParksListScreen.swift b/SwiftUI-WorkoutApp/Screens/Parks/ParksListScreen.swift index 5b3544fd..1945077a 100644 --- a/SwiftUI-WorkoutApp/Screens/Parks/ParksListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Parks/ParksListScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -10,8 +11,6 @@ struct ParksListScreen: View { @EnvironmentObject private var parksManager: ParksManager @State private var parks = [Park]() @State private var isLoading = false - @State private var showErrorAlert = false - @State private var errorTitle = "" /// Площадка для открытия детального экрана @State private var selectedPark: Park? @State private var updateParksTask: Task? @@ -55,16 +54,13 @@ struct ParksListScreen: View { dismiss() } } - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("Ok") { errorTitle = "" } - } .task { await askForParks() } .refreshable { guard mode.canRefreshList else { return } await askForParks(refresh: true) } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { refreshButtonIfNeeded } } @@ -132,7 +128,7 @@ private extension ParksListScreen { parks = list } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -152,14 +148,9 @@ private extension ParksListScreen { dismiss() } } catch { - setupErrorAlert(error.localizedDescription) + SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } } - - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - errorTitle = message - } } #if DEBUG diff --git a/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/AvatarPickerView.swift b/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/AvatarPickerView.swift new file mode 100644 index 00000000..822fc741 --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/AvatarPickerView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct AvatarPickerView: UIViewControllerRepresentable { + let completion: (UIImage) -> Void + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.allowsEditing = true + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_: UIImagePickerController, context _: Context) {} + + func makeCoordinator() -> Coordinator { .init(self) } + + final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: AvatarPickerView + + init(_ parent: AvatarPickerView) { + self.parent = parent + } + + func imagePickerController( + _: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + if let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage { + parent.completion(image) + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_: UIImagePickerController) { + parent.dismiss() + } + } +} diff --git a/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/ChangePasswordScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/ChangePasswordScreen.swift index 13baa0e3..0311aa69 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/ChangePasswordScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/ChangePasswordScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -10,8 +11,6 @@ struct ChangePasswordScreen: View { @EnvironmentObject private var defaults: DefaultsService @State private var model = PassworModel() @State private var isLoading = false - @State private var showErrorAlert = false - @State private var errorMessage = "" @State private var isChangeSuccessful = false @State private var changePasswordTask: Task? @FocusState private var focus: FocusableField? @@ -33,11 +32,7 @@ struct ChangePasswordScreen: View { .padding() .loadingOverlay(if: isLoading) .background(Color.swBackground) - .alert(errorMessage, isPresented: $showErrorAlert) { - Button("Ok") { errorMessage = "" } - } .onChange(of: isChangeSuccessful, perform: performLogout) - .onChange(of: errorMessage, perform: setupErrorAlert) .onDisappear(perform: cancelTask) .navigationTitle("Изменить пароль") .navigationBarTitleDisplayMode(.inline) @@ -83,9 +78,7 @@ private extension ChangePasswordScreen { } var canChangePassword: Bool { - model.isReady - && errorMessage.isEmpty - && isNetworkConnected + model.isReady && isNetworkConnected } var passwordField: some View { @@ -147,7 +140,10 @@ private extension ChangePasswordScreen { isChangeSuccessful = try await SWClient(with: defaults) .changePassword(current: model.current, new: model.new.text) } catch { - errorMessage = ErrorFilter.message(from: error) + SWAlert.shared.presentDefaultUIKit( + title: "Ошибка".localized, + message: ErrorFilter.message(from: error) + ) } isLoading.toggle() } @@ -159,10 +155,6 @@ private extension ChangePasswordScreen { } } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - } - func cancelTask() { changePasswordTask?.cancel() } @@ -171,5 +163,6 @@ private extension ChangePasswordScreen { #if DEBUG #Preview { ChangePasswordScreen() + .environmentObject(DefaultsService()) } #endif diff --git a/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/EditProfileScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/EditProfileScreen.swift index 51fa5842..87a8e691 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/EditProfileScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/EditProfile/EditProfileScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -14,35 +15,33 @@ struct EditProfileScreen: View { /// Все доступные страны и города @State private var locations = Locations(countries: []) @State private var isLoading = false - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var editUserTask: Task? + @State private var newAvatarImageModel: AvatarModel? + @State private var showImagePicker = false @FocusState private var focus: FocusableField? var body: some View { - VStack(spacing: 0) { + VStack(spacing: 12) { ScrollView { - Group { - loginField.padding(.top) + VStack(spacing: 12) { + avatarPicker + loginField emailField nameField changePasswordButton + VStack(spacing: 4) { + genderPicker + birthdayPicker + countryPicker + cityPicker + } } - .padding(.bottom, 12) - genderPicker - birthdayPicker - countryPicker - cityPicker + .padding() } - Spacer() saveChangesButton } - .padding([.horizontal, .bottom]) .loadingOverlay(if: isLoading) .background(Color.swBackground) - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .onAppear(perform: prepareLocationsAndUserForm) .onDisappear { editUserTask?.cancel() } .navigationTitle("Изменить профиль") @@ -55,6 +54,11 @@ private extension EditProfileScreen { case login, email, fullName } + struct AvatarModel: Equatable { + let id = UUID().uuidString + let uiImage: UIImage + } + var loginField: some View { SWTextField( placeholder: userForm.placeholder(.userName), @@ -88,6 +92,33 @@ private extension EditProfileScreen { } } + var avatarPicker: some View { + VStack(spacing: 20) { + if let model = newAvatarImageModel { + Image(uiImage: model.uiImage) + .resizable() + .scaledToFill() + .frame(width: 150, height: 150) + .clipShape(.rect(cornerRadius: 12)) + .transition(.scale.combined(with: .slide).combined(with: .opacity)) + .id(model.id) + } else { + CachedImage(url: defaults.mainUserInfo?.avatarURL, mode: .profileAvatar) + .transition(.scale.combined(with: .slide).combined(with: .opacity)) + } + Button("Изменить фотографию") { showImagePicker.toggle() } + .buttonStyle(SWButtonStyle(mode: .tinted, size: .large, maxWidth: nil)) + .padding(.bottom, 8) + .sheet(isPresented: $showImagePicker) { + AvatarPickerView { + newAvatarImageModel = .init(uiImage: $0) + userForm.image = $0.toMediaFile() + } + } + } + .animation(.default, value: newAvatarImageModel) + } + var genderPicker: some View { Menu { Picker("", selection: $userForm.genderCode) { @@ -161,6 +192,7 @@ private extension EditProfileScreen { var saveChangesButton: some View { Button("Сохранить", action: saveChangesAction) .buttonStyle(SWButtonStyle(mode: .filled, size: .large)) + .padding([.horizontal, .bottom]) .disabled( !userForm.isReadyToSave(comparedTo: oldUserForm) || !isNetworkConnected @@ -180,7 +212,7 @@ private extension EditProfileScreen { userForm = oldUserForm } } catch { - setupErrorAlert(error.localizedDescription) + SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } } @@ -205,20 +237,17 @@ private extension EditProfileScreen { editUserTask = Task { do { let userID = defaults.mainUserInfo?.id ?? 0 - if try await SWClient(with: defaults).editUser(userID, model: userForm) { - dismiss() - } + let result = try await SWClient(with: defaults).editUser(userID, model: userForm) + try defaults.saveUserInfo(result) + let currentPassword = try defaults.basicAuthInfo().password + try defaults.saveAuthData(login: userForm.userName, password: currentPassword) + dismiss() } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + isLoading = false + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } - isLoading = false } } - - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - alertMessage = message - } } private extension EditProfileScreen { diff --git a/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalEntriesScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalEntriesScreen.swift index f1df9bc1..ab4dcb33 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalEntriesScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalEntriesScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -9,8 +10,6 @@ struct JournalEntriesScreen: View { @EnvironmentObject private var defaults: DefaultsService @State private var entries = [JournalEntryResponse]() @State private var isLoading = false - @State private var showErrorAlert = false - @State private var errorTitle = "" @State private var showCreateEntrySheet = false @State private var entryIdToDelete: Int? @State private var showDeleteDialog = false @@ -68,16 +67,13 @@ struct JournalEntriesScreen: View { isPresented: $showDeleteDialog, titleVisibility: .visible ) { deleteEntryButton } - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("Ok") { errorTitle = "" } - } .task { await askForEntries() } .refreshable { await askForEntries(refresh: true) } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { refreshButtonIfNeeded } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { addEntryButtonIfNeeded } } @@ -156,7 +152,7 @@ private extension JournalEntriesScreen { entries = try await SWClient(with: defaults) .getJournalEntries(for: userID, journalID: currentJournal.id) } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -174,7 +170,7 @@ private extension JournalEntriesScreen { entries.removeAll(where: { $0.id == entryID }) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -183,11 +179,6 @@ private extension JournalEntriesScreen { } } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - errorTitle = message - } - func cancelTasks() { [deleteEntryTask, updateEntriesTask].forEach { $0?.cancel() } } diff --git a/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalSettingsScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalSettingsScreen.swift index ce4700b6..d19fd59c 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalSettingsScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalSettingsScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -10,8 +11,6 @@ struct JournalSettingsScreen: View { @Environment(\.isNetworkConnected) private var isNetworkConnected @State private var journal: JournalResponse @State private var isLoading = false - @State private var showErrorAlert = false - @State private var alertMessage = "" @State private var saveJournalChangesTask: Task? @FocusState private var isTextFieldFocused: Bool private let options = JournalAccess.allCases @@ -40,9 +39,6 @@ struct JournalSettingsScreen: View { } .loadingOverlay(if: isLoading) .interactiveDismissDisabled(isLoading) - .alert(alertMessage, isPresented: $showErrorAlert) { - Button("Ok") { alertMessage = "" } - } .onDisappear { saveJournalChangesTask?.cancel() } } } @@ -93,18 +89,18 @@ private extension JournalSettingsScreen { saveJournalChangesTask = Task { isLoading = true do { - if try await SWClient(with: defaults).editJournalSettings( - for: journal.id, + let isSuccess = try await SWClient(with: defaults).editJournalSettings( + with: journal.id, title: journal.title, + for: defaults.mainUserInfo?.id, viewAccess: journal.viewAccessType, commentAccess: journal.commentAccessType - ) { + ) + if isSuccess { updateOnSuccess(journal) } } catch { - let message = ErrorFilter.message(from: error) - showErrorAlert = !message.isEmpty - alertMessage = message + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading.toggle() } diff --git a/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalsListScreen.swift index c40170fa..5ec382fe 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalsListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/Journals/JournalsListScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -11,8 +12,6 @@ struct JournalsListScreen: View { @State private var newJournalTitle = "" @State private var isLoading = false @State private var isCreatingJournal = false - @State private var showErrorAlert = false - @State private var errorTitle = "" @State private var journalIdToDelete: Int? @State private var journalToEdit: JournalResponse? @State private var showDeleteDialog = false @@ -32,16 +31,13 @@ struct JournalsListScreen: View { titleVisibility: .visible ) { deleteJournalButton } .sheet(isPresented: $isCreatingJournal) { newJournalSheet } - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("Ok") { closeAlert() } - } .task { await askForJournals() } .refreshable { await askForJournals(refresh: true) } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { refreshButton } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { addJournalButton } } @@ -63,7 +59,8 @@ private extension JournalsListScreen { } var refreshButtonOpacity: CGFloat { - showEmptyView || !DeviceOSVersionChecker.iOS16Available ? 1 : 0 + guard !DeviceOSVersionChecker.iOS16Available else { return 0 } + return showEmptyView ? 1 : 0 } var addJournalButton: some View { @@ -127,10 +124,7 @@ private extension JournalsListScreen { text: $newJournalTitle, isLoading: isLoading, isSendButtonDisabled: !canSaveNewJournal, - sendAction: saveNewJournal, - showErrorAlert: $showErrorAlert, - errorTitle: $errorTitle, - dismissError: closeAlert + sendAction: saveNewJournal ) } @@ -139,21 +133,26 @@ private extension JournalsListScreen { var deleteJournalButton: some View { Button(role: .destructive) { deleteJournalTask = Task { - guard let journalID = journalIdToDelete, !isLoading else { return } + guard let journalID = journalIdToDelete else { return } isLoading = true do { - if try await SWClient(with: defaults).deleteJournal(journalID: journalID) { + let isJournalDeleted = try await SWClient(with: defaults).deleteJournal( + with: journalID, + for: defaults.mainUserInfo?.id + ) + if isJournalDeleted { journals.removeAll(where: { $0.id == journalID }) defaults.setUserNeedUpdate(true) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } } label: { Text("Удалить") } + .disabled(isLoading) } func showNewJournalSheet() { @@ -174,7 +173,7 @@ private extension JournalsListScreen { do { journals = try await SWClient(with: defaults).getJournals(for: userID) } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -183,14 +182,18 @@ private extension JournalsListScreen { isLoading = true saveJournalTask = Task { do { - if try await SWClient(with: defaults).createJournal(with: newJournalTitle) { + let isJournalCreated = try await SWClient(with: defaults).createJournal( + with: newJournalTitle, + for: defaults.mainUserInfo?.id + ) + if isJournalCreated { newJournalTitle = "" isCreatingJournal.toggle() defaults.setUserNeedUpdate(true) await askForJournals(refresh: true) } } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -208,13 +211,6 @@ private extension JournalsListScreen { showDeleteDialog.toggle() } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - errorTitle = message - } - - func closeAlert() { errorTitle = "" } - func cancelTasks() { [saveJournalTask, deleteJournalTask, updateListTask].forEach { $0?.cancel() } } diff --git a/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift new file mode 100644 index 00000000..e869e3b4 --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift @@ -0,0 +1,157 @@ +import SWAlert +import SWDesignSystem +import SwiftUI +import SWModels +import SWNetworkClient + +/// Экран с профилем главного пользователя +struct MainUserProfileScreen: View { + @Environment(\.isNetworkConnected) private var isNetworkConnected + @EnvironmentObject private var defaults: DefaultsService + @State private var isLoading = false + @State private var showLogoutDialog = false + @State private var showSearchUsersScreen = false + private var client: SWClient { SWClient(with: defaults) } + + var body: some View { + ScrollView { + if let user = defaults.mainUserInfo { + makeProfileContent(for: user) + } + } + .frame(maxWidth: .infinity) + .loadingOverlay(if: isLoading) + .background(Color.swBackground) + .refreshable { await askForUserInfo(refresh: true) } + .task { await askForUserInfo() } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + refreshButtonIfNeeded + } + ToolbarItem(placement: .topBarTrailing) { + searchUsersButton + .disabled(isLoading) + } + } + } +} + +private extension MainUserProfileScreen { + @ViewBuilder + func makeProfileContent(for user: UserResponse) -> some View { + VStack(spacing: 0) { + ProfileViews.makeUserInfo(for: user) + .id(defaults.mainUserInfo?.avatarURL) + editProfileButton + VStack(spacing: 12) { + ProfileViews.makeFriends( + for: user, + friendRequestsCount: defaults.friendRequestsList.count + ) + ProfileViews.makeUsedParks(for: user) + ProfileViews.makeAddedParks(for: user) + ProfileViews.makeJournals(for: user, isMainUser: true) + blacklistButtonIfNeeded + } + logoutButton + } + .padding(.horizontal) + } + + @ViewBuilder + var refreshButtonIfNeeded: some View { + if !DeviceOSVersionChecker.iOS16Available { + Button { + Task { await askForUserInfo(refresh: true) } + } label: { + Icons.Regular.refresh.view + } + .disabled(isLoading) + } + } + + var searchUsersButton: some View { + Button { + showSearchUsersScreen = true + } label: { + Icons.Regular.magnifyingglass.view + } + .disabled(!isNetworkConnected) + .accessibilityIdentifier("searchUsersButton") + .sheet(isPresented: $showSearchUsersScreen) { + NavigationView { + SearchUsersScreen() + } + } + } + + var editProfileButton: some View { + NavigationLink(destination: EditProfileScreen()) { + Text("Изменить профиль") + } + .buttonStyle(SWButtonStyle(icon: .pencil, mode: .tinted, size: .large)) + .padding(.bottom, 24) + } + + @ViewBuilder + var blacklistButtonIfNeeded: some View { + ZStack { + if !defaults.blacklistedUsers.isEmpty { + NavigationLink(destination: UsersListScreen(mode: .blacklist)) { + FormRowView( + title: "Черный список", + trailingContent: .textWithChevron(defaults.blacklistedUsersCountString) + ) + } + } + } + .animation(.default, value: defaults.blacklistedUsers.isEmpty) + } + + var logoutButton: some View { + Button("Выйти") { showLogoutDialog = true } + .foregroundStyle(Color.swSmallElements) + .padding(.top, 36) + .padding(.bottom, 20) + .confirmationDialog( + .init(Constants.Alert.logout), + isPresented: $showLogoutDialog, + titleVisibility: .visible + ) { + Button("Выйти", role: .destructive) { + defaults.triggerLogout() + } + } + } + + func askForUserInfo(refresh: Bool = false) async { + guard !isLoading else { return } + if !refresh { isLoading = true } + if refresh || defaults.needUpdateUser { + await makeUserInfo() + } + isLoading = false + } + + func makeUserInfo() async { + guard let mainUserId = defaults.mainUserInfo?.id else { return } + do { + // TODO: вынести обновление заявок/черного списка в отдельную логику + async let getUserInfo = client.getUserByID(mainUserId) + async let getFriendRequests = client.getFriendRequests() + async let getBlacklist = client.getBlacklist() + let (userInfo, friendRequests, blacklist) = try await (getUserInfo, getFriendRequests, getBlacklist) + try defaults.saveUserInfo(userInfo) + try defaults.saveFriendRequests(friendRequests) + try defaults.saveBlacklist(blacklist) + } catch { + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) + } + } +} + +#if DEBUG +#Preview { + MainUserProfileScreen() +} +#endif diff --git a/SwiftUI-WorkoutApp/Screens/Profile/ProfileScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/ProfileScreen.swift index 39242558..33e290f1 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/ProfileScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/ProfileScreen.swift @@ -9,7 +9,7 @@ struct ProfileScreen: View { NavigationView { ZStack { if defaults.isAuthorized { - UserDetailsScreen(for: defaults.mainUserInfo) + MainUserProfileScreen() .navigationBarTitleDisplayMode(.inline) .transition(.move(edge: .top).combined(with: .opacity)) } else { diff --git a/SwiftUI-WorkoutApp/Screens/Profile/ProfileViews.swift b/SwiftUI-WorkoutApp/Screens/Profile/ProfileViews.swift new file mode 100644 index 00000000..e2c69b2d --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Profile/ProfileViews.swift @@ -0,0 +1,93 @@ +import SWDesignSystem +import SwiftUI +import SWModels + +enum ProfileViews {} + +extension ProfileViews { + @ViewBuilder @MainActor + static func makeUserInfo(for user: UserResponse) -> some View { + ProfileView( + imageURL: user.avatarURL, + login: user.userName ?? "", + genderWithAge: user.genderWithAge, + countryAndCity: SWAddress(user.countryID, user.cityID)?.address ?? "" + ) + .padding(24) + } + + @ViewBuilder @MainActor + static func makeFriends( + for user: UserResponse, + friendRequestsCount: Int = 0 + ) -> some View { + let showButton = user.hasFriends || friendRequestsCount > 0 + ZStack { + if showButton { + NavigationLink(destination: UsersListScreen(mode: .friends(userID: user.id))) { + FormRowView( + title: "Друзья", + trailingContent: .textWithBadgeAndChevron( + user.friendsCountString, + friendRequestsCount + ) + ) + } + } + } + .animation(.default, value: showButton) + } + + @ViewBuilder @MainActor + static func makeUsedParks(for user: UserResponse) -> some View { + if user.hasUsedParks { + NavigationLink { + ParksListScreen(mode: .usedBy(userID: user.id)) + } label: { + FormRowView( + title: "Где тренируется", + trailingContent: .textWithChevron(user.usesParksCountString) + ) + } + .accessibilityIdentifier("usesParksButton") + } + } + + @ViewBuilder @MainActor + static func makeAddedParks(for user: UserResponse) -> some View { + if user.hasAddedParks { + NavigationLink { + ParksListScreen(mode: .added(list: user.addedParks ?? [])) + } label: { + FormRowView( + title: user.addedParksString, + trailingContent: .textWithChevron(user.addedParksCountString) + ) + } + } + } + + /// Делает вьюху для перехода в дневники + /// - Parameters: + /// - user: Данные пользователя + /// - isMainUser: `true` - основной пользователь, `false` - любой другой + /// - Returns: `NavigationLink` для перехода в дневники + @ViewBuilder @MainActor + static func makeJournals( + for user: UserResponse, + isMainUser: Bool = false + ) -> some View { + if user.hasJournals || isMainUser { + NavigationLink { + JournalsListScreen(userID: user.id) + .navigationTitle("Дневники") + .navigationBarTitleDisplayMode(.inline) + } label: { + FormRowView( + title: "Дневники", + trailingContent: .textWithChevron(user.journalsCountString) + ) + } + } + } +} diff --git a/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift index 1801b638..09019996 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -11,8 +12,6 @@ struct SearchUsersScreen: View { @State private var users = [UserResponse]() @State private var isLoading = false @State private var query = "" - @State private var showErrorAlert = false - @State private var errorMessage = "" @State private var searchTask: Task? @State private var sendMessageTask: Task? var mode = Mode.regular @@ -49,10 +48,6 @@ struct SearchUsersScreen: View { onDismiss: { endMessaging() }, content: messageSheet ) - .alert(errorMessage, isPresented: $showErrorAlert) { - Button("Ok") { closeAlert() } - } - .onChange(of: errorMessage, perform: setupErrorAlert) .onDisappear(perform: cancelTasks) .navigationTitle("Поиск пользователей") .navigationBarTitleDisplayMode(.inline) @@ -103,10 +98,7 @@ private extension SearchUsersScreen { text: $messagingModel.message, isLoading: messagingModel.isLoading, isSendButtonDisabled: !messagingModel.canSendMessage, - sendAction: { sendMessage(to: recipient.id) }, - showErrorAlert: $showErrorAlert, - errorTitle: $errorMessage, - dismissError: closeAlert + sendAction: { sendMessage(to: recipient.id) } ) } @@ -117,7 +109,7 @@ private extension SearchUsersScreen { let isSuccess = try await SWClient(with: defaults).sendMessage(messagingModel.message, to: userID) endMessaging(isSuccess: isSuccess) } catch { - setupErrorAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } messagingModel.isLoading = false } @@ -138,22 +130,15 @@ private extension SearchUsersScreen { .findUsers(with: query.withoutSpaces) users = foundUsers if foundUsers.isEmpty { - errorMessage = "Не удалось найти такого пользователя" + SWAlert.shared.presentDefaultUIKit(message: "Не удалось найти такого пользователя") } } catch { - errorMessage = ErrorFilter.message(from: error) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } } - func setupErrorAlert(_ message: String) { - showErrorAlert = !message.isEmpty - errorMessage = message - } - - func closeAlert() { errorMessage = "" } - func cancelTasks() { [searchTask, sendMessageTask].forEach { $0?.cancel() } } diff --git a/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift index e4abdd0a..eb23786f 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -11,11 +12,7 @@ struct UserDetailsScreen: View { @State private var isLoading = false @State private var socialActions = SocialActions() @State private var messagingModel = MessagingModel() - @State private var showAlertMessage = false - @State private var showLogoutDialog = false @State private var showBlacklistConfirmation = false - @State private var showSearchUsersScreen = false - @State private var alertMessage = "" @State private var friendActionTask: Task? @State private var sendMessageTask: Task? @State private var blacklistUserTask: Task? @@ -32,20 +29,14 @@ struct UserDetailsScreen: View { var body: some View { ScrollView { VStack(spacing: 0) { - userInfoSection - if isMainUser { - editProfileButton - } else { - communicationSection - } + ProfileViews.makeUserInfo(for: user) + communicationSection VStack(spacing: 12) { - friendsButtonIfNeeded - usesParksIfNeeded - addedParksIfNeeded - journalsButtonIfNeeded - if isMainUser { blacklistButtonIfNeeded } + ProfileViews.makeFriends(for: user) + ProfileViews.makeUsedParks(for: user) + ProfileViews.makeAddedParks(for: user) + ProfileViews.makeJournals(for: user) } - if isMainUser { logoutButton } } .padding(.horizontal) } @@ -53,27 +44,18 @@ struct UserDetailsScreen: View { .opacity(user.isFull ? 1 : 0) .loadingOverlay(if: isLoading) .background(Color.swBackground) - .alert(alertMessage, isPresented: $showAlertMessage) { - Button("Ok") { closeAlert() } - } - .refreshable { await askForUserInfo(refresh: true) } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { refreshButtonIfNeeded } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Group { - if isMainUser { - searchUsersButton - } else { - blockUserButton - } - } - .disabled(isLoading) + ToolbarItem(placement: .topBarTrailing) { + blockUserButton + .disabled(isLoading) } } .onDisappear(perform: cancelTasks) - .task(priority: .userInitiated) { await askForUserInfo() } + .refreshable { await askForUserInfo(refresh: true) } + .task { await askForUserInfo() } .navigationTitle("Профиль") } } @@ -91,24 +73,6 @@ private extension UserDetailsScreen { } } - var userInfoSection: some View { - ProfileView( - imageURL: user.avatarURL, - login: user.userName ?? "", - genderWithAge: user.genderWithAge, - countryAndCity: SWAddress(user.countryID, user.cityID)?.address ?? "" - ) - .padding(24) - } - - var editProfileButton: some View { - NavigationLink(destination: EditProfileScreen()) { - Text("Изменить профиль") - } - .buttonStyle(SWButtonStyle(icon: .pencil, mode: .tinted, size: .large)) - .padding(.bottom, 24) - } - var communicationSection: some View { VStack(spacing: 12) { Button("Сообщение") { messagingModel.recipient = user } @@ -156,125 +120,13 @@ private extension UserDetailsScreen { } } - func toggleFriendRequestSent(isSent: Bool) { - socialActions.isFriendRequestSent = isSent - } - - @ViewBuilder - var usesParksIfNeeded: some View { - if user.hasUsedParks { - NavigationLink { - ParksListScreen(mode: .usedBy(userID: user.id)) - } label: { - FormRowView( - title: "Где тренируется", - trailingContent: .textWithChevron(user.usesParksCountString) - ) - } - .accessibilityIdentifier("usesParksButton") - } - } - - @ViewBuilder - var addedParksIfNeeded: some View { - if user.hasAddedParks { - NavigationLink { - ParksListScreen(mode: .added(list: user.addedParks ?? [])) - } label: { - FormRowView( - title: user.addedParksString, - trailingContent: .textWithChevron(user.addedParksCountString) - ) - } - } - } - - @ViewBuilder - var friendsButtonIfNeeded: some View { - let friendRequestsCount = defaults.friendRequestsList.count - if user.hasFriends || (isMainUser && friendRequestsCount > .zero) { - NavigationLink(destination: UsersListScreen(mode: .friends(userID: user.id))) { - FormRowView( - title: "Друзья", - trailingContent: .textWithBadgeAndChevron( - user.friendsCountString, - friendRequestsCount - ) - ) - } - } - } - - @ViewBuilder - var blacklistButtonIfNeeded: some View { - if !defaults.blacklistedUsers.isEmpty { - NavigationLink(destination: UsersListScreen(mode: .blacklist)) { - FormRowView( - title: "Черный список", - trailingContent: .textWithChevron(defaults.blacklistedUsersCountString) - ) - } - } - } - - @ViewBuilder - var journalsButtonIfNeeded: some View { - if isMainUser || user.hasJournals { - NavigationLink { - JournalsListScreen(userID: user.id) - .navigationTitle("Дневники") - .navigationBarTitleDisplayMode(.inline) - } label: { - FormRowView( - title: "Дневники", - trailingContent: .textWithChevron(user.journalsCountString) - ) - } - } - } - - var logoutButton: some View { - Button("Выйти") { showLogoutDialog = true } - .foregroundStyle(Color.swSmallElements) - .padding(.top, 36) - .padding(.bottom, 20) - .confirmationDialog( - .init(Constants.Alert.logout), - isPresented: $showLogoutDialog, - titleVisibility: .visible - ) { - Button("Выйти", role: .destructive) { - defaults.triggerLogout() - } - } - } - - var searchUsersButton: some View { - Button { - showSearchUsersScreen = true - } label: { - Icons.Regular.magnifyingglass.view - } - .disabled(!isNetworkConnected) - .accessibilityIdentifier("searchUsersButton") - .sheet(isPresented: $showSearchUsersScreen) { - NavigationView { - SearchUsersScreen() - } - } - } - - var settingsButton: some View { - NavigationLink(destination: SettingsScreen()) { - Icons.Regular.gearshape.view - } - } - func performFriendAction() { isLoading = true friendActionTask = Task { do { - if try await SWClient(with: defaults).friendAction(userID: user.id, option: socialActions.friend) { + let isSuccess = try await SWClient(with: defaults).friendAction(userID: user.id, option: socialActions.friend) + if isSuccess { + defaults.updateFriendIds(friendID: user.id, action: socialActions.friend) switch socialActions.friend { case .sendFriendRequest: socialActions.isFriendRequestSent = true @@ -283,7 +135,7 @@ private extension UserDetailsScreen { } } } catch { - setupResponseAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -293,20 +145,28 @@ private extension UserDetailsScreen { isLoading = true blacklistUserTask = Task { do { - if try await SWClient(with: defaults).blacklistAction( + let isSuccess = try await SWClient(with: defaults).blacklistAction( user: user, option: socialActions.blacklist - ) { + ) + if isSuccess { + defaults.updateBlacklist(option: socialActions.blacklist, user: user) switch socialActions.blacklist { case .add: - setupResponseAlert("Пользователь добавлен в черный список") + SWAlert.shared.presentDefaultUIKit( + title: "Готово".localized, + message: "Пользователь добавлен в черный список".localized + ) socialActions.blacklist = .remove case .remove: - setupResponseAlert("Пользователь удален из черного списка") + SWAlert.shared.presentDefaultUIKit( + title: "Готово".localized, + message: "Пользователь удален из черного списка".localized + ) socialActions.blacklist = .add } } } catch { - setupResponseAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } isLoading = false } @@ -315,24 +175,13 @@ private extension UserDetailsScreen { func askForUserInfo(refresh: Bool = false) async { guard !isLoading else { return } if !refresh { isLoading = true } - if isMainUser { - if !refresh, !defaults.needUpdateUser, - let mainUserInfo = defaults.mainUserInfo { - user = mainUserInfo - } else { - await makeUserInfo() - } - } else { - if !refresh, user.isFull { - isLoading = false - } else { - await makeUserInfo() - } - let isPersonInFriendList = defaults.friendsIdsList.contains(user.id) - socialActions.friend = isPersonInFriendList ? .removeFriend : .sendFriendRequest - let isPersonBlocked = defaults.blacklistedUsers.map(\.id).contains(user.id) - socialActions.blacklist = isPersonBlocked ? .remove : .add + if refresh || !user.isFull { + await makeUserInfo() } + let isPersonInFriendList = defaults.friendsIdsList.contains(user.id) + socialActions.friend = isPersonInFriendList ? .removeFriend : .sendFriendRequest + let isPersonBlocked = defaults.blacklistedUsers.map(\.id).contains(user.id) + socialActions.blacklist = isPersonBlocked ? .remove : .add isLoading = false } @@ -342,25 +191,15 @@ private extension UserDetailsScreen { text: $messagingModel.message, isLoading: messagingModel.isLoading, isSendButtonDisabled: !messagingModel.canSendMessage, - sendAction: sendMessage, - showErrorAlert: $showAlertMessage, - errorTitle: $alertMessage, - dismissError: closeAlert + sendAction: sendMessage ) } func makeUserInfo() async { do { - let client = SWClient(with: defaults) - async let info = client.getUserByID(user.id) - if isMainUser { - async let friendRequests: () = client.getFriendRequests() - async let blacklist: () = client.getBlacklist() - _ = try await (friendRequests, blacklist) - } - user = try await info + user = try await SWClient(with: defaults).getUserByID(user.id) } catch { - setupResponseAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } } @@ -373,23 +212,12 @@ private extension UserDetailsScreen { messagingModel.recipient = nil } } catch { - setupResponseAlert(ErrorFilter.message(from: error)) + SWAlert.shared.presentDefaultUIKit(message: ErrorFilter.message(from: error)) } messagingModel.isLoading = false } } - func setupResponseAlert(_ message: String) { - showAlertMessage = !message.isEmpty - alertMessage = message - } - - func closeAlert() { alertMessage = "" } - - var isMainUser: Bool { - user.id == defaults.mainUserInfo?.id - } - func cancelTasks() { [friendActionTask, sendMessageTask, blacklistUserTask].forEach { $0?.cancel() } } diff --git a/SwiftUI-WorkoutApp/Screens/Settings/LoginScreen.swift b/SwiftUI-WorkoutApp/Screens/Settings/LoginScreen.swift index dd887b2c..929388de 100644 --- a/SwiftUI-WorkoutApp/Screens/Settings/LoginScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Settings/LoginScreen.swift @@ -1,3 +1,4 @@ +import SWAlert import SWDesignSystem import SwiftUI import SWModels @@ -9,12 +10,12 @@ struct LoginScreen: View { @Environment(\.isNetworkConnected) private var isNetworkConnected @State private var isLoading = false @State private var credentials = Credentials() - @State private var showResetInfoAlert = false - @State private var showResetSuccessfulAlert = false - @State private var errorMessage = "" + @State private var resetErrorMessage = "" + @State private var loginErrorMessage = "" @State private var loginTask: Task? @State private var resetPasswordTask: Task? @FocusState private var focus: FocusableField? + private var client: SWClient { SWClient(with: defaults) } var body: some View { VStack(spacing: 0) { @@ -33,22 +34,30 @@ struct LoginScreen: View { .loadingOverlay(if: isLoading) .interactiveDismissDisabled(isLoading) .background(Color.swBackground) - .onChange(of: credentials) { _ in errorMessage = "" } - .alert(.init(Constants.Alert.resetSuccessful), isPresented: $showResetSuccessfulAlert) { - Button("Ok") { showResetSuccessfulAlert = false } - } + .onChange(of: credentials) { _ in clearErrorMessages() } .onDisappear(perform: cancelTasks) } } private extension LoginScreen { + // TODO: написать unit-тесты struct Credentials: Equatable { - var login = "" - var password = "" + var login: String + var password: String + let minPasswordSize: Int + + init( + login: String = "", + password: String = "", + minPasswordSize: Int = Constants.minPasswordSize + ) { + self.login = login + self.password = password + self.minPasswordSize = minPasswordSize + } var isReady: Bool { - !login.isEmpty - && password.trueCount >= Constants.minPasswordSize + !login.isEmpty && password.trueCount >= minPasswordSize } var canRestorePassword: Bool { !login.isEmpty } @@ -58,7 +67,9 @@ private extension LoginScreen { case username, password } - var isError: Bool { !errorMessage.isEmpty } + var isError: Bool { + !loginErrorMessage.isEmpty || !resetErrorMessage.isEmpty + } var canLogIn: Bool { credentials.isReady && !isError && isNetworkConnected @@ -69,7 +80,7 @@ private extension LoginScreen { placeholder: "Логин или email", text: $credentials.login, isFocused: focus == .username, - errorState: isError ? .noMessage : nil + errorState: isError ? .message(resetErrorMessage) : nil ) .focused($focus, equals: .username) .onAppear(perform: showKeyboard) @@ -90,7 +101,7 @@ private extension LoginScreen { text: $credentials.password, isSecure: true, isFocused: focus == .password, - errorState: isError ? .message(errorMessage) : nil + errorState: !loginErrorMessage.isEmpty ? .message(loginErrorMessage) : nil ) .focused($focus, equals: .password) .onSubmit(loginAction) @@ -107,9 +118,6 @@ private extension LoginScreen { var forgotPasswordButton: some View { Button("Восстановить пароль", action: forgotPasswordAction) .tint(.swMainText) - .alert(.init(Constants.Alert.forgotPassword), isPresented: $showResetInfoAlert) { - Button("Ok") { showResetInfoAlert = false } - } } func loginAction() { @@ -118,10 +126,13 @@ private extension LoginScreen { isLoading.toggle() loginTask = Task { do { - try await SWClient(with: defaults) - .logInWith(credentials.login, credentials.password) + let token = AuthData(login: credentials.login, password: credentials.password).token + let userId = try await client.logIn(with: token) + try defaults.saveAuthData(login: credentials.login, password: credentials.password) + let userInfo = try await client.getUserByID(userId) + try defaults.saveUserInfo(userInfo) } catch { - errorMessage = ErrorFilter.message(from: error) + loginErrorMessage = ErrorFilter.message(from: error) } isLoading.toggle() } @@ -129,22 +140,34 @@ private extension LoginScreen { func forgotPasswordAction() { guard credentials.canRestorePassword else { - showResetInfoAlert = true + SWAlert.shared.presentDefaultUIKit( + message: Constants.Alert.forgotPassword.localized + ) return } + clearErrorMessages() isLoading.toggle() resetPasswordTask = Task { do { - showResetSuccessfulAlert = try await SWClient(with: defaults) - .resetPassword(for: credentials.login) + if try await SWClient(with: defaults).resetPassword(for: credentials.login) { + SWAlert.shared.presentDefaultUIKit( + title: "Готово".localized, + message: Constants.Alert.resetSuccessful.localized + ) + } } catch { - errorMessage = ErrorFilter.message(from: error) + resetErrorMessage = ErrorFilter.message(from: error) } isLoading.toggle() } focus = credentials.canRestorePassword ? nil : .username } + func clearErrorMessages() { + loginErrorMessage = "" + resetErrorMessage = "" + } + func cancelTasks() { [loginTask, resetPasswordTask].forEach { $0?.cancel() } } diff --git a/SwiftUI-WorkoutApp/Services/DefaultsService.swift b/SwiftUI-WorkoutApp/Services/DefaultsService.swift index 8864c5c4..632ce853 100644 --- a/SwiftUI-WorkoutApp/Services/DefaultsService.swift +++ b/SwiftUI-WorkoutApp/Services/DefaultsService.swift @@ -4,11 +4,12 @@ import Utils @MainActor final class DefaultsService: ObservableObject, DefaultsProtocol { + var authToken: String? { try? basicAuthInfo().token } + @AppStorage(Key.needUpdateUser.rawValue) private(set) var needUpdateUser = false - @AppStorage(Key.isUserAuthorized.rawValue) - private(set) var isAuthorized = false + var isAuthorized: Bool { mainUserInfo != nil } @AppStorage(Key.appTheme.rawValue) private(set) var appTheme = AppColorTheme.system @@ -28,14 +29,11 @@ final class DefaultsService: ObservableObject, DefaultsProtocol { @AppStorage(Key.blacklist.rawValue) private var blacklist = Data() - @AppStorage(Key.hasParks.rawValue) - private(set) var hasParks = false + var hasParks: Bool { mainUserInfo?.hasUsedParks == true } - @AppStorage(Key.hasJournals.rawValue) - private(set) var hasJournals = false + var hasJournals: Bool { mainUserInfo?.hasJournals == true } - @AppStorage(Key.hasFriends.rawValue) - private(set) var hasFriends = false + var hasFriends: Bool { !friendsIdsList.isEmpty } @AppStorage(Key.unreadMessagesCount.rawValue) private(set) var unreadMessagesCount = 0 @@ -65,7 +63,7 @@ final class DefaultsService: ObservableObject, DefaultsProtocol { var blacklistedUsersCountString: String { String.localizedStringWithFormat( - NSLocalizedString("usersCount", comment: ""), + "usersCount".localized, blacklistedUsers.count ) } @@ -90,8 +88,9 @@ final class DefaultsService: ObservableObject, DefaultsProtocol { appTheme = theme } - func saveAuthData(_ info: AuthData) throws { - authData = try JSONEncoder().encode(info) + func saveAuthData(login: String, password: String) throws { + let model = AuthData(login: login, password: password) + authData = try JSONEncoder().encode(model) } func basicAuthInfo() throws -> AuthData { @@ -103,16 +102,11 @@ final class DefaultsService: ObservableObject, DefaultsProtocol { } func saveUserInfo(_ info: UserResponse) throws { - hasFriends = info.friendsCount ?? 0 != 0 - setHasParks(info.usedParksCount != 0) - setHasJournals(info.journalsCount ?? 0 != 0) - if !isAuthorized { isAuthorized = true } userInfo = try JSONEncoder().encode(info) setUserNeedUpdate(false) } func saveFriendsIds(_ ids: [Int]) throws { - hasFriends = !ids.isEmpty friendsIds = try JSONEncoder().encode(ids) } @@ -135,22 +129,6 @@ final class DefaultsService: ObservableObject, DefaultsProtocol { try? saveBlacklist(newList) } - func setHasJournals(_ hasJournals: Bool) { - self.hasJournals = hasJournals - } - - func setHasParks(_ isAddedPark: Bool) { - switch (hasParks, isAddedPark) { - case (true, true), (false, false): break - case (true, false): - if mainUserInfo?.usedParksCount == 1 { - hasParks = false - } - case (false, true): - hasParks = true - } - } - func saveUnreadMessagesCount(_ count: Int) { unreadMessagesCount = count } @@ -159,18 +137,27 @@ final class DefaultsService: ObservableObject, DefaultsProtocol { lastCountriesUpdateDate = .now } + /// Обновляет сохраненный список идентификаторов друзей главного пользователя + /// + /// Если друга удаляют, то удаляем его `id` из списка сохраненных друзей + /// - Parameters: + /// - friendID: `id` друга + /// - action: действие с другом (отправка заявки/удаление) + func updateFriendIds(friendID: Int, action: FriendAction) { + var newList = friendsIdsList + guard case .removeFriend = action else { return } + newList.removeAll(where: { $0 == friendID }) + try? saveFriendsIds(newList) + } + func triggerLogout() { authData = .init() userInfo = .init() - isAuthorized = false - hasParks = false try? saveFriendsIds([]) try? saveFriendRequests([]) try? saveBlacklist([]) saveUnreadMessagesCount(0) - setHasJournals(false) setUserNeedUpdate(true) - setAppTheme(.system) } } @@ -188,8 +175,6 @@ extension DefaultsService { private extension DefaultsService { enum Key: String { - case isUserAuthorized, appTheme, authData, userInfo, friends, friendRequests, blacklist, hasJournals, needUpdateUser, hasFriends, - unreadMessagesCount, lastCountriesUpdateDate - case hasParks = "hasSportsGrounds" + case appTheme, authData, userInfo, friends, friendRequests, blacklist, needUpdateUser, unreadMessagesCount, lastCountriesUpdateDate } } diff --git a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift index c91f885e..8d9a73b0 100644 --- a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift +++ b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift @@ -16,6 +16,7 @@ struct SwiftUI_WorkoutAppApp: App { @State private var countriesUpdateTask: Task? @State private var socialUpdateTask: Task? private let countriesStorage = SWAddress() + private var client: SWClient { SWClient(with: defaults) } private var colorScheme: ColorScheme? { switch defaults.appTheme { case .light: .light @@ -44,10 +45,14 @@ struct SwiftUI_WorkoutAppApp: App { switch phase { case .active: updateCountriesIfNeeded() + guard let mainUserId = defaults.mainUserInfo?.id else { return } socialUpdateTask = Task { - let isUpdated = await SWClient(with: defaults) - .getSocialUpdates(userID: defaults.mainUserInfo?.id) - defaults.setUserNeedUpdate(!isUpdated) + if let result = await client.getSocialUpdates(userID: mainUserId) { + try? defaults.saveFriendsIds(result.friends.map(\.id)) + try? defaults.saveFriendRequests(result.friendRequests) + try? defaults.saveBlacklist(result.blacklist) + defaults.setUserNeedUpdate(false) + } } default: [socialUpdateTask, countriesUpdateTask].forEach { $0?.cancel() } @@ -59,7 +64,7 @@ struct SwiftUI_WorkoutAppApp: App { private func updateCountriesIfNeeded() { guard countriesStorage.needUpdate(defaults.lastCountriesUpdateDate) else { return } countriesUpdateTask = Task { - if let countries = try? await SWClient(with: defaults).getCountries(), + if let countries = try? await client.getCountries(), countriesStorage.save(countries) { defaults.didUpdateCountries() }