diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 6645194..c5b1e49 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,17 @@ objects = { /* Begin PBXBuildFile section */ + 084770A82DAF92F60043935A /* PostExampleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770A72DAF92E50043935A /* PostExampleModel.swift */; }; + 084770AB2DAFA1C80043935A /* PostsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770AA2DAFA1C00043935A /* PostsRepository.swift */; }; + 084770DC2DAFD9C50043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */; }; + 084770DD2DAFD9C50043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */; }; + 084770DE2DAFD9C50043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */; }; + 084770E22DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */; }; + 084770E42DAFE1090043935A /* ListWithPageRequestServiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */; }; + 084770E62DAFE1A10043935A /* ListWithPageRequestServiceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */; }; + 08D497D82DB7E4DB008C31C3 /* PostModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D497D72DB7E4D7008C31C3 /* PostModel.swift */; }; + 08D497DA2DB7EA87008C31C3 /* PostsRepositoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D497D92DB7EA78008C31C3 /* PostsRepositoryError.swift */; }; + 08D497DD2DB7EAD4008C31C3 /* IntsRepositoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D497DC2DB7EAD2008C31C3 /* IntsRepositoryError.swift */; }; 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */; }; 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */; }; 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */; }; @@ -19,6 +30,17 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 084770A72DAF92E50043935A /* PostExampleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostExampleModel.swift; sourceTree = ""; }; + 084770AA2DAFA1C00043935A /* PostsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsRepository.swift; sourceTree = ""; }; + 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=1&PS=10.json"; sourceTree = ""; }; + 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=2&PS=10.json"; sourceTree = ""; }; + 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=3&PS=10.json"; sourceTree = ""; }; + 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=4&PS=10.json"; sourceTree = ""; }; + 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithPageRequestServiceView.swift; sourceTree = ""; }; + 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithPageRequestServiceViewModel.swift; sourceTree = ""; }; + 08D497D72DB7E4D7008C31C3 /* PostModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostModel.swift; sourceTree = ""; }; + 08D497D92DB7EA78008C31C3 /* PostsRepositoryError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsRepositoryError.swift; sourceTree = ""; }; + 08D497DC2DB7EAD2008C31C3 /* IntsRepositoryError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntsRepositoryError.swift; sourceTree = ""; }; 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithSectionsView.swift; sourceTree = ""; }; 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateViews.swift; sourceTree = ""; }; 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithoutSectionView.swift; sourceTree = ""; }; @@ -43,6 +65,65 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 084770A92DAFA1B30043935A /* Repository */ = { + isa = PBXGroup; + children = ( + E10FA355297A68830031ED65 /* IntsRepository.swift */, + 084770AA2DAFA1C00043935A /* PostsRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + 084770C72DAFA5250043935A /* Mock */ = { + isa = PBXGroup; + children = ( + 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */, + 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */, + 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */, + 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */, + ); + path = Mock; + sourceTree = ""; + }; + 084770D02DAFA7BB0043935A /* UI */ = { + isa = PBXGroup; + children = ( + 089B61F82DB2239C00059219 /* ListWithPageRequestService */, + 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */, + E1FDB14B29770604003CC2A5 /* ContentView.swift */, + 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */, + 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */, + ); + path = UI; + sourceTree = ""; + }; + 084770D12DAFA7D60043935A /* Model */ = { + isa = PBXGroup; + children = ( + 08D497D72DB7E4D7008C31C3 /* PostModel.swift */, + 084770A72DAF92E50043935A /* PostExampleModel.swift */, + ); + path = Model; + sourceTree = ""; + }; + 089B61F82DB2239C00059219 /* ListWithPageRequestService */ = { + isa = PBXGroup; + children = ( + 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */, + 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */, + ); + path = ListWithPageRequestService; + sourceTree = ""; + }; + 08D497DB2DB7EACD008C31C3 /* Error */ = { + isa = PBXGroup; + children = ( + 08D497DC2DB7EAD2008C31C3 /* IntsRepositoryError.swift */, + 08D497D92DB7EA78008C31C3 /* PostsRepositoryError.swift */, + ); + path = Error; + sourceTree = ""; + }; E10FA35129770CBE0031ED65 /* Packages */ = { isa = PBXGroup; children = ( @@ -72,12 +153,12 @@ E1FDB14829770604003CC2A5 /* Sources */ = { isa = PBXGroup; children = ( + 08D497DB2DB7EACD008C31C3 /* Error */, + 084770C72DAFA5250043935A /* Mock */, + 084770D12DAFA7D60043935A /* Model */, + 084770D02DAFA7BB0043935A /* UI */, + 084770A92DAFA1B30043935A /* Repository */, E1FDB14929770604003CC2A5 /* ExampleApp.swift */, - E1FDB14B29770604003CC2A5 /* ContentView.swift */, - 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */, - E10FA355297A68830031ED65 /* IntsRepository.swift */, - 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */, - 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */, E1FDB14D29770605003CC2A5 /* Assets.xcassets */, E1FDB14F29770605003CC2A5 /* Preview Content */, ); @@ -161,6 +242,10 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 084770DC2DAFD9C50043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */, + 084770DD2DAFD9C50043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */, + 084770E22DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json in Resources */, + 084770DE2DAFD9C50043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */, E1FDB15129770605003CC2A5 /* Preview Assets.xcassets in Resources */, E1FDB14E29770605003CC2A5 /* Assets.xcassets in Resources */, ); @@ -186,7 +271,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n swiftlint --path .. --config ../swiftlint.yml\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$HOME/.mint/bin:/opt/homebrew/bin:$PATH\"\nexpected_swiflint_version=\"0.58.2\"\n\nif (mint list | grep \"$expected_swiflint_version\"); then\n mint install realm/SwiftLint@\"$expected_swiflint_version\"\n swiftlint lint --config ../swiftlint.yml ..\nelse\n echo \"SwiftLint $expected_swiflint_version is not installed\"\n echo \"run the command in the terminal: mint install realm/SwiftLint@$expected_swiflint_version\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -195,12 +280,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 084770A82DAF92F60043935A /* PostExampleModel.swift in Sources */, E10FA356297A68830031ED65 /* IntsRepository.swift in Sources */, 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */, + 08D497DD2DB7EAD4008C31C3 /* IntsRepositoryError.swift in Sources */, E1FDB14C29770604003CC2A5 /* ContentView.swift in Sources */, 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */, + 084770AB2DAFA1C80043935A /* PostsRepository.swift in Sources */, + 084770E42DAFE1090043935A /* ListWithPageRequestServiceView.swift in Sources */, 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */, + 08D497DA2DB7EA87008C31C3 /* PostsRepositoryError.swift in Sources */, E1FDB14A29770604003CC2A5 /* ExampleApp.swift in Sources */, + 084770E62DAFE1A10043935A /* ListWithPageRequestServiceViewModel.swift in Sources */, + 08D497D82DB7E4DB008C31C3 /* PostModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Sources/Error/IntsRepositoryError.swift b/Example/Sources/Error/IntsRepositoryError.swift new file mode 100644 index 0000000..814b693 --- /dev/null +++ b/Example/Sources/Error/IntsRepositoryError.swift @@ -0,0 +1,21 @@ +// +// IntsRepositoryError.swift +// Example +// +// Created by Maria Nesterova on 22.04.2025. +// + +import Foundation + +enum IntsRepositoryError: Swift.Error { + case undefined +} + +extension IntsRepositoryError: LocalizedError { + var errorDescription: String? { + switch self { + case .undefined: + return "Undefined error" + } + } +} diff --git a/Example/Sources/Error/PostsRepositoryError.swift b/Example/Sources/Error/PostsRepositoryError.swift new file mode 100644 index 0000000..8a0cdb6 --- /dev/null +++ b/Example/Sources/Error/PostsRepositoryError.swift @@ -0,0 +1,21 @@ +// +// PostsRepositoryError.swift +// Example +// +// Created by Maria Nesterova on 22.04.2025. +// + +import Foundation + +enum PostsRepositoryError: Swift.Error { + case undefined +} + +extension PostsRepositoryError: LocalizedError { + var errorDescription: String? { + switch self { + case .undefined: + return "Undefined error" + } + } +} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json new file mode 100644 index 0000000..b11b2f8 --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "title": "Post 1", + "description": "Got first post", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "223e4567-e89b-12d3-a456-426614174001", + "title": "Post 2", + "description": "Excited for the second post!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "323e4567-e89b-12d3-a456-426614174002", + "title": "Post 3", + "description": "Third post vibes", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "423e4567-e89b-12d3-a456-426614174003", + "title": "Post 4", + "description": "Another day, another post", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "523e4567-e89b-12d3-a456-426614174004", + "title": "Post 5", + "description": "Loving this journey", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "623e4567-e89b-12d3-a456-426614174005", + "title": "Post 6", + "description": "Halfway through!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "723e4567-e89b-12d3-a456-426614174006", + "title": "Post 7", + "description": "Lucky number seven", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "823e4567-e89b-12d3-a456-426614174007", + "title": "Post 8", + "description": "Keeping it real", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "923e4567-e89b-12d3-a456-426614174008", + "title": "Post 9", + "description": "Almost at double digits", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "a23e4567-e89b-12d3-a456-426614174009", + "title": "Post 10", + "description": "Made it to ten!", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": true +} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json new file mode 100644 index 0000000..cf8bdb9 --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "b23e4567-e89b-12d3-a456-426614174010", + "title": "Post 11", + "description": "Eleven is my favorite", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "c23e4567-e89b-12d3-a456-426614174011", + "title": "Post 12", + "description": "Twelve posts and counting", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "d23e4567-e89b-12d3-a456-426614174012", + "title": "Post 13", + "description": "Lucky thirteen", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "e23e4567-e89b-12d3-a456-426614174013", + "title": "Post 14", + "description": "Fourteen feels great", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "f23e4567-e89b-12d3-a456-426614174014", + "title": "Post 15", + "description": "Halfway to thirty!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "g23e4567-e89b-12d3-a456-426614174015", + "title": "Post 16", + "description": "Sweet sixteen", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "h23e4567-e89b-12d3-a456-426614174016", + "title": "Post 17", + "description": "Seventeen and thriving", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "i23e4567-e89b-12d3-a456-426614174017", + "title": "Post 18", + "description": "Eighteen posts strong", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "j23e4567-e89b-12d3-a456-426614174018", + "title": "Post 19", + "description": "Nineteen and counting", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "k23e4567-e89b-12d3-a456-426614174019", + "title": "Post 20", + "description": "Twenty posts milestone!", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": true +} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json new file mode 100644 index 0000000..0af9f48 --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "l23e4567-e89b-12d3-a456-426614174020", + "title": "Post 21", + "description": "Beyond twenty, let's go!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "m23e4567-e89b-12d3-a456-426614174021", + "title": "Post 22", + "description": "Twenty-two and feeling new", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "n23e4567-e89b-12d3-a456-426614174022", + "title": "Post 23", + "description": "Twenty-three, what's in store?", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "o23e4567-e89b-12d3-a456-426614174023", + "title": "Post 24", + "description": "Twenty-four, let's soar", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "p23e4567-e89b-12d3-a456-426614174024", + "title": "Post 25", + "description": "Quarter century of posts!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "q23e4567-e89b-12d3-a456-426614174025", + "title": "Post 26", + "description": "Twenty-six, mixing it up", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "r23e4567-e89b-12d3-a456-426614174026", + "title": "Post 27", + "description": "Twenty-seven, keep it revvin'", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "s23e4567-e89b-12d3-a456-426614174027", + "title": "Post 28", + "description": "Twenty-eight, feeling great", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "t23e4567-e89b-12d3-a456-426614174028", + "title": "Post 29", + "description": "Twenty-nine, on cloud nine", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "u23e4567-e89b-12d3-a456-426614174029", + "title": "Post 30", + "description": "Thirty posts, who's counting?", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": true +} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=4&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=4&PS=10.json new file mode 100644 index 0000000..246144a --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=4&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "v23e4567-e89b-12d3-a456-426614174030", + "title": "Post 31", + "description": "Thirty-one, having fun", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "w23e4567-e89b-12d3-a456-426614174031", + "title": "Post 32", + "description": "Thirty-two, something new", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "x23e4567-e89b-12d3-a456-426614174032", + "title": "Post 33", + "description": "Thirty-three, living free", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "y23e4567-e89b-12d3-a456-426614174033", + "title": "Post 34", + "description": "Thirty-four, explore more", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "z23e4567-e89b-12d3-a456-426614174034", + "title": "Post 35", + "description": "Thirty-five, feeling alive", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "aa3e4567-e89b-12d3-a456-426614174035", + "title": "Post 36", + "description": "Thirty-six, full of tricks", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "bb3e4567-e89b-12d3-a456-426614174036", + "title": "Post 37", + "description": "Thirty-seven, post heaven", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "cc3e4567-e89b-12d3-a456-426614174037", + "title": "Post 38", + "description": "Thirty-eight, can't wait", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "dd3e4567-e89b-12d3-a456-426614174038", + "title": "Post 39", + "description": "Thirty-nine, feeling fine", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "ee3e4567-e89b-12d3-a456-426614174039", + "title": "Post 40", + "description": "Forty posts, the most!", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": false +} diff --git a/Example/Sources/Model/PostExampleModel.swift b/Example/Sources/Model/PostExampleModel.swift new file mode 100644 index 0000000..e3ee9c0 --- /dev/null +++ b/Example/Sources/Model/PostExampleModel.swift @@ -0,0 +1,22 @@ +// +// PostExampleModel.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import Foundation +import PagingList + +struct PostExampleModel: PaginatedResponse { + let items: [PostModel] + // swiftlint:disable:next discouraged_optional_boolean + let hasMore: Bool? + var totalPages: Int? + var currentPage: Int? + + enum CodingKeys: String, CodingKey { + case items = "posts" + case hasMore, totalPages, currentPage + } +} diff --git a/Example/Sources/Model/PostModel.swift b/Example/Sources/Model/PostModel.swift new file mode 100644 index 0000000..c6cc5ff --- /dev/null +++ b/Example/Sources/Model/PostModel.swift @@ -0,0 +1,12 @@ +// +// PostModel.swift +// Example +// +// Created by Maria Nesterova on 22.04.2025. +// + +struct PostModel: Codable, Identifiable, Sendable { + let id: String + let title: String + let description: String +} diff --git a/Example/Sources/IntsRepository.swift b/Example/Sources/Repository/IntsRepository.swift similarity index 68% rename from Example/Sources/IntsRepository.swift rename to Example/Sources/Repository/IntsRepository.swift index 0079e19..16f43e4 100644 --- a/Example/Sources/IntsRepository.swift +++ b/Example/Sources/Repository/IntsRepository.swift @@ -7,20 +7,7 @@ import Foundation -enum IntsRepositoryError: Swift.Error { - case undefined -} - -extension IntsRepositoryError: LocalizedError { - var errorDescription: String? { - switch self { - case .undefined: - return "Ooops:(" - } - } -} - -class IntsRepository { +final class IntsRepository: Sendable { private enum Constants { static let delayInNanoseconds: UInt64 = 3_000_000_000 } @@ -39,6 +26,6 @@ class IntsRepository { } } -extension Int: Identifiable { +extension Int: @retroactive Identifiable { public var id: Int { self } } diff --git a/Example/Sources/Repository/PostsRepository.swift b/Example/Sources/Repository/PostsRepository.swift new file mode 100644 index 0000000..a6730cd --- /dev/null +++ b/Example/Sources/Repository/PostsRepository.swift @@ -0,0 +1,45 @@ +// +// PostsRepository.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import Foundation + +final class PostsRepository: Sendable { + private enum Constants { + static let delayInNanoseconds: UInt64 = 3_000_000_000 + } + + @Sendable + func getPosts(page: Int, pageSize: Int) async throws -> PostExampleModel { + try await Task.sleep(nanoseconds: Constants.delayInNanoseconds) + + if let postExampleModel = getPostExampleData(page: page, pageSize: pageSize) { + return postExampleModel + } else { + throw PostsRepositoryError.undefined + } + } + + private func getPostExampleData(page: Int, pageSize: Int) -> PostExampleModel? { + var mockFileName = "MockPostExampleModel" + let mockExtension = "json" + + mockFileName = "\(mockFileName)&PI=\(page)&PS=\(pageSize)" + + guard let mockFileUrl = Bundle.main.url(forResource: mockFileName, withExtension: mockExtension) else { + return nil + } + + do { + let data = try Data(contentsOf: mockFileUrl) + let posts = try JSONDecoder().decode(PostExampleModel.self, from: data) + + return posts + } catch { + return nil + } + } +} diff --git a/Example/Sources/ContentView.swift b/Example/Sources/UI/ContentView.swift similarity index 70% rename from Example/Sources/ContentView.swift rename to Example/Sources/UI/ContentView.swift index bab672c..2d1c327 100644 --- a/Example/Sources/ContentView.swift +++ b/Example/Sources/UI/ContentView.swift @@ -11,6 +11,7 @@ import PagingList enum PagingListType: Equatable, Hashable, Identifiable { case listWithSection case listWithoutSection + case listWithPageRequestService var id: Self { self } } @@ -24,7 +25,7 @@ struct ContentView: View { Button { navigationPath.append(.listWithSection) } label: { - Text("Tap to go list with section") + Text("Tap to go to list with section") .padding(20) } .background(.gray) @@ -33,7 +34,16 @@ struct ContentView: View { Button { navigationPath.append(.listWithoutSection) } label: { - Text("Tap to go list without section") + Text("Tap to go to list without section") + .padding(20) + } + .background(.gray) + .cornerRadius(15) + + Button { + navigationPath.append(.listWithPageRequestService) + } label: { + Text("Tap to go to list with PageRequestService") .padding(20) } .background(.gray) @@ -45,6 +55,8 @@ struct ContentView: View { ListWithSectionsView() case .listWithoutSection: ListWithoutSectionView() + case .listWithPageRequestService: + ListWithPageRequestServiceView() } } } diff --git a/Example/Sources/ListStateViews.swift b/Example/Sources/UI/ListStateViews.swift similarity index 100% rename from Example/Sources/ListStateViews.swift rename to Example/Sources/UI/ListStateViews.swift diff --git a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift new file mode 100644 index 0000000..e884db7 --- /dev/null +++ b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift @@ -0,0 +1,75 @@ +// +// ListWithPageRequestService.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import SwiftUI +import PagingList + +struct ListWithPageRequestServiceView: View { + @StateObject private var viewModel = ListWithPageRequestServiceViewModel() + + private let repository = PostsRepository() + + var body: some View { + PagingList( + state: $viewModel.pagingState, + items: viewModel.posts + ) { post in + PostView(post: post) + .listRowSeparator(.hidden) + } fullscreenEmptyView: { + FullscreenEmptyStateView() + .listRowSeparator(.hidden) + } fullscreenLoadingView: { + FullscreenLoadingStateView() + .listRowSeparator(.hidden) + } fullscreenErrorView: { error in + FullscreenErrorStateView(error: error) { + viewModel.pagingState = .fullscreenLoading + viewModel.requestPosts(isFirst: true) + } + .listRowSeparator(.hidden) + } pagingDisabledView: { + PagingDisabledStateView() + .listRowSeparator(.hidden) + } pagingLoadingView: { + if viewModel.canLoadMore { + PagingLoadingStateView() + .listRowSeparator(.hidden) + } + } pagingErrorView: { error in + PagingErrorStateView(error: error) { + viewModel.requestPosts(isFirst: false) + } + .listRowSeparator(.hidden) + } onPageRequest: { isFirst in + viewModel.requestPosts(isFirst: isFirst) + } onRefreshRequest: { + viewModel.requestPosts(isFirst: true) + } + .listStyle(.plain) + .onAppear { + viewModel.requestPosts(isFirst: true) + } + } +} + +private struct PostView: View { + let post: PostModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(post.title) + .font(.title) + Text(post.description) + .font(.caption) + } + } +} + +#Preview { + ListWithPageRequestServiceView() +} diff --git a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift new file mode 100644 index 0000000..9c25cdb --- /dev/null +++ b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift @@ -0,0 +1,48 @@ +// +// ListWithPageRequestServiceViewModel.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import Foundation +import PagingList + +@MainActor +final class ListWithPageRequestServiceViewModel: ObservableObject { + private enum Constants { + static let requestLimit = 10 + } + + @Published var posts: [PostModel] = [] + @Published var pagingState: PagingListState = .fullscreenLoading + @Published var canLoadMore: Bool = true + + var pageRequestService: PageRequestService + + private let postRepository: PostsRepository + + init(postRepository: PostsRepository = PostsRepository()) { + self.postRepository = postRepository + pageRequestService = PageRequestService(startPage: 1, fetchPage: postRepository.getPosts(page:pageSize:)) + } + + func requestPosts(isFirst: Bool) { + Task { + do { + try await pageRequestService.request(pageSize: Constants.requestLimit, isFirst: isFirst) + let posts = await pageRequestService.getItems() + let pagingState = await pageRequestService.getPagingState() + let canLoadMore = await pageRequestService.getCanLoadMore() + self.posts = posts + self.pagingState = pagingState + self.canLoadMore = canLoadMore + } catch { + let pagingState = await pageRequestService.getPagingState() + let canLoadMore = await pageRequestService.getCanLoadMore() + self.pagingState = pagingState + self.canLoadMore = canLoadMore + } + } + } +} diff --git a/Example/Sources/ListWithSectionsView.swift b/Example/Sources/UI/ListWithSectionsView.swift similarity index 98% rename from Example/Sources/ListWithSectionsView.swift rename to Example/Sources/UI/ListWithSectionsView.swift index 5094e64..041eefd 100644 --- a/Example/Sources/ListWithSectionsView.swift +++ b/Example/Sources/UI/ListWithSectionsView.swift @@ -25,7 +25,6 @@ struct ListWithSectionsView: View { private let repository = IntsRepository() - // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { PagingList( state: $pagingState, @@ -84,7 +83,6 @@ struct ListWithSectionsView: View { requestItems(isFirst: true) } } - // swiftlint:enable vertical_parameter_alignment_on_call // Sync method for first loading and pagination loading content. private func requestItems(isFirst: Bool) { diff --git a/Example/Sources/ListWithoutSectionView.swift b/Example/Sources/UI/ListWithoutSectionView.swift similarity index 96% rename from Example/Sources/ListWithoutSectionView.swift rename to Example/Sources/UI/ListWithoutSectionView.swift index 478c17c..70509eb 100644 --- a/Example/Sources/ListWithoutSectionView.swift +++ b/Example/Sources/UI/ListWithoutSectionView.swift @@ -14,12 +14,11 @@ struct ListWithoutSectionView: View { } @State private var loadedPagesCount = 0 - @State private var items = [Int]() + @State private var items: [Int] = [] @State private var pagingState: PagingListState = .items private let repository = IntsRepository() - // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { PagingList( state: $pagingState, @@ -62,7 +61,6 @@ struct ListWithoutSectionView: View { requestItems(isFirst: true) } } - // swiftlint:enable vertical_parameter_alignment_on_call // Sync method for first loading and pagination loading content. private func requestItems(isFirst: Bool) { diff --git a/Sources/PagingList/Extension/NotificationName+Ext.swift b/Sources/PagingList/Extension/NotificationName+Ext.swift new file mode 100644 index 0000000..a9914e5 --- /dev/null +++ b/Sources/PagingList/Extension/NotificationName+Ext.swift @@ -0,0 +1,12 @@ +// +// NotificationName+Ext.swift +// PagingList +// +// Created by Maria Nesterova on 22.04.2025. +// + +import Foundation + +extension Notification.Name { + static let stopPrefetching = Notification.Name("StopPrefetching") +} diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift new file mode 100644 index 0000000..03a003e --- /dev/null +++ b/Sources/PagingList/PageRequestService.swift @@ -0,0 +1,235 @@ +import Foundation + +/// Errors that can occur during paginated requests. +public enum PagingError: Error { + /// A request is already in progress. + case requestInProgress + + /// The response data is invalid or cannot be cast to the expected type. + case invalidResponse +} + +/// A protocol defining the structure of a paginated response, including items and optional pagination metadata. +public protocol PaginatedResponse: Codable, Sendable { + /// The type of items contained in the response, conforming to Codable and Sendable. + associatedtype T: Codable, Sendable + + /// The list of items for the current page. + var items: [T] { get } + + // swiftlint:disable discouraged_optional_boolean + /// Indicates whether more pages are available. + var hasMore: Bool? { get } + // swiftlint:enable discouraged_optional_boolean + + /// The total number of pages, if known. + var totalPages: Int? { get } + + /// The current page number, if known. + var currentPage: Int? { get } +} + +/// A service for managing paginated data requests, including prefetching upcoming pages. +public final class PageRequestService: Sendable { + private let state: PageRequestState + private let fetchPage: @Sendable (_ page: Int, _ pageSize: Int) async throws -> ResponseModel + + /// Initializes the service with a starting page and a closure for fetching pages. + /// - Parameters: + /// - startPage: The initial page number (default is 1). + /// - prefetchTreshold: The maximum number of pages to prefetch ahead (default is 1). + /// - fetchPage: A closure that fetches a page of data asynchronously, returning a `PaginatedResponse`. + public init( + startPage: Int = 1, + prefetchTreshold: Int = 1, + fetchPage: @escaping @Sendable (_ page: Int, _ pageSize: Int) async throws -> ResponseModel + ) { + self.state = PageRequestState(startPage: startPage, prefetchTreshold: prefetchTreshold) + self.fetchPage = fetchPage + + // Subscribe to a notification to stop prefetching when the PagingList screen is dismissed. + NotificationCenter.default.addObserver( + forName: .stopPrefetching, + object: nil, + queue: .main + ) { [weak self] _ in + self?.stopPrefetching() + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// Requests a page of data, either from prefetched data or via a network request. + /// - Parameters: + /// - pageSize: The number of items per page. + /// - isFirst: If `true`, requests the first page and resets existing data. + public func request(pageSize: Int, isFirst: Bool) async throws { + guard await state.startRequest(isFirst: isFirst) else { + throw PagingError.requestInProgress + } + + let currentPage = await state.currentPage + let page = isFirst ? state.startPage : currentPage + + let task = Task { + do { + let modelItems: [DataModel] + + if + isFirst == false, + let prefetchedResponse = await state.getPrefetchedResponse(for: page), + let items = prefetchedResponse.items as? [DataModel] + { + // Use prefetched data if available for the current page. + modelItems = items + await updateCanLoadMore(items: items, pageSize: pageSize, model: prefetchedResponse) + } else { + // Perform a request if no prefetched data is available. + let model = try await fetchPage(page, pageSize) + + guard let items = model.items as? [DataModel] else { + throw PagingError.invalidResponse + } + + modelItems = items + await updateCanLoadMore(items: modelItems, pageSize: pageSize, model: model) + } + + guard Task.isCancelled == false else { + return + } + + await state.setItems(modelItems) + await state.setPagingState(.items) + await state.incrementPage() + + await state.endRequest() + await prefetchIfNeeded(pageSize: pageSize) + } catch { + await state.endRequest() + await state.setPagingState(isFirst ? .fullscreenError(error) : .pagingError(error)) + throw error + } + } + + await state.setRequestTask(task) + try await task.value + } + + /// Stops ongoing prefetching operations. + public func stopPrefetching() { + Task { + await state.cancelPrefetchTask() + await state.endRequest() + } + } + + /// Retrieves whether more pages can be loaded, related to current page on the screen. + /// - Returns: `true` if more pages are available, `false` otherwise. + public func getCanLoadMore() async -> Bool { + await state.canLoadMore + } + + /// Retrieves the current list of loaded items, excluding prefetched. + /// - Returns: An array of `DataModel` items. + public func getItems() async -> [DataModel] { + await state.items + } + + /// Retrieves the current UI state of the paginated list. + /// - Returns: The current `PagingListState`. + public func getPagingState() async -> PagingListState { + await state.pagingState + } + + /// Sets the UI state of the paginated list. + /// - Parameter pagingState: The new `PagingListState` to apply. + public func setPagingState(pagingState: PagingListState) async { + await state.setPagingState(pagingState) + } + + private func prefetchIfNeeded(pageSize: Int) async { + guard await state.isRequestInProcess else { + return + } + + let canLoadMorePrefetch = await state.canLoadMorePrefetch + let canPrefetchMore = await state.canPrefetchMore + + if canPrefetchMore && canLoadMorePrefetch { + await state.setPrefetchPending(true) + await prefetchNextPages(pageSize: pageSize) + } + } + + private func prefetchNextPages(pageSize: Int) async { + await state.cancelPrefetchTask() + + let task = Task { [weak self] in + guard let self else { + return + } + + guard await state.canPrefetchMore else { + await state.setPrefetchPending(false) + return + } + + guard await state.isPrefetchPending else { + return + } + + let nextPage: Int + + if let maxPrefetchedPage = await state.maxPrefetchedPage { + nextPage = max(maxPrefetchedPage + 1, await state.currentPage) + } else { + nextPage = await state.currentPage + } + + if let response = try? await fetchPage(nextPage, pageSize) { + if let items = response.items as? [DataModel] { + await updateCanLoadMorePrefetch(items: items, pageSize: pageSize, model: response) + } + + await state.setPrefetchedItems(response, forPage: nextPage) + await state.incrementPrefetchedPages() + await state.setMaxPrefetchedPage(nextPage) + } + + await state.setPrefetchPending(false) + await prefetchIfNeeded(pageSize: pageSize) + } + + await state.setPrefetchTask(task) + } + + private func updateCanLoadMore(items: [DataModel], pageSize: Int, model: ResponseModel? = nil) async { + let canLoadMore = getCanLoadMore(items: items, pageSize: pageSize, model: model) + + await state.setCanLoadMore(canLoadMore) + await state.setCanLoadMorePrefetch(canLoadMore) + } + + private func updateCanLoadMorePrefetch(items: [DataModel], pageSize: Int, model: ResponseModel? = nil) async { + let canLoadMore = getCanLoadMore(items: items, pageSize: pageSize, model: model) + + await state.setCanLoadMorePrefetch(canLoadMore) + } + + private func getCanLoadMore(items: [DataModel], pageSize: Int, model: ResponseModel? = nil) -> Bool { + let canLoadMore: Bool + + if let hasMore = model?.hasMore { + canLoadMore = hasMore + } else if let totalPages = model?.totalPages, let currentPage = model?.currentPage { + canLoadMore = currentPage < totalPages + } else { + canLoadMore = items.count == pageSize + } + + return canLoadMore + } +} diff --git a/Sources/PagingList/PageRequestState.swift b/Sources/PagingList/PageRequestState.swift new file mode 100644 index 0000000..13335ce --- /dev/null +++ b/Sources/PagingList/PageRequestState.swift @@ -0,0 +1,170 @@ +/// A thread-safe actor that manages state of paginated requests, including current page, items, and prefetching logic. +actor PageRequestState { + let startPage: Int + + /// The maximum page number that has been prefetched, if any. + var maxPrefetchedPage: Int? + + /// Indicates whether more pages can be prefetched based on the prefetch threshold. + var canPrefetchMore: Bool { return prefetchedPages < prefetchTreshold } + + private(set) var currentPage: Int + + /// Indicates whether more pages can be loaded, related to current page on the screen for requests to continue. + private(set) var canLoadMore: Bool = true + + /// Indicates whether more pages can be loaded, related to already prefetched pages. + private(set) var canLoadMorePrefetch: Bool = true + + /// The list of items loaded so far. + private(set) var items: [DataModel] = [] + + /// The current UI state of the paginated list (e.g., loading, error, items). + private(set) var pagingState: PagingListState = .fullscreenLoading + + /// Indicates whether a page request is currently in progress. + private(set) var isRequestInProcess: Bool = false + + /// Indicates whether a prefetch operation is pending. + private(set) var isPrefetchPending: Bool = false + + private var requestTask: Task? + private var prefetchedPages: Int = 0 + private var prefetchedResponse: [Int: ResponseModel] = [:] + private var prefetchTask: Task? + private let prefetchTreshold: Int + + /// Initializes the state with a starting page and prefetch threshold. + /// - Parameters: + /// - startPage: The initial page number. + /// - prefetchTreshold: The maximum number of pages to prefetch ahead. + init(startPage: Int, prefetchTreshold: Int) { + self.startPage = startPage + self.currentPage = startPage + self.prefetchTreshold = prefetchTreshold + } + + /// Starts a new page request, preventing concurrent requests. + /// - Returns: `true` if the request was started, `false` if a request is already in progress. + func startRequest(isFirst: Bool) -> Bool { + if isFirst { + items.removeAll() + cancelRequestTask() + endRequest() + currentPage = startPage + cancelPrefetchTask() + resetPrefetchedPages() + } + + guard isRequestInProcess == false else { + return false + } + + isRequestInProcess = true + + return isRequestInProcess + } + + /// Ends the current page request, allowing new requests to start. + func endRequest() { + isRequestInProcess = false + } + + /// Increments the count of prefetched pages. + func incrementPrefetchedPages() { + prefetchedPages += 1 + } + + /// Resets the prefetching state, clearing prefetched pages and responses. + func resetPrefetchedPages() { + prefetchedPages = 0 + prefetchedResponse = [:] + isPrefetchPending = false + } + + /// Increments the current page number. + func incrementPage() { + currentPage += 1 + } + + /// Sets whether more pages can be loaded, related to current page on the screen for requests to continue., . + /// - Parameter value: `true` if more pages are available, `false` otherwise. + func setCanLoadMore(_ value: Bool) { + canLoadMore = value + } + + /// Sets whether more pages can be prefetched, related to already prefetched pages. + /// - Parameter value: `true` if prefetching is allowed, `false` otherwise. + func setCanLoadMorePrefetch(_ value: Bool) { + canLoadMorePrefetch = value + } + + /// Updates the list of items, either appending or replacing based on whether it's the first page. + /// - Parameters: + /// - newItems: The new items to add. + /// - isFirst: If `true`, clears existing items before adding new ones. + func setItems(_ newItems: [DataModel]) { + items.append(contentsOf: newItems) + } + + /// Sets the current UI state of the paginated list. + /// - Parameter state: The new `PagingListState` to apply. + func setPagingState(_ state: PagingListState) { + pagingState = state + } + + /// Sets the maximum prefetched page number. + /// - Parameter value: The page number to set as the maximum prefetched. + func setMaxPrefetchedPage(_ value: Int) { + maxPrefetchedPage = value + } + + /// Sets the task responsible for requesting pages. + /// - Parameter task: The `Task` to set, or `nil` to clear. + func setRequestTask(_ task: Task?) { + requestTask = task + } + + /// Sets the task responsible for prefetching pages. + /// - Parameter task: The `Task` to set, or `nil` to clear. + func setPrefetchTask(_ task: Task?) { + prefetchTask = task + } + + /// Cancels the current prefetch task, if any. + func cancelPrefetchTask() { + prefetchTask?.cancel() + prefetchTask = nil + } + + /// Retrieves and removes the prefetched response for a specific page. + /// - Parameter page: The page number to retrieve. + /// - Returns: The prefetched `ResponseModel` if available, or `nil`. + func getPrefetchedResponse(for page: Int) -> ResponseModel? { + let result = prefetchedResponse[page] + + prefetchedResponse.removeValue(forKey: page) + prefetchedPages -= 1 + + return result + } + + /// Stores a prefetched response for a specific page. + /// - Parameters: + /// - items: The `ResponseModel` to store, or `nil` to clear. + /// - page: The page number associated with the response. + func setPrefetchedItems(_ items: ResponseModel?, forPage page: Int) { + prefetchedResponse[page] = items + } + + /// Sets whether a prefetch operation is pending. + /// - Parameter value: `true` if prefetching is pending, `false` otherwise. + func setPrefetchPending(_ value: Bool) { + isPrefetchPending = value + } + + private func cancelRequestTask() { + requestTask?.cancel() + requestTask = nil + } +} diff --git a/Sources/PagingList/PagingList.swift b/Sources/PagingList/PagingList.swift index 5a14dfe..370934f 100644 --- a/Sources/PagingList/PagingList.swift +++ b/Sources/PagingList/PagingList.swift @@ -63,6 +63,9 @@ public struct PagingList< } } .refreshable(action: requestOnRefresh) + .onDisappear { + NotificationCenter.default.post(name: .stopPrefetching, object: nil) + } } public init( @@ -92,11 +95,7 @@ public struct PagingList< } private func requestNextPage() { - if state == .pagingLoading { - return - } - - if state == .disabled { + if state == .pagingLoading || state == .disabled { return } diff --git a/Sources/PagingList/PagingListState.swift b/Sources/PagingList/PagingListState.swift index 65563c6..a7fc1ca 100644 --- a/Sources/PagingList/PagingListState.swift +++ b/Sources/PagingList/PagingListState.swift @@ -1,19 +1,25 @@ import Foundation -public enum PagingListState { - // Paging disabled. +public enum PagingListState: Sendable { + /// Paging disabled. case disabled - // Cells are visible. No loading next items here. + + /// Cells are visible. No loading next items here. case items - // Fullscreen initial data loading(first page). + + /// Fullscreen initial data loading(first page). case fullscreenLoading - // Fullscreen error on loading first page. + + /// Fullscreen error on loading first page. case fullscreenError(Error) - // Loading next page(> 1). Next page loading cell is visible here at the bottom fo the list. + + /// Loading next page(> 1). Next page loading cell is visible here at the bottom fo the list. case pagingLoading - // Error on next page loading. Next page error cell is visible here at the bottom of the list. + + /// Error on next page loading. Next page error cell is visible here at the bottom of the list. case pagingError(Error) - // Updating content with pull to refresh + + /// Updating content with pull to refresh case refresh } diff --git a/swiftlint.yml b/swiftlint.yml index cc4e3c5..27324ed 100644 --- a/swiftlint.yml +++ b/swiftlint.yml @@ -4,13 +4,9 @@ disabled_rules: - type_body_length - notification_center_detachment - generic_type_name - + opt_in_rules: - - anyobject_protocol - array_init - - block_based_kvo - - class_delegate_protocol - - closing_brace - closure_body_length - closure_end_indentation - closure_spacing @@ -20,17 +16,14 @@ opt_in_rules: - contains_over_filter_is_empty - contains_over_first_not_nil - contains_over_range_nil_comparison - - compiler_protocol_init - - custom_rules + - convenience_type - discouraged_optional_boolean - discouraged_object_literal - - duplicate_imports - empty_collection_literal - empty_count - empty_string - empty_xctest_method - explicit_init - - explicit_self - fallthrough - fatal_error_message - first_where @@ -41,7 +34,6 @@ opt_in_rules: - joined_default_parameter - last_where - legacy_multiple - - legacy_random - literal_expression_end_indentation - lower_acl_than_parent - multiline_arguments @@ -49,6 +41,7 @@ opt_in_rules: - multiline_literal_brackets - multiline_parameters - nimble_operator + - non_overridable_class_declaration - nslocalizedstring_key - number_separator - object_literal @@ -60,6 +53,7 @@ opt_in_rules: - private_outlet - prefer_zero_over_explicit_init - prohibited_super_call + - prohibited_interface_builder - quick_discouraged_call - quick_discouraged_focused_test - quick_discouraged_pending_test @@ -73,8 +67,6 @@ opt_in_rules: - strong_iboutlet - toggle_bool - unneeded_parentheses_in_closure_argument - - unused_declaration - - unused_import - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - xct_specific_matcher @@ -89,6 +81,15 @@ excluded: - Source/UI/Extensions/UIFont+extension.swift - Source/Resources/R.generated.swift +analyzer_rules: + - explicit_self + - unused_declaration + - unused_import + +opening_brace: + severity: warning + ignore_multiline_statement_conditions: true + force_cast: warning force_unwrapping: warning force_try: warning @@ -104,8 +105,8 @@ file_length: error: 500 closure_body_length: - warning: 30 - error: 30 + warning: 40 + error: 40 type_name: min_length: 1 @@ -131,7 +132,74 @@ nesting: type_level: warning: 4 error: 8 - -warning_threshold: 50 + +custom_rules: + # Избегаем многострочных комментариев /* ... */ + no_multiline_comments: + name: "No Multiline Comments" + regex: "/\\*[^*]*\\*+([^/*][^*]*\\*+)*/" + match_kinds: comment + message: "Use // instead of /* ... */ for comments." + severity: warning + + # Избегаем !, используем == false + no_negation_in_conditions: + name: "No Negation in Conditions" + regex: "(if|guard|while)\\s+([^\\{]*\\s+)?![a-zA-Z0-9_]+(\\.[a-zA-Z0-9_]+)*\\s*([^\\{]*)\\s*(guard\\s+.*\\s+else\\s*)?\\{" + message: "Use '== false' instead of '!' in conditions (e.g., 'if name.isEmpty == false')." + severity: warning + + # Избегаем _Protocol_ в названиях протоколов + no_protocol_suffix: + name: "No 'protocol' in Protocol Names" + regex: "protocol\\s+[a-zA-Z0-9_]*?(?i)protocol(?-i)[a-zA-Z0-9_]*" + message: "Avoid using 'protocol' (in any case) in protocol names." + severity: warning + + # Избегаем configure/set в именах методов, используем setup + no_configure_or_set_in_methods: + name: "No 'configure' or 'set' in Method Names" + regex: "(func|private\\s+func)\\s+\\b(configure|set)\\b[a-zA-Z0-9_]*\\(" + excluded: + - ".*(?i)(service|provider).*" # Исключает файлы с Service или Provider в имени типа + message: "Use 'setup' instead of 'configure' or 'set' in method names." + severity: warning + + # Используем [] вместо [Type]() + no_type_init_for_arrays: + name: "No Type Init for Arrays" + regex: "(let|var)\\s+[a-zA-Z0-9_]+\\s*=\\s*\\[+[a-zA-Z0-9_]+(?:\\.[a-zA-Z0-9_]+)*\\]+\\(\\)" + message: "Use '[]' instead of '[Type]()' for array initialization." + severity: warning + + # Избегание unowned + no_unowned_in_closures: + name: "No Unowned in Closures" + regex: "\\[unowned\\s+self\\]" + message: "Use '[weak self]' instead of 'unowned' in closures." + severity: error + + # Свойства с get, set выделяем в приватные методы, если логика требует более одной строки + property_get_set_single_line: + name: "Property Get/Set Single Line" + regex: "^\\s*(var|let)\\s+[a-zA-Z0-9_]+\\s*:\\s*[a-zA-Z0-9_]+\\s*\\{\\s*(get|set)\\s*\\{[^}]*\\n[^}]*\\}" + message: "Properties with get/set should be single-line. Extract multi-line logic to a private method." + severity: warning + + # Свойства с willSet, didSet выделяем в приватные методы, если логика требует более одной строки + property_willset_didset_single_line: + name: "Property WillSet/DidSet Single Line" + regex: "(willSet|didSet)\\s*\\{[^}]*\\n[^}]*\\}" + message: "WillSet and DidSet should be single-line. Extract multi-line logic to a private method." + severity: warning + + # Избегаем эмодзи в коде + no_emoji: + name: "No Emoji" + regex: "[\\x{1F300}-\\x{1F5FF}\\x{1F600}-\\x{1F64F}\\x{1F680}-\\x{1F6FF}\\x{1F900}-\\x{1F9FF}]" + message: "Do not use emoji in code." + severity: warning + +warning_threshold: 1 reporter: "xcode"