diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4415dfc..b09f602 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,20 +23,12 @@ jobs:
with:
args: --strict
env:
-
DIFF_BASE: ${{ github.base_ref }}
-
-
-
macOS:
-
name: ${{ matrix.name }}
runs-on: ${{ matrix.runsOn }}
-
env:
-
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer"
-
timeout-minutes: 20
strategy:
fail-fast: false
@@ -45,49 +37,37 @@ jobs:
- xcode: "Xcode_15.0"
runsOn: macos-13
name: "macOS 13, Xcode 15.0, Swift 5.9.0"
+ params: "-skipMacroValidation"
- xcode: "Xcode_14.3.1"
runsOn: macos-13
name: "macOS 13, Xcode 14.3.1, Swift 5.8.0"
+ params: ""
- xcode: "Xcode_14.2"
runsOn: macOS-12
name: "macOS 12, Xcode 14.2, Swift 5.7.2"
- xcode: "Xcode_14.1"
runsOn: macOS-12
name: "macOS 12, Xcode 14.1, Swift 5.7.1"
+ params: ""
steps:
- uses: actions/checkout@v3
-
- name: ${{ matrix.name }}
- run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
-
+ run: xcodebuild test -scheme "Blade-Package" -destination "platform=macOS" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3.1.0
with:
-
token: ${{ secrets.CODECOV_TOKEN }}
-
xcode: true
-
xcode_archive_path: test_output/${{ matrix.name }}.xcresult
-
- uses: actions/upload-artifact@v4
with:
-
name: ${{ matrix.name }}
-
path: test_output
-
-
-
iOS:
-
name: ${{ matrix.name }}
runs-on: ${{ matrix.runsOn }}
-
env:
-
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer"
-
timeout-minutes: 20
strategy:
fail-fast: false
@@ -97,34 +77,25 @@ jobs:
name: "iOS 17.0.1"
xcode: "Xcode_15.0"
runsOn: macos-13
+ params: "-skipMacroValidation"
- destination: "OS=16.4,name=iPhone 14 Pro"
name: "iOS 16.4"
xcode: "Xcode_14.3.1"
runsOn: macos-13
+ params: ""
steps:
- uses: actions/checkout@v3
-
- name: ${{ matrix.name }}
- run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
-
+ run: xcodebuild test -scheme "Blade-Package" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
- uses: actions/upload-artifact@v4
with:
-
name: ${{ matrix.name }}
path: test_output
-
-
-
-
tvOS:
-
name: ${{ matrix.name }}
runs-on: ${{ matrix.runsOn }}
-
env:
-
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer"
-
timeout-minutes: 20
strategy:
fail-fast: false
@@ -134,44 +105,31 @@ jobs:
name: "tvOS 17.0"
xcode: "Xcode_15.0"
runsOn: macos-13
+ params: "-skipMacroValidation"
- destination: "OS=16.4,name=Apple TV"
name: "tvOS 16.4"
xcode: "Xcode_14.3.1"
runsOn: macos-13
+ params: ""
steps:
- uses: actions/checkout@v3
-
- name: ${{ matrix.name }}
- run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
-
+ run: xcodebuild test -scheme "Blade-Package" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3.1.0
with:
-
token: ${{ secrets.CODECOV_TOKEN }}
-
xcode: true
-
xcode_archive_path: test_output/${{ matrix.name }}.xcresult
-
- uses: actions/upload-artifact@v4
with:
-
name: ${{ matrix.name }}
-
path: test_output
-
-
-
watchOS:
-
name: ${{ matrix.name }}
runs-on: ${{ matrix.runsOn }}
-
env:
-
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer"
-
timeout-minutes: 20
strategy:
fail-fast: false
@@ -181,47 +139,36 @@ jobs:
name: "watchOS 10.0"
xcode: "Xcode_15.0"
runsOn: macos-13
+ params: "-skipMacroValidation"
- destination: "OS=9.4,name=Apple Watch Series 8 (45mm)"
name: "watchOS 9.4"
xcode: "Xcode_14.3.1"
runsOn: macos-13
+ params: ""
- destination: "OS=8.5,name=Apple Watch Series 7 (45mm)"
name: "watchOS 8.5"
xcode: "Xcode_14.3.1"
runsOn: macos-13
+ params: ""
steps:
- uses: actions/checkout@v3
-
- name: ${{ matrix.name }}
- run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
-
+ run: xcodebuild test -scheme "Blade-Package" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3.1.0
with:
-
token: ${{ secrets.CODECOV_TOKEN }}
-
xcode: true
-
xcode_archive_path: test_output/${{ matrix.name }}.xcresult
-
- uses: actions/upload-artifact@v4
with:
-
name: ${{ matrix.name }}
-
path: test_output
-
-
spm:
-
name: ${{ matrix.name }}
runs-on: ${{ matrix.runsOn }}
-
env:
-
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer"
-
timeout-minutes: 20
strategy:
fail-fast: false
@@ -235,10 +182,11 @@ jobs:
runsOn: macos-13
steps:
- uses: actions/checkout@v3
-
- name: ${{ matrix.name }}
- run: swift build -c release --target "{{ cookiecutter.name }}"
-
+ run: |
+ swift build -c release --target "Blade"
+ swift build -c release --target "BladeTCA"
+
merge-test-reports:
needs: [iOS, macOS, watchOS, tvOS]
runs-on: macos-13
diff --git a/.swiftlint.yml b/.swiftlint.yml
index 89efd09..44740c2 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -1,6 +1,8 @@
excluded:
- Tests
- Package.swift
+ - Package@swift-5.7.swift
+ - Package@swift-5.8.swift
- .build
# Rules
@@ -9,6 +11,7 @@ disabled_rules:
- trailing_comma
- todo
- opening_brace
+ - multiple_closures_with_trailing_closure
opt_in_rules: # some rules are only opt-in
- anyobject_protocol
@@ -18,7 +21,6 @@ opt_in_rules: # some rules are only opt-in
- closure_end_indentation
- closure_spacing
- collection_alignment
- - conditional_returns_on_newline
- contains_over_filter_count
- contains_over_filter_is_empty
- contains_over_first_not_nil
diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Blade-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Blade-Package.xcscheme
new file mode 100644
index 0000000..0d2f5ea
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/Blade-Package.xcscheme
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Blade.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Blade.xcscheme
new file mode 100644
index 0000000..12000ba
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/Blade.xcscheme
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BladeTCA.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BladeTCA.xcscheme
new file mode 100644
index 0000000..1f635f4
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/BladeTCA.xcscheme
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e9885a..63e5ce9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,2 +1,12 @@
# Change Log
-All notable changes to this project will be documented in this file.
\ No newline at end of file
+All notable changes to this project will be documented in this file.
+
+#### 1.x Releases
+- `1.0.x` Releases - [1.0.0](#100)
+
+## [1.0.0](https://github.com/space-code/blade/releases/tag/1.0.0)
+Released on 2024-02-05.
+
+#### Added
+- Initial release of Blade.
+ - Added by [Nikita Vasilev](https://github.com/nik3212).
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5f89471..1e2051e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -58,4 +58,4 @@ See [CODE_OF_CONDUCT.md](https://github.com/space-code/blade/blob/master/CODE_OF
---
-*Some of the ideas and wording for the statements above were based on work by the [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) and [Linux](https://elinux.org/Developer_Certificate_Of_Origin) communities. We commend them for their efforts to facilitate collaboration in their projects.*
\ No newline at end of file
+*Some of the ideas and wording for the statements above were based on work by the [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) and [Linux](https://elinux.org/Developer_Certificate_Of_Origin) communities.
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 3edc6d6..856d64b 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ bootstrap: hook
mint bootstrap
hook:
- ln -sf .git/hooks/pre-commit ../../hooks/pre-commit
+ ln -sf ../../hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
mint:
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..4284f1c
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,113 @@
+{
+ "pins" : [
+ {
+ "identity" : "combine-schedulers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/combine-schedulers",
+ "state" : {
+ "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb",
+ "version" : "1.0.0"
+ }
+ },
+ {
+ "identity" : "swift-case-paths",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-case-paths",
+ "state" : {
+ "revision" : "76d7791b5bda47df7e3d4690c4c3aaf089730707",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-clocks",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-clocks",
+ "state" : {
+ "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33",
+ "version" : "1.0.2"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections",
+ "state" : {
+ "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192",
+ "version" : "1.0.6"
+ }
+ },
+ {
+ "identity" : "swift-composable-architecture",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-composable-architecture.git",
+ "state" : {
+ "revision" : "ae491c9e3f66631e72d58db8bb4c27dfc3d3afd4",
+ "version" : "1.6.0"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71",
+ "version" : "1.1.0"
+ }
+ },
+ {
+ "identity" : "swift-custom-dump",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-custom-dump",
+ "state" : {
+ "revision" : "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605",
+ "version" : "1.1.2"
+ }
+ },
+ {
+ "identity" : "swift-dependencies",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-dependencies",
+ "state" : {
+ "revision" : "adb04a8e35f07edc001877af9f9f97fcc21d409e",
+ "version" : "1.2.0"
+ }
+ },
+ {
+ "identity" : "swift-identified-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-identified-collections",
+ "state" : {
+ "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8",
+ "version" : "1.0.0"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-syntax",
+ "state" : {
+ "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d",
+ "version" : "509.1.1"
+ }
+ },
+ {
+ "identity" : "swiftui-navigation",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swiftui-navigation",
+ "state" : {
+ "revision" : "78f9d72cf667adb47e2040aa373185c88c63f0dc",
+ "version" : "1.2.0"
+ }
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
+ "state" : {
+ "revision" : "b58e6627149808b40634c4552fcf2f44d0b3ca87",
+ "version" : "1.1.0"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Package.swift b/Package.swift
index ce886f3..fa74cab 100644
--- a/Package.swift
+++ b/Package.swift
@@ -5,19 +5,36 @@ import PackageDescription
let package = Package(
name: "Blade",
+ platforms: [
+ .iOS(.v13),
+ .macOS(.v11),
+ .tvOS(.v13),
+ .watchOS(.v7),
+ .visionOS(.v1),
+ ],
products: [
- // Products define the executables and libraries a package produces, making them visible to other packages.
- .library(
- name: "Blade",
- targets: ["Blade"]),
+ .library(name: "Blade", targets: ["Blade"]),
+ .library(name: "BladeTCA", targets: ["BladeTCA"]),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")),
],
targets: [
- // Targets are the basic building blocks of a package, defining a module or a test suite.
- // Targets can depend on other targets in this package and products from dependencies.
+ .target(name: "Blade"),
.target(
- name: "Blade"),
+ name: "BladeTCA",
+ dependencies: [
+ "Blade",
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
+ .testTarget(name: "BladeTests", dependencies: ["Blade"]),
.testTarget(
- name: "BladeTests",
- dependencies: ["Blade"]),
+ name: "BladeTCATests",
+ dependencies: [
+ "BladeTCA",
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
]
)
diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift
new file mode 100644
index 0000000..e381fcd
--- /dev/null
+++ b/Package@swift-5.7.swift
@@ -0,0 +1,32 @@
+// swift-tools-version: 5.7
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "Blade",
+ platforms: [
+ .iOS(.v13),
+ .macOS(.v11),
+ .tvOS(.v13),
+ .watchOS(.v7),
+ ],
+ products: [
+ .library(name: "Blade", targets: ["Blade"]),
+ .library(name: "BladeTCA", targets: ["BladeTCA"]),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")),
+ ],
+ targets: [
+ .target(name: "Blade"),
+ .target(
+ name: "BladeTCA",
+ dependencies: [
+ "Blade",
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
+ .testTarget(name: "BladeTests", dependencies: ["Blade"]),
+ ]
+)
diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift
new file mode 100644
index 0000000..f4caac3
--- /dev/null
+++ b/Package@swift-5.8.swift
@@ -0,0 +1,32 @@
+// swift-tools-version: 5.8
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "Blade",
+ platforms: [
+ .iOS(.v13),
+ .macOS(.v11),
+ .tvOS(.v13),
+ .watchOS(.v7),
+ ],
+ products: [
+ .library(name: "Blade", targets: ["Blade"]),
+ .library(name: "BladeTCA", targets: ["BladeTCA"]),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")),
+ ],
+ targets: [
+ .target(name: "Blade"),
+ .target(
+ name: "BladeTCA",
+ dependencies: [
+ "Blade",
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
+ .testTarget(name: "BladeTests", dependencies: ["Blade"]),
+ ]
+)
diff --git a/README.md b/README.md
index 909b62c..68ca9b2 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,28 @@
+![Blade: a pagination framework that simplifies the integration of pagination into the application](https://raw.githubusercontent.com/space-code/blade/dev/Resources/blade.png)
+
blade
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## Description
-`Blade` description.
+`Blade` is a pagination framework that simplifies the integration of pagination into the application.
- [Usage](#usage)
- [Requirements](#requirements)
@@ -29,8 +34,133 @@
## Usage
+Blade provides two libraries for working with pagination: `Blade` and `BladeTCA`. `BladeTCA` is an extension designed for working with [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture). Both support working with offset and cursor-based paginations.
+
+### Basic Usage
+
+First, you need to implement page loader for whether cursor or offset based pagination:
+
+```swift
+import Blade
+
+/// Offset pagination loader
+
+final class OffsetPageLoader: IOffsetPageLoader {
+ func loadPage(request: OffsetPaginationRequest) async throws -> Page {
+ // Implementation here
+ }
+}
+
+/// Cursor pagination loader
+
+final class CursorPageLoader: ICursorPageLoader {
+ func loadPage(request: CursorPaginationRequest) async throws -> Page {
+ // Implementation here
+ }
+}
+```
+
+Second, create a `Paginator` instance:
+
+```swift
+import Blade
+
+/// Offset-based pagination
+let paginator = Paginator(configuration: PaginationLimitOffset(firstPage: .zero, limit: 20), offsetPageLoader: OffsetPageLoader())
+
+/// Cursor-based pagination
+let paginator = Paginator(configuration: PaginationCursorSeek(id: #id_here), offsetPageLoader: CursorPageLoader())
+```
+
+Third, the paginator is capable of requesting the first page, the next page, and resetting its state, like this:
+
+```swift
+/// Request an initial page
+let page = try await paginator.refresh()
+
+/// Request next page
+let nextPage = try await paginator.nextPage()
+
+/// Reset state
+await paginator.reset()
+```
+
+### TCA Usage
+
+If your app uses the [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture), `Blade` can be easily integrated.
+
+```swift
+import BladeTCA
+
+// MARK: Reducer
+
+@Reducer
+struct SomeFeature {
+ // MARK: Types
+
+ struct State: Equatable {
+ var paginator: PaginatorState
+ }
+
+ enum Action {
+ case child(PaginatorAction)
+ }
+
+ // MARK: Reducer
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch state {
+ case .clild:
+ return .none
+ }
+ }
+ .paginator(
+ state: \SomeFeature.State.paginator,
+ action: /SomeFeature.Action.child,
+ loadPage: { request, state in
+ // Load page here
+ }
+ )
+ }
+}
+
+// MARK: View
+
+import ComposableArchitecture
+import DesignKit
+import SwiftUI
+
+// MARK: - SomeView
+
+struct SomeView: View {
+ // MARK: Properties
+
+ private let store: StoreOf
+
+ // MARK: Initialization
+
+ init(store: StoreOf) {
+ self.store = store
+ }
+
+ // MARK: View
+
+ var body: some View {
+ PaginatorListView(
+ store: store.scope(state: \.paginator, action: { .child($0) })
+ ) { state in
+ // Implement UI here
+ }
+ }
+```
+
## Requirements
+- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+
+- Xcode 15.0
+- Swift 5.7
+
## Installation
### Swift Package Manager
@@ -62,4 +192,4 @@ Please feel free to help out with this project! If you see something that could
Nikita Vasilev, nv3212@gmail.com
## License
-blade is available under the MIT license. See the LICENSE file for more info.
\ No newline at end of file
+blade is available under the MIT license. See the LICENSE file for more info.
diff --git a/Resources/blade.png b/Resources/blade.png
new file mode 100644
index 0000000..8ce176a
Binary files /dev/null and b/Resources/blade.png differ
diff --git a/Sources/Blade/Classes/Blade.swift b/Sources/Blade/Classes/Blade.swift
deleted file mode 100644
index af25d31..0000000
--- a/Sources/Blade/Classes/Blade.swift
+++ /dev/null
@@ -1,6 +0,0 @@
-//
-// Blade
-// Copyright © 2024 Space Code. All rights reserved.
-//
-
-final class Blade {}
diff --git a/Sources/Blade/Classes/Core/Paginator/IPaginator.swift b/Sources/Blade/Classes/Core/Paginator/IPaginator.swift
new file mode 100644
index 0000000..b921e6d
--- /dev/null
+++ b/Sources/Blade/Classes/Core/Paginator/IPaginator.swift
@@ -0,0 +1,29 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// Protocol defining the interface for a paginator that manages asynchronous loading of paginated data.
+public protocol IPaginator {
+ /// The type of elements the paginator is handling, which must conform to `Decodable` & `Equatable`.
+ associatedtype Element: Decodable & Equatable
+
+ /// Asynchronously refreshes the paginator, resetting to the first page and fetching new data.
+ ///
+ /// - Returns: A `PageInfo` representing the refreshed data.
+ ///
+ /// - Throws: An error if there's an issue during the refresh process.
+ func refresh() async throws -> Page
+
+ /// Asynchronously loads the next page of data.
+ ///
+ /// - Returns: A `PageInfo` representing the newly loaded data.
+ ///
+ /// - Throws: An error if there's an issue during the loading process.
+ func loadNextPage() async throws -> Page
+
+ /// Asynchronously resets the paginator, clearing all previously loaded data and resetting the page index.
+ func reset() async
+}
diff --git a/Sources/Blade/Classes/Core/Paginator/PageLoader/ICursorPageLoader.swift b/Sources/Blade/Classes/Core/Paginator/PageLoader/ICursorPageLoader.swift
new file mode 100644
index 0000000..1424f7e
--- /dev/null
+++ b/Sources/Blade/Classes/Core/Paginator/PageLoader/ICursorPageLoader.swift
@@ -0,0 +1,19 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+public protocol ICursorPageLoader {
+ /// The type of elements the paginator is handling, which must conform to `Decodable` & `Equatable`.
+ associatedtype Element: Decodable & Equatable & Identifiable
+
+ /// Loads a page of elements based on the provided pagination request asynchronously.
+ ///
+ /// - Parameters:
+ /// - request: A `LimitPageRequest` specifying the limit and offset for the requested page.
+ ///
+ /// - Returns: An asynchronous task representing the loading process, resolving to a `Page` of elements.
+ func loadPage(request: CursorPaginationRequest) async throws -> Page
+}
diff --git a/Sources/Blade/Classes/Core/Paginator/PageLoader/IOffsetPageLoader.swift b/Sources/Blade/Classes/Core/Paginator/PageLoader/IOffsetPageLoader.swift
new file mode 100644
index 0000000..140a149
--- /dev/null
+++ b/Sources/Blade/Classes/Core/Paginator/PageLoader/IOffsetPageLoader.swift
@@ -0,0 +1,19 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+public protocol IOffsetPageLoader {
+ /// The type of elements the paginator is handling, which must conform to `Decodable` & `Equatable`.
+ associatedtype Element: Decodable & Equatable
+
+ /// Loads a page of elements based on the provided pagination request asynchronously.
+ ///
+ /// - Parameters:
+ /// - request: A `OffsetPaginationRequest` specifying the limit and offset for the requested page.
+ ///
+ /// - Returns: An asynchronous task representing the loading process, resolving to a `Page` of elements.
+ func loadPage(request: OffsetPaginationRequest) async throws -> Page
+}
diff --git a/Sources/Blade/Classes/Core/Paginator/Paginator.swift b/Sources/Blade/Classes/Core/Paginator/Paginator.swift
new file mode 100644
index 0000000..32789c5
--- /dev/null
+++ b/Sources/Blade/Classes/Core/Paginator/Paginator.swift
@@ -0,0 +1,98 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+// MARK: - Paginator
+
+/// Paginator is an actor responsible for paginating and loading data using a provided paginator service.
+public actor Paginator {
+ // MARK: Types
+
+ /// Enum representing errors that may occur during pagination.
+ public enum Error: Swift.Error {
+ case alreadyLoading
+ }
+
+ // MARK: Properties
+
+ /// Internal flag to track whether the paginator is currently loading data.
+ private var isLoadingInternal = false
+
+ /// Public property to check if the paginator is currently loading.
+ private(set) var isLoading: Bool {
+ get { Task.isCancelled || isLoadingInternal }
+ set { isLoadingInternal = newValue }
+ }
+
+ /// An array to store the loaded elements.
+ public private(set) var elements: [T] = []
+
+ private let paginationStrategy: any IPaginationStrategy
+
+ // MARK: Initialization
+
+ /// Initializes the Paginator with the provided first page number and paginator service.
+ public init(
+ configuration: PaginationLimitOffset,
+ offsetPageLoader: any IOffsetPageLoader
+ ) {
+ paginationStrategy = LimitOffsetStrategy(
+ configuration: configuration,
+ pageLoader: offsetPageLoader
+ )
+ }
+
+ public init(
+ configuration: PaginationCursorSeek,
+ cursorPageLoader: any ICursorPageLoader
+ ) where T: Identifiable {
+ paginationStrategy = CursorSeekStrategy(
+ configuration: configuration,
+ pageLoader: cursorPageLoader
+ )
+ }
+
+ // MARK: Private
+
+ private func perform(_ task: @autoclosure () async throws -> Page) async throws -> Page {
+ guard !isLoadingInternal else { throw Error.alreadyLoading }
+
+ isLoadingInternal = true
+ defer { isLoadingInternal = false }
+
+ return try await task()
+ }
+}
+
+// MARK: IPaginator
+
+extension Paginator: IPaginator {
+ public func refresh() async throws -> Page {
+ try await perform(
+ await {
+ let page = try await paginationStrategy.refresh()
+ elements = page.items
+ return page
+ }()
+ )
+ }
+
+ public func loadNextPage() async throws -> Page {
+ try await perform(
+ await {
+ let page = try await paginationStrategy.loadNextPage()
+ elements += page.items
+ return page
+ }()
+ )
+ }
+
+ public func reset() async {
+ await paginationStrategy.reset()
+ elements = []
+ isLoadingInternal = false
+ }
+}
diff --git a/Sources/Blade/Classes/Core/Paginator/Strategies/CursorSeekStrategy.swift b/Sources/Blade/Classes/Core/Paginator/Strategies/CursorSeekStrategy.swift
new file mode 100644
index 0000000..804dd4e
--- /dev/null
+++ b/Sources/Blade/Classes/Core/Paginator/Strategies/CursorSeekStrategy.swift
@@ -0,0 +1,53 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+// MARK: - CursorSeekStrategy
+
+actor CursorSeekStrategy {
+ // MARK: Properties
+
+ private let configuration: PaginationCursorSeek
+ private let pageLoader: any ICursorPageLoader
+
+ private var id: Element.ID
+
+ // MARK: Initialization
+
+ init(
+ configuration: PaginationCursorSeek,
+ pageLoader: any ICursorPageLoader
+ ) {
+ self.configuration = configuration
+ self.pageLoader = pageLoader
+ id = configuration.id
+ }
+}
+
+// MARK: IPaginationStrategy
+
+extension CursorSeekStrategy: IPaginationStrategy {
+ func refresh() async throws -> Page {
+ let page = try await pageLoader.loadPage(request: CursorPaginationRequest(id: id))
+ return page
+ }
+
+ func loadNextPage() async throws -> Page {
+ let page = try await pageLoader.loadPage(request: CursorPaginationRequest(id: id))
+
+ guard let lastID = page.items.last?.id else {
+ throw URLError(.unknown)
+ }
+
+ id = lastID
+
+ return page
+ }
+
+ func reset() async {
+ id = configuration.id
+ }
+}
diff --git a/Sources/Blade/Classes/Core/Paginator/Strategies/LimitOffsetStrategy.swift b/Sources/Blade/Classes/Core/Paginator/Strategies/LimitOffsetStrategy.swift
new file mode 100644
index 0000000..e3bfc41
--- /dev/null
+++ b/Sources/Blade/Classes/Core/Paginator/Strategies/LimitOffsetStrategy.swift
@@ -0,0 +1,47 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+// MARK: - LimitOffsetStrategy
+
+actor LimitOffsetStrategy {
+ // MARK: Properties
+
+ private let configuration: PaginationLimitOffset
+ private let pageLoader: any IOffsetPageLoader
+
+ private var currentPage: Int
+
+ // MARK: Initialization
+
+ init(configuration: PaginationLimitOffset, pageLoader: any IOffsetPageLoader) {
+ self.configuration = configuration
+ self.pageLoader = pageLoader
+ currentPage = configuration.firstPage
+ }
+}
+
+// MARK: IPaginationStrategy
+
+extension LimitOffsetStrategy: IPaginationStrategy {
+ func refresh() async throws -> Page {
+ currentPage = configuration.firstPage
+ let page = try await pageLoader.loadPage(request: OffsetPaginationRequest(limit: configuration.limit, offset: .zero))
+ currentPage += 1
+ return page
+ }
+
+ func loadNextPage() async throws -> Page {
+ let page = try await pageLoader.loadPage(request: OffsetPaginationRequest(
+ limit: configuration.limit,
+ offset: configuration.limit * (currentPage + 1)
+ ))
+ currentPage += 1
+ return page
+ }
+
+ func reset() async {}
+}
diff --git a/Sources/Blade/Classes/Core/Paginator/Strategies/Protocols/IPaginationStrategy.swift b/Sources/Blade/Classes/Core/Paginator/Strategies/Protocols/IPaginationStrategy.swift
new file mode 100644
index 0000000..a0f6e86
--- /dev/null
+++ b/Sources/Blade/Classes/Core/Paginator/Strategies/Protocols/IPaginationStrategy.swift
@@ -0,0 +1,8 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+protocol IPaginationStrategy: IPaginator where Element: Decodable & Equatable {}
diff --git a/Sources/Blade/Classes/Models/Configuration/PaginationCursorSeek.swift b/Sources/Blade/Classes/Models/Configuration/PaginationCursorSeek.swift
new file mode 100644
index 0000000..dd57163
--- /dev/null
+++ b/Sources/Blade/Classes/Models/Configuration/PaginationCursorSeek.swift
@@ -0,0 +1,18 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+public struct PaginationCursorSeek {
+ // MARK: Properties
+
+ public let id: T.ID
+
+ // MARK: Initialization
+
+ public init(id: T.ID) {
+ self.id = id
+ }
+}
diff --git a/Sources/Blade/Classes/Models/Configuration/PaginationLimitOffset.swift b/Sources/Blade/Classes/Models/Configuration/PaginationLimitOffset.swift
new file mode 100644
index 0000000..0ce0315
--- /dev/null
+++ b/Sources/Blade/Classes/Models/Configuration/PaginationLimitOffset.swift
@@ -0,0 +1,20 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+public struct PaginationLimitOffset {
+ // MARK: Properties
+
+ public let firstPage: Int
+ public let limit: Int
+
+ // MARK: Initialization
+
+ public init(firstPage: Int, limit: Int) {
+ self.firstPage = firstPage
+ self.limit = limit
+ }
+}
diff --git a/Sources/Blade/Classes/Models/Page.swift b/Sources/Blade/Classes/Models/Page.swift
new file mode 100644
index 0000000..5b99678
--- /dev/null
+++ b/Sources/Blade/Classes/Models/Page.swift
@@ -0,0 +1,30 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// A generic struct representing a paginated collection of items.
+public struct Page: Equatable {
+ // MARK: Properties
+
+ /// An array of items of generic type T contained in the current page.
+ public let items: [T]
+
+ /// A boolean flag indicating whether there are more data available beyond
+ /// the current page.
+ public let hasMoreData: Bool
+
+ // MARK: Initialization
+
+ /// Creates a `Page` instance.
+ ///
+ /// - Parameters:
+ /// - items: An array of items of generic type T contained in the current page.
+ /// - hasMoreData: A boolean flag indicating whether there are more data available beyond the current page.
+ public init(items: [T], hasMoreData: Bool) {
+ self.items = items
+ self.hasMoreData = hasMoreData
+ }
+}
diff --git a/Sources/Blade/Classes/Models/Requests/CursorPaginationRequest.swift b/Sources/Blade/Classes/Models/Requests/CursorPaginationRequest.swift
new file mode 100644
index 0000000..eea7eb3
--- /dev/null
+++ b/Sources/Blade/Classes/Models/Requests/CursorPaginationRequest.swift
@@ -0,0 +1,18 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+public struct CursorPaginationRequest: Equatable {
+ // MARK: Properties
+
+ public let id: T.ID
+
+ // MARK: Initialization
+
+ public init(id: T.ID) {
+ self.id = id
+ }
+}
diff --git a/Sources/Blade/Classes/Models/Requests/OffsetPaginationRequest.swift b/Sources/Blade/Classes/Models/Requests/OffsetPaginationRequest.swift
new file mode 100644
index 0000000..ff30950
--- /dev/null
+++ b/Sources/Blade/Classes/Models/Requests/OffsetPaginationRequest.swift
@@ -0,0 +1,31 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// A struct representing a request for paginated data with specified limits and offsets.
+public struct OffsetPaginationRequest: Equatable {
+ // MARK: Properties
+
+ /// The maximum number of items to be included in a page.
+ public let limit: Int
+
+ /// The offset indicating the position of the first item in the requested page
+ /// relative to the entire dataset.
+ public let offset: Int
+
+ // MARK: Initialization
+
+ /// Creates a ``OffsetPaginationRequest`` instance.
+ ///
+ /// - Parameters:
+ /// - limit: The maximum number of items to be included in a page.
+ /// - offset: The offset indicating the position of the first item in the requested page
+ /// relative to the entire dataset.
+ public init(limit: Int, offset: Int) {
+ self.limit = limit
+ self.offset = offset
+ }
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/CursorPositionBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/CursorPositionBuilderStrategy.swift
new file mode 100644
index 0000000..f2dd580
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/CursorPositionBuilderStrategy.swift
@@ -0,0 +1,18 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// A cursor-based paginator position builder.
+struct CursorPositionBuilderStrategy: IPositionBuilderStrategy {
+ /// Creates a next position.
+ ///
+ /// - Parameter state: The current state of the paginator.
+ ///
+ /// - Returns: The next position offset based on the strategy.
+ func next(state: PaginatorState) -> State.ID {
+ state.items.last?.id ?? state.position
+ }
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/OffsetPositionBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/OffsetPositionBuilderStrategy.swift
new file mode 100644
index 0000000..418fabe
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/OffsetPositionBuilderStrategy.swift
@@ -0,0 +1,18 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+
+/// A offset-based paginator position builder.
+struct OffsetPositionBuilderStrategy: IPositionBuilderStrategy {
+ /// Creates a next position.
+ ///
+ /// - Parameter state: The current state of the paginator.
+ ///
+ /// - Returns: The next position offset based on the strategy.
+ func next(state: PaginatorState) -> Int {
+ state.items.count
+ }
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/Protocols/IPositionBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/Protocols/IPositionBuilderStrategy.swift
new file mode 100644
index 0000000..3f85ff6
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/Protocols/IPositionBuilderStrategy.swift
@@ -0,0 +1,19 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// This protocol defines the interface for a strategy used in building positions based on a given state.
+protocol IPositionBuilderStrategy {
+ associatedtype State: Equatable
+ associatedtype PositionType: Equatable
+
+ /// Takes a state as input and returns the corresponding position.
+ ///
+ /// - Parameter state: The state for which the next position needs to be built.
+ ///
+ /// - Returns: The resulting position based on the provided state.
+ func next(state: State) -> PositionType
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/CursorRequestBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/CursorRequestBuilderStrategy.swift
new file mode 100644
index 0000000..d1ca2fc
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/CursorRequestBuilderStrategy.swift
@@ -0,0 +1,20 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+
+/// A request builder strategy for cursor-based pagination.
+struct CursorRequestBuilderStrategy: IRequestBuilderStrategy {
+ // MARK: IRequestBuilderStrategy
+
+ /// Constructs a pagination request based on the provided state.
+ ///
+ /// - Parameter state: The current state of the paginator.
+ ///
+ /// - Returns: A CursorPaginationRequest with the cursor position from the provided state.
+ func makeRequest(state: PaginatorState) -> CursorPaginationRequest {
+ CursorPaginationRequest(id: state.position)
+ }
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/OffsetRequestBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/OffsetRequestBuilderStrategy.swift
new file mode 100644
index 0000000..1fc7aea
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/OffsetRequestBuilderStrategy.swift
@@ -0,0 +1,35 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+import Foundation
+
+/// A request builder strategy for offset-based pagination.
+struct OffsetRequestBuilderStrategy: IRequestBuilderStrategy {
+ // MARK: Properties
+
+ /// The maximum number of items to be included in the pagination request.
+ private let limit: Int
+
+ // MARK: Initialization
+
+ /// Initializes the OffsetRequestBuilderStrategy with a specified limit.
+ ///
+ /// - Parameter limit: The maximum number of items to be included in each pagination request.
+ init(limit: Int) {
+ self.limit = limit
+ }
+
+ // MARK: IRequestBuilderStrategy
+
+ /// Constructs a pagination request based on the provided state.
+ ///
+ /// - Parameter state: The current state of the paginator.
+ ///
+ /// - Returns: An OffsetPaginationRequest with the offset position and specified limit from the provided state.
+ func makeRequest(state: PaginatorState) -> OffsetPaginationRequest {
+ OffsetPaginationRequest(limit: limit, offset: state.position)
+ }
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/Protocols/IRequestBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/Protocols/IRequestBuilderStrategy.swift
new file mode 100644
index 0000000..c11a87b
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/Protocols/IRequestBuilderStrategy.swift
@@ -0,0 +1,22 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// A protocol for defining request builder strategies in a paginator.
+protocol IRequestBuilderStrategy {
+ /// The state type associated with the strategy, conforming to Equatable.
+ associatedtype State: Equatable
+
+ /// The request type associated with the strategy, conforming to Equatable.
+ associatedtype Request: Equatable
+
+ /// Makes a request.
+ ///
+ /// - Parameter state: The current state for which a request needs to be built.
+ ///
+ /// - Returns: The constructed request based on the provided state.
+ func makeRequest(state: State) -> Request
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorIntegrationReducer.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorIntegrationReducer.swift
new file mode 100644
index 0000000..a05aa9a
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorIntegrationReducer.swift
@@ -0,0 +1,50 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+import ComposableArchitecture
+
+struct PaginatorIntegrationReducer<
+ Parent: Reducer,
+ State: Equatable & Identifiable,
+ Action: Equatable,
+ PositionType: Equatable,
+ Request: Equatable
+>: Reducer {
+ // MARK: Properties
+
+ let parent: Parent
+ let childState: WritableKeyPath>
+ let childAction: AnyCasePath>
+ let loadPage: @Sendable (Request, Parent.State) async throws -> Page
+ let requestBuilderStrategy: any IRequestBuilderStrategy, Request>
+ let positionBuilderStrategy: any IPositionBuilderStrategy, PositionType>
+
+ private enum CancelID { case requestPage }
+
+ // MARK: Reducer
+
+ var body: some Reducer {
+ Scope(state: childState, action: childAction) {
+ PaginatorReducer(
+ requestBuilder: requestBuilderStrategy,
+ positionBuilder: positionBuilderStrategy
+ )
+ }
+
+ Reduce { state, action in
+ guard let paginatorAction = childAction.extract(from: action),
+ case let .requestPage(pageRequest) = paginatorAction
+ else {
+ return parent.reduce(into: &state, action: action)
+ }
+
+ return .run { [state] send in
+ await send(childAction.embed(.response(TaskResult { try await loadPage(pageRequest, state) })))
+ }
+ .cancellable(id: CancelID.requestPage, cancelInFlight: true)
+ }
+ }
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorReducer.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorReducer.swift
new file mode 100644
index 0000000..0797a40
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorReducer.swift
@@ -0,0 +1,71 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+import ComposableArchitecture
+
+struct PaginatorReducer<
+ State: Equatable & Identifiable,
+ Action: Equatable,
+ PositionType: Equatable,
+ Request: Equatable
+>: Reducer {
+ // MARK: Types
+
+ typealias State = PaginatorState
+ typealias Action = PaginatorAction
+
+ // MARK: Properties
+
+ private let requestBuilder: any IRequestBuilderStrategy
+ private let positionBuilder: any IPositionBuilderStrategy
+
+ // MARK: Initialization
+
+ init(
+ requestBuilder: any IRequestBuilderStrategy,
+ positionBuilder: any IPositionBuilderStrategy
+ ) {
+ self.requestBuilder = requestBuilder
+ self.positionBuilder = positionBuilder
+ }
+
+ // MARK: Reducer
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case let .itemAppeared(id):
+ guard state.items.last?.id == id else { return .none }
+ return fetchNextPage(&state)
+ case .requestPage:
+ state.isLoading = true
+ return fetchNextPage(&state)
+ case let .response(.success(page)):
+ state.isLoading = false
+
+ state.items.append(contentsOf: page.items)
+ state.hasMoreData = page.hasMoreData
+
+ state.position = positionBuilder.next(state: state)
+
+ return .none
+ case .response(.failure):
+ state.isLoading = false
+ return .none
+ }
+ }
+ }
+
+ // MARK: Private
+
+ private func fetchNextPage(_ state: inout Self.State) -> Effect {
+ guard !state.isLoading, state.hasMoreData else { return .none }
+ state.isLoading = true
+
+ let request: Request = requestBuilder.makeRequest(state: state)
+ return .send(.requestPage(request))
+ }
+}
diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Reducer+.swift b/Sources/BladeTCA/Classes/COre/Reducers/Reducer+.swift
new file mode 100644
index 0000000..093485e
--- /dev/null
+++ b/Sources/BladeTCA/Classes/COre/Reducers/Reducer+.swift
@@ -0,0 +1,60 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+import ComposableArchitecture
+
+// MARK: - Reducer Extension for Paginator Integration
+
+/// An extension on the `Reducer` type providing a method for integrating a paginator into a Composable Architecture.
+public extension Reducer {
+ /// Integrates a paginator into a Composable Architecture, facilitating paginated data loading.
+ ///
+ /// - Parameters:
+ /// - limit: The number of items to load per page, with a default value of 20.
+ /// - state: The key path to the paginator state.
+ /// - action: The case path to the paginator actions.
+ /// - loadPage: A closure to load a page of items based on the provided `LimitPageRequest` and current state.
+ ///
+ /// - Returns: A reducer for integrating the paginator functionality.
+ func paginator(
+ limit: Int = 20,
+ state: WritableKeyPath>,
+ action: AnyCasePath>,
+ loadPage: @Sendable @escaping (OffsetPaginationRequest, State) async throws -> Page
+ ) -> some Reducer {
+ PaginatorIntegrationReducer(
+ parent: self,
+ childState: state,
+ childAction: action,
+ loadPage: loadPage,
+ requestBuilderStrategy: OffsetRequestBuilderStrategy(limit: limit),
+ positionBuilderStrategy: OffsetPositionBuilderStrategy()
+ )
+ }
+
+ /// Integrates a paginator into a Composable Architecture, facilitating paginated data loading with cursor-based pagination.
+ ///
+ /// - Parameters:
+ /// - state: The key path to the paginator state.
+ /// - action: The case path to the paginator actions.
+ /// - loadPage: A closure to load a page of items based on the provided `CursorPaginationRequest` and current state.
+ ///
+ /// - Returns: A reducer for integrating the paginator functionality.
+ func paginator(
+ state: WritableKeyPath>,
+ action: AnyCasePath>>,
+ loadPage: @Sendable @escaping (CursorPaginationRequest, State) async throws -> Page
+ ) -> some Reducer {
+ PaginatorIntegrationReducer(
+ parent: self,
+ childState: state,
+ childAction: action,
+ loadPage: loadPage,
+ requestBuilderStrategy: CursorRequestBuilderStrategy(),
+ positionBuilderStrategy: CursorPositionBuilderStrategy()
+ )
+ }
+}
diff --git a/Sources/BladeTCA/Classes/Models/PaginatorAction.swift b/Sources/BladeTCA/Classes/Models/PaginatorAction.swift
new file mode 100644
index 0000000..5a9ad48
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Models/PaginatorAction.swift
@@ -0,0 +1,32 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+import ComposableArchitecture
+import Foundation
+
+// MARK: - PaginatorAction
+
+/// Represents the actions that can be performed on a paginator in a Composable Architecture.
+///
+/// - Parameters:
+/// - State: The type of state managed by the paginator.
+/// - Action: The type of actions that can be associated with the paginator.
+public enum PaginatorAction<
+ State: Equatable & Identifiable,
+ Action: Equatable,
+ Request: Equatable
+>: Equatable {
+ // MARK: Action Cases
+
+ /// Indicates that an item with the specified identifier has appeared in the UI.
+ case itemAppeared(State.ID)
+
+ /// Represents a request to load the next page of items using the provided `RequestType`.
+ case requestPage(Request)
+
+ /// Represents the response to a page request, containing the result of the operation.
+ case response(TaskResult>)
+}
diff --git a/Sources/BladeTCA/Classes/Models/State/CursorPaginatorState.swift b/Sources/BladeTCA/Classes/Models/State/CursorPaginatorState.swift
new file mode 100644
index 0000000..ac4a397
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Models/State/CursorPaginatorState.swift
@@ -0,0 +1,36 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import ComposableArchitecture
+
+/// Represents the state of a paginator for cursor-based pagination.
+public struct CursorPaginatorState: Equatable, IPaginatorState {
+ // MARK: Properties
+
+ /// The array of identifiable items managed by the paginator.
+ public var items: IdentifiedArrayOf
+
+ /// A Boolean value indicating whether the paginator is currently loading more data.
+ var isLoading: Bool
+
+ /// The offset or position in the data set from where the paginator should load more items.
+ var id: State.ID
+
+ /// A Boolean value indicating whether there is more data available to be loaded.
+ var hasMoreData: Bool
+
+ // MARK: Initialization
+
+ /// Initializes a paginator state with an array of identifiable items.
+ ///
+ /// - Parameters:
+ /// - items: The array of identifiable items to be managed by the paginator.
+ public init(items: IdentifiedArrayOf, id: State.ID) {
+ self.items = items
+ isLoading = false
+ hasMoreData = true
+ self.id = id
+ }
+}
diff --git a/Sources/BladeTCA/Classes/Models/State/PaginatorState.swift b/Sources/BladeTCA/Classes/Models/State/PaginatorState.swift
new file mode 100644
index 0000000..e9d4285
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Models/State/PaginatorState.swift
@@ -0,0 +1,39 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import ComposableArchitecture
+import Foundation
+
+// MARK: - OffsetPaginatorState
+
+/// Represents the state of a paginator for cursor-based pagination.
+public struct PaginatorState: Equatable, IPaginatorState {
+ // MARK: Properties
+
+ /// The array of identifiable items managed by the paginator.
+ public var items: IdentifiedArrayOf
+
+ /// A Boolean value indicating whether the paginator is currently loading more data.
+ var isLoading: Bool
+
+ /// The offset or position in the data set from where the paginator should load more items.
+ var position: T
+
+ /// A Boolean value indicating whether there is more data available to be loaded.
+ var hasMoreData: Bool
+
+ // MARK: Initialization
+
+ /// Initializes a paginator state with an array of identifiable items.
+ ///
+ /// - Parameters:
+ /// - items: The array of identifiable items to be managed by the paginator.
+ public init(items: IdentifiedArrayOf, position: T) {
+ self.items = items
+ isLoading = false
+ hasMoreData = true
+ self.position = position
+ }
+}
diff --git a/Sources/BladeTCA/Classes/Models/State/Protocols/IPaginatorState.swift b/Sources/BladeTCA/Classes/Models/State/Protocols/IPaginatorState.swift
new file mode 100644
index 0000000..a543def
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Models/State/Protocols/IPaginatorState.swift
@@ -0,0 +1,14 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import ComposableArchitecture
+
+protocol IPaginatorState {
+ associatedtype Element: Equatable & Identifiable
+
+ var items: IdentifiedArrayOf { get set }
+ var isLoading: Bool { get set }
+ var hasMoreData: Bool { get set }
+}
diff --git a/Sources/BladeTCA/Classes/Presentation/ViewModifiers/LoadingViewModifier.swift b/Sources/BladeTCA/Classes/Presentation/ViewModifiers/LoadingViewModifier.swift
new file mode 100644
index 0000000..ae29da8
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Presentation/ViewModifiers/LoadingViewModifier.swift
@@ -0,0 +1,33 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import SwiftUI
+
+struct LoadingViewModifier: ViewModifier {
+ // MARK: Properties
+
+ let isLoading: Bool
+
+ // MARK: ViewModifier
+
+ func body(content: Content) -> some View {
+ VStack {
+ content
+
+ if isLoading {
+ progressView
+ }
+ }
+ }
+
+ // MARK: Private
+
+ private var progressView: some View {
+ if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
+ return ProgressView().progressViewStyle(.circular)
+ }
+ return EmptyView()
+ }
+}
diff --git a/Sources/BladeTCA/Classes/Presentation/Views/PaginatorForEachView.swift b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorForEachView.swift
new file mode 100644
index 0000000..865ed87
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorForEachView.swift
@@ -0,0 +1,46 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import ComposableArchitecture
+import SwiftUI
+
+public struct PaginatorForEachView<
+ State: Equatable & Identifiable,
+ Action: Equatable,
+ Body: View,
+ PositionType: Equatable,
+ Request: Equatable
+>: View {
+ // MARK: Types
+
+ private typealias StoreType = ViewStoreOf>
+
+ // MARK: Properties
+
+ public let store: Store, PaginatorAction>
+ public let content: (State) -> Body
+
+ public init(
+ store: Store, PaginatorAction>,
+ content: @escaping (State) -> Body
+ ) {
+ self.store = store
+ self.content = content
+ }
+
+ // MARK: View
+
+ public var body: some View {
+ WithViewStore(store, observe: { $0 }) { (viewStore: StoreType) in
+ ForEach(viewStore.items) { item in
+ content(item)
+ .onAppear {
+ viewStore.send(.itemAppeared(item.id))
+ }
+ }
+ .modifier(LoadingViewModifier(isLoading: viewStore.isLoading && !viewStore.items.isEmpty))
+ }
+ }
+}
diff --git a/Sources/BladeTCA/Classes/Presentation/Views/PaginatorListView.swift b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorListView.swift
new file mode 100644
index 0000000..9ff9cca
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorListView.swift
@@ -0,0 +1,47 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import BladeTCA
+import ComposableArchitecture
+import SwiftUI
+
+public struct PaginatorListView<
+ State: Equatable & Identifiable,
+ Action: Equatable,
+ Body: View,
+ PositionType: Equatable,
+ Request: Equatable
+>: View {
+ // MARK: Types
+
+ private typealias StoreType = ViewStoreOf>
+
+ // MARK: Properties
+
+ public let store: Store, PaginatorAction>
+ public let content: (State) -> Body
+
+ public init(
+ store: Store, PaginatorAction>,
+ content: @escaping (State) -> Body
+ ) {
+ self.store = store
+ self.content = content
+ }
+
+ // MARK: View
+
+ public var body: some View {
+ WithViewStore(store, observe: { $0 }) { (viewStore: StoreType) in
+ List(viewStore.items) { item in
+ content(item)
+ .onAppear {
+ viewStore.send(.itemAppeared(item.id))
+ }
+ }
+ .modifier(LoadingViewModifier(isLoading: viewStore.isLoading && !viewStore.items.isEmpty))
+ }
+ }
+}
diff --git a/Sources/BladeTCA/Classes/Presentation/Views/PaginatorView.swift b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorView.swift
new file mode 100644
index 0000000..fadfde8
--- /dev/null
+++ b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorView.swift
@@ -0,0 +1,62 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import BladeTCA
+import ComposableArchitecture
+import SwiftUI
+
+// MARK: - PaginatorView
+
+public struct PaginatorView<
+ State: Equatable & Identifiable,
+ Action: Equatable,
+ PositionType: Equatable,
+ Request: Equatable,
+ Body: View,
+ RowContent: View
+>: View {
+ // MARK: Types
+
+ private typealias StoreType = ViewStoreOf>
+
+ // MARK: Properties
+
+ public let store: Store, PaginatorAction>
+ public let content: ([State], @escaping (State) -> AnyView) -> Body
+ public let rowContent: (State) -> RowContent
+
+ // MARK: Initialization
+
+ public init(
+ store: Store, PaginatorAction>,
+ @ViewBuilder content: @escaping ([State], @escaping (State) -> AnyView) -> Body,
+ @ViewBuilder rowContent: @escaping (State) -> RowContent
+ ) {
+ self.store = store
+ self.content = content
+ self.rowContent = rowContent
+ }
+
+ // MARK: View
+
+ public var body: some View {
+ WithViewStore(store, observe: { $0 }) { (viewStore: StoreType) in
+ content(viewStore.items.elements) { item in
+ rowContent(item)
+ .onAppear { viewStore.send(.itemAppeared(item.id)) }
+ .any
+ }
+ .modifier(LoadingViewModifier(isLoading: viewStore.isLoading && !viewStore.items.isEmpty))
+ }
+ }
+}
+
+// MARK: Extension
+
+private extension View {
+ var any: AnyView {
+ AnyView(self)
+ }
+}
diff --git a/Tests/BladePackage.xctestplan b/Tests/BladePackage.xctestplan
new file mode 100644
index 0000000..3033db4
--- /dev/null
+++ b/Tests/BladePackage.xctestplan
@@ -0,0 +1,44 @@
+{
+ "configurations" : [
+ {
+ "id" : "9BC7C122-0678-48E3-A9A8-0D03B540050E",
+ "name" : "Test Scheme Action",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "codeCoverage" : {
+ "targets" : [
+ {
+ "containerPath" : "container:",
+ "identifier" : "Blade",
+ "name" : "Blade"
+ },
+ {
+ "containerPath" : "container:",
+ "identifier" : "BladeTCA",
+ "name" : "BladeTCA"
+ }
+ ]
+ }
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:",
+ "identifier" : "BladeTCATests",
+ "name" : "BladeTCATests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:",
+ "identifier" : "BladeTests",
+ "name" : "BladeTests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/Tests/BladeTCATests/Helpers/IdentifiedArray+Items.swift b/Tests/BladeTCATests/Helpers/IdentifiedArray+Items.swift
new file mode 100644
index 0000000..4fffc34
--- /dev/null
+++ b/Tests/BladeTCATests/Helpers/IdentifiedArray+Items.swift
@@ -0,0 +1,14 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import ComposableArchitecture
+import Foundation
+
+extension IdentifiedArray where Element == TestItem, ID == UUID {
+ static func items(count: Int) -> IdentifiedArray {
+ let elements: [Element] = Array(0 ..< count).map { _ in Element() }
+ return IdentifiedArray(uniqueElements: elements)
+ }
+}
diff --git a/Tests/BladeTCATests/Models/TestItem.swift b/Tests/BladeTCATests/Models/TestItem.swift
new file mode 100644
index 0000000..35dbef3
--- /dev/null
+++ b/Tests/BladeTCATests/Models/TestItem.swift
@@ -0,0 +1,28 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+// MARK: - TestItem
+
+struct TestItem: Equatable, Identifiable {
+ // MARK: Properties
+
+ let id: UUID
+
+ // MARK: Initialization
+
+ init(id: UUID = UUID()) {
+ self.id = id
+ }
+}
+
+// MARK: - UUID + Identifiable
+
+extension UUID: Identifiable {
+ public var id: Self {
+ self
+ }
+}
diff --git a/Tests/BladeTCATests/UnitTests/PaginatorIntegrationReducerTests.swift b/Tests/BladeTCATests/UnitTests/PaginatorIntegrationReducerTests.swift
new file mode 100644
index 0000000..0fe9934
--- /dev/null
+++ b/Tests/BladeTCATests/UnitTests/PaginatorIntegrationReducerTests.swift
@@ -0,0 +1,108 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+@testable import BladeTCA
+import ComposableArchitecture
+import XCTest
+
+// MARK: - PaginatorIntegrationReducerTests
+
+@MainActor
+final class PaginatorIntegrationReducerTests: XCTestCase {
+ // MARK: Tests
+
+ func test_thatPaginatorUpdatesData_whenRequestDidCompleteSuccessfully() async {
+ let items: IdentifiedArray = .items(count: .limit)
+ let pageResponse = Page(items: items.elements, hasMoreData: true)
+
+ let store = TestStore(
+ initialState: PaginatorIntegrationReducer
+ .State(paginator: .init(items: items, position: .limit))
+ ) {
+ PaginatorIntegrationReducer(
+ parent: TestReducer(),
+ childState: \TestReducer.State.paginator,
+ childAction: /TestReducer.Action.child,
+ loadPage: { _, _ in pageResponse },
+ requestBuilderStrategy: OffsetRequestBuilderStrategy(limit: .limit),
+ positionBuilderStrategy: OffsetPositionBuilderStrategy()
+ )
+ }
+
+ // 1. Last item did appear
+ await store.send(.child(.itemAppeared(items[items.count - 1].id))) {
+ $0.paginator.isLoading = true
+ }
+
+ // 2. Send a request
+ await store.receive(.child(.requestPage(OffsetPaginationRequest(limit: .limit, offset: .limit))))
+
+ // 3. Receive a response
+ await store.receive(.child(.response(.success(pageResponse)))) {
+ $0.paginator.isLoading = false
+ $0.paginator.hasMoreData = true
+ $0.paginator.position = .limit
+ }
+ }
+
+ func test_thatPaginatorDoesNotUpdateData_whenRequestDidFailed() async {
+ let items: IdentifiedArray = .items(count: .limit)
+
+ let store = TestStore(
+ initialState: PaginatorIntegrationReducer
+ .State(paginator: .init(items: items, position: .limit))
+ ) {
+ PaginatorIntegrationReducer(
+ parent: TestReducer(),
+ childState: \TestReducer.State.paginator,
+ childAction: /TestReducer.Action.child,
+ loadPage: { _, _ in throw URLError(.unknown) },
+ requestBuilderStrategy: OffsetRequestBuilderStrategy(limit: .limit),
+ positionBuilderStrategy: OffsetPositionBuilderStrategy()
+ )
+ }
+
+ await store.send(.child(.itemAppeared(items[items.count - 1].id))) {
+ $0.paginator.isLoading = true
+ }
+
+ await store.receive(.child(.requestPage(OffsetPaginationRequest(limit: .limit, offset: .limit))))
+ await store.receive(.child(.response(.failure(URLError(.unknown))))) {
+ $0.paginator.isLoading = false
+ $0.paginator.hasMoreData = true
+ }
+ }
+}
+
+// MARK: PaginatorIntegrationReducerTests.TestReducer
+
+private extension PaginatorIntegrationReducerTests {
+ @Reducer
+ struct TestReducer {
+ struct State: Equatable {
+ var paginator: PaginatorState
+ }
+
+ enum Action: Equatable {
+ case child(BladeTCA.PaginatorAction)
+ }
+
+ var body: some ReducerOf {
+ Reduce { _, action in
+ switch action {
+ case .child:
+ return .none
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Constants
+
+private extension Int {
+ static let limit = 10
+}
diff --git a/Tests/BladeTCATests/UnitTests/PaginatorReducerTests.swift b/Tests/BladeTCATests/UnitTests/PaginatorReducerTests.swift
new file mode 100644
index 0000000..dd4a1bb
--- /dev/null
+++ b/Tests/BladeTCATests/UnitTests/PaginatorReducerTests.swift
@@ -0,0 +1,105 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+@testable import BladeTCA
+import ComposableArchitecture
+import XCTest
+
+// MARK: - PaginatorReducerTests
+
+@MainActor
+final class PaginatorReducerTests: XCTestCase {
+ // MARK: Types
+
+ private typealias PaginatorAction = BladeTCA.PaginatorAction
+
+ // MARK: Tests
+
+ func test_thatPaginatorOffsetRequestsNextPage_whenLastItemDidAppear() async {
+ let items: IdentifiedArray = .items(count: .limit)
+
+ let store = TestStore(
+ initialState: PaginatorReducer.State(
+ items: items, position: .limit
+ )
+ ) {
+ PaginatorReducer(
+ requestBuilder: OffsetRequestBuilderStrategy(limit: .limit),
+ positionBuilder: OffsetPositionBuilderStrategy()
+ )
+ }
+
+ await store.send(.itemAppeared(items[.limit - 1].id)) {
+ $0.isLoading = true
+ }
+
+ await store.receive(.requestPage(OffsetPaginationRequest(limit: .limit, offset: .limit)))
+ }
+
+ func test_thatPaginatorOffsetDoesNotRequestNextPage_whenLastItemDidNotAppear() async {
+ let items: IdentifiedArray = .items(count: .limit)
+
+ let store = TestStore(initialState: PaginatorReducer.State(
+ items: items,
+ position: .zero
+ )) {
+ PaginatorReducer(
+ requestBuilder: OffsetRequestBuilderStrategy(limit: .limit),
+ positionBuilder: OffsetPositionBuilderStrategy()
+ )
+ }
+
+ await store.send(.itemAppeared(items[0].id))
+ }
+
+ func test_thatPaginatorCursorRequestsNextPage_whenLastItemDidAppear() async throws {
+ let items: IdentifiedArray = .items(count: .limit)
+ let id = try XCTUnwrap(items.last?.id)
+
+ let store = TestStore(
+ initialState: PaginatorReducer>.State(
+ items: items, position: id
+ )
+ ) {
+ PaginatorReducer>(
+ requestBuilder: CursorRequestBuilderStrategy(),
+ positionBuilder: CursorPositionBuilderStrategy()
+ )
+ }
+
+ await store.send(.itemAppeared(items[.limit - 1].id)) {
+ $0.isLoading = true
+ }
+
+ await store.receive(.requestPage(CursorPaginationRequest(id: id)))
+ }
+
+ func test_thatPaginatorCursorDoesNotRequestNextPage_whenLastItemDidNotAppear() async {
+ let items: IdentifiedArray = .items(count: .limit)
+
+ let store = TestStore(
+ initialState: PaginatorReducer>.State(
+ items: items,
+ position: items[0].id
+ )
+ ) {
+ PaginatorReducer>(
+ requestBuilder: CursorRequestBuilderStrategy(),
+ positionBuilder: CursorPositionBuilderStrategy()
+ )
+ }
+
+ await store.send(.itemAppeared(items[0].id))
+ }
+}
+
+// MARK: - Constants
+
+private extension Int {
+ static let limit = 10
+}
+
+/// https://engineering.monstar-lab.com/en/post/2023/10/26/The-Composable-architecture-and-TDD/
diff --git a/Tests/BladeTests/BladeTests.swift b/Tests/BladeTests/BladeTests.swift
deleted file mode 100644
index e3f7faa..0000000
--- a/Tests/BladeTests/BladeTests.swift
+++ /dev/null
@@ -1,8 +0,0 @@
-//
-// Blade
-// Copyright © 2024 Space Code. All rights reserved.
-//
-
-import XCTest
-
-final class BladeTests: XCTestCase {}
diff --git a/Tests/BladeTests/Mocks/CursorPageLoaderMock.swift b/Tests/BladeTests/Mocks/CursorPageLoaderMock.swift
new file mode 100644
index 0000000..a345a9e
--- /dev/null
+++ b/Tests/BladeTests/Mocks/CursorPageLoaderMock.swift
@@ -0,0 +1,22 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+
+final class CursorPageLoaderMock: ICursorPageLoader {
+ var invokedLoadPage = false
+ var invokedLoadPageCount = 0
+ var invokedLoadPageParameters: (request: CursorPaginationRequest, Void)?
+ var invokedLoadPageParametersList = [(request: CursorPaginationRequest, Void)]()
+ var stubbedLoadPage: Page!
+
+ func loadPage(request: CursorPaginationRequest) async throws -> Page {
+ invokedLoadPage = true
+ invokedLoadPageCount += 1
+ invokedLoadPageParameters = (request, ())
+ invokedLoadPageParametersList.append((request, ()))
+ return stubbedLoadPage
+ }
+}
diff --git a/Tests/BladeTests/Mocks/OffsetPageLoaderMock.swift b/Tests/BladeTests/Mocks/OffsetPageLoaderMock.swift
new file mode 100644
index 0000000..1d80496
--- /dev/null
+++ b/Tests/BladeTests/Mocks/OffsetPageLoaderMock.swift
@@ -0,0 +1,22 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+
+final class OffsetPageLoaderMock: IOffsetPageLoader {
+ var invokedLoadPage = false
+ var invokedLoadPageCount = 0
+ var invokedLoadPageParameters: (request: OffsetPaginationRequest, Void)?
+ var invokedLoadPageParametersList = [(request: OffsetPaginationRequest, Void)]()
+ var stubbedLoadPage: Page!
+
+ func loadPage(request: OffsetPaginationRequest) async throws -> Page {
+ invokedLoadPage = true
+ invokedLoadPageCount += 1
+ invokedLoadPageParameters = (request, ())
+ invokedLoadPageParametersList.append((request, ()))
+ return stubbedLoadPage
+ }
+}
diff --git a/Tests/BladeTests/Models/TestItem.swift b/Tests/BladeTests/Models/TestItem.swift
new file mode 100644
index 0000000..0587bf7
--- /dev/null
+++ b/Tests/BladeTests/Models/TestItem.swift
@@ -0,0 +1,18 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Foundation
+
+struct TestItem: Equatable, Identifiable, Decodable {
+ // MARK: Properties
+
+ let id: UUID
+
+ // MARK: Initialization
+
+ init(id: UUID = UUID()) {
+ self.id = id
+ }
+}
diff --git a/Tests/BladeTests/UnitTests/CursorPaginatorTests.swift b/Tests/BladeTests/UnitTests/CursorPaginatorTests.swift
new file mode 100644
index 0000000..4b96306
--- /dev/null
+++ b/Tests/BladeTests/UnitTests/CursorPaginatorTests.swift
@@ -0,0 +1,101 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+import XCTest
+
+// MARK: - CursorPaginatorTests
+
+final class CursorPaginatorTests: XCTestCase {
+ // MARK: Properties
+
+ private var pageLoaderMock: CursorPageLoaderMock!
+
+ // MARK: XCTestCase
+
+ override func setUp() {
+ super.setUp()
+ pageLoaderMock = CursorPageLoaderMock()
+ }
+
+ override func tearDown() {
+ pageLoaderMock = nil
+ super.tearDown()
+ }
+
+ // MARK: Tests
+
+ func test_thatPaginatorLoadsNextPage() async throws {
+ // given
+ let sut = prepareSut()
+
+ pageLoaderMock.stubbedLoadPage = .fake()
+
+ // when
+ _ = try await sut.loadNextPage()
+ _ = try await sut.loadNextPage()
+
+ // then
+ var request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[0].request)
+ XCTAssertEqual(request.id, .id)
+
+ request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[1].request)
+ XCTAssertNotEqual(request.id, .id)
+ }
+
+ func test_thatPaginatorRefreshesState() async throws {
+ // given
+ let sut = prepareSut()
+
+ pageLoaderMock.stubbedLoadPage = .fake()
+
+ // when
+ _ = try await sut.refresh()
+
+ // then
+ let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request)
+ XCTAssertEqual(request.id, .id)
+ }
+
+ func test_thatPagaginatorResetsState() async throws {
+ // given
+ let sut = prepareSut()
+
+ pageLoaderMock.stubbedLoadPage = .fake()
+
+ // when
+ _ = try await sut.loadNextPage()
+ await sut.reset()
+ _ = try await sut.refresh()
+
+ // then
+ let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request)
+ XCTAssertEqual(request.id, .id)
+ }
+
+ // MARK: Private
+
+ private func prepareSut(id: UUID = .id) -> Paginator {
+ Paginator(
+ configuration: .init(id: id),
+ cursorPageLoader: pageLoaderMock
+ )
+ }
+}
+
+// MARK: - Constants
+
+private extension UUID {
+ static let id = UUID()
+}
+
+private extension Page where T == TestItem {
+ static func fake(numberOfItems: Int = 1, hasMoreData: Bool = true) -> Page {
+ Page(
+ items: Array(0 ..< numberOfItems).map { _ in TestItem(id: UUID()) },
+ hasMoreData: hasMoreData
+ )
+ }
+}
diff --git a/Tests/BladeTests/UnitTests/OffsetPaginatorTests.swift b/Tests/BladeTests/UnitTests/OffsetPaginatorTests.swift
new file mode 100644
index 0000000..b4bb884
--- /dev/null
+++ b/Tests/BladeTests/UnitTests/OffsetPaginatorTests.swift
@@ -0,0 +1,108 @@
+//
+// Blade
+// Copyright © 2024 Space Code. All rights reserved.
+//
+
+import Blade
+import XCTest
+
+// MARK: - OffsetPaginatorTests
+
+final class OffsetPaginatorTests: XCTestCase {
+ // MARK: Properties
+
+ private var pageLoaderMock: OffsetPageLoaderMock!
+
+ // MARK: XCTestCase
+
+ override func setUp() {
+ super.setUp()
+ pageLoaderMock = OffsetPageLoaderMock()
+ }
+
+ override func tearDown() {
+ pageLoaderMock = nil
+ super.tearDown()
+ }
+
+ // MARK: Tests
+
+ func test_thatPaginatorLoadsNextPage() async throws {
+ // given
+ let sut = prepareSut()
+
+ pageLoaderMock.stubbedLoadPage = .fake()
+
+ // when
+ _ = try await sut.loadNextPage()
+ _ = try await sut.loadNextPage()
+
+ // then
+ var request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[0].request)
+ XCTAssertEqual(request.offset, .limit)
+ XCTAssertEqual(request.limit, .limit)
+
+ request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[1].request)
+ XCTAssertEqual(request.offset, 2 * .limit)
+ XCTAssertEqual(request.limit, .limit)
+
+// let count = await sut.elements.count
+// XCTAssertEqual(count, 2 * .limit)
+ }
+
+ func test_thatPaginatorRefreshesState() async throws {
+ // given
+ let sut = prepareSut()
+
+ pageLoaderMock.stubbedLoadPage = .fake()
+
+ // when
+ _ = try await sut.refresh()
+
+ // then
+ let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request)
+ XCTAssertEqual(request.offset, .zero)
+ XCTAssertEqual(request.limit, .limit)
+ }
+
+ func test_thatPagaginatorResetsState() async throws {
+ // given
+ let sut = prepareSut()
+
+ pageLoaderMock.stubbedLoadPage = .fake()
+
+ // when
+ _ = try await sut.loadNextPage()
+ await sut.reset()
+ _ = try await sut.refresh()
+
+ // then
+ let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request)
+ XCTAssertEqual(request.offset, .zero)
+ XCTAssertEqual(request.limit, .limit)
+ }
+
+ // MARK: Private
+
+ private func prepareSut(firstPage: Int = .zero, limit: Int = .limit) -> Paginator {
+ Paginator(
+ configuration: .init(firstPage: firstPage, limit: limit),
+ offsetPageLoader: pageLoaderMock
+ )
+ }
+}
+
+// MARK: - Constants
+
+private extension Int {
+ static let limit = 10
+}
+
+private extension Page where T == TestItem {
+ static func fake(numberOfItems: Int = 1, hasMoreData: Bool = true) -> Page {
+ Page(
+ items: Array(0 ..< numberOfItems).map { _ in TestItem(id: UUID()) },
+ hasMoreData: hasMoreData
+ )
+ }
+}
diff --git a/hooks/pre-commit b/hooks/pre-commit
old mode 100644
new mode 100755