diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 5c461ac..232acca 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 017278612D7D83E700CE424F /* ItemTagData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785D2D7D83E700CE424F /* ItemTagData.swift */; }; 017278622D7D83E700CE424F /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785C2D7D83E700CE424F /* ItemTag.swift */; }; 017278632D7D83E700CE424F /* ItemTagState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785F2D7D83E700CE424F /* ItemTagState.swift */; }; + 4A8DA0DEF6F142C3A127058A /* PaginationMeta.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */; }; 017278642D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785E2D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift */; }; 017278652D7D83E700CE424F /* ItemTagType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278602D7D83E700CE424F /* ItemTagType.swift */; }; 017278682D7D83F600CE424F /* ScanState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278672D7D83F600CE424F /* ScanState.swift */; }; @@ -260,6 +261,7 @@ 0172785D2D7D83E700CE424F /* ItemTagData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagData.swift; sourceTree = ""; }; 0172785E2D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagInfoFromNdefMessage.swift; sourceTree = ""; }; 0172785F2D7D83E700CE424F /* ItemTagState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagState.swift; sourceTree = ""; }; + 2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationMeta.swift; sourceTree = ""; }; 017278602D7D83E700CE424F /* ItemTagType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagType.swift; sourceTree = ""; }; 017278672D7D83F600CE424F /* ScanState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanState.swift; sourceTree = ""; }; 0172786A2D7D840A00CE424F /* ShowTagInfoScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowTagInfoScanResult.swift; sourceTree = ""; }; @@ -587,6 +589,7 @@ 0172785F2D7D83E700CE424F /* ItemTagState.swift */, 017278602D7D83E700CE424F /* ItemTagType.swift */, 01B526532AF4E36400655131 /* MainTab.swift */, + 2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */, 017278082D7D4F7400CE424F /* Onboarding.swift */, 017278672D7D83F600CE424F /* ScanState.swift */, 01B526552AF4E82A00655131 /* ScrollToTopID.swift */, @@ -1053,6 +1056,7 @@ 0199CD252E07510200109DC6 /* ItemTagRepositoryProtocol.swift in Sources */, 0199CD262E07510200109DC6 /* ShopRepositoryProtocol.swift in Sources */, 017278632D7D83E700CE424F /* ItemTagState.swift in Sources */, + 4A8DA0DEF6F142C3A127058A /* PaginationMeta.swift in Sources */, 017278642D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift in Sources */, 017278652D7D83E700CE424F /* ItemTagType.swift in Sources */, 0172046625AA82BF008FD63B /* MessageBarView.swift in Sources */, diff --git a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift index 2952501..9b5ac78 100644 --- a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift +++ b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift @@ -10,6 +10,8 @@ import SwiftUI var itemTags: [ItemTag] = [] var state: DataState = .initial + var paginationMeta: PaginationMeta? + var isLoadingMore = false required init(itemTagsService: ItemTagsService) { self.itemTagsService = itemTagsService @@ -37,7 +39,9 @@ import SwiftUI Task { @MainActor in do { - itemTags = try await itemTagsService.allItemTags(shopId: shopId) + let response = try await itemTagsService.allItemTags(shopId: shopId) + itemTags = response.itemTags + paginationMeta = response.paginationMeta state = .hasData } catch { state = .failed @@ -48,9 +52,60 @@ import SwiftUI } } + func reloadPage(shopId: String, page: Int) { + if Task.isCancelled { + return + } + + if state == .loading { + return + } + + state = .loading + + Task { @MainActor in + do { + let response = try await itemTagsService.allItemTags(shopId: shopId, page: page) + itemTags = response.itemTags + paginationMeta = response.paginationMeta + state = .hasData + } catch { + state = .failed + Failure + .fetch(from: Self.self, reason: error.codedDescription) + .log() + } + } + } + + func loadNextPage(shopId: String) { + guard let meta = paginationMeta, meta.hasMorePages else { return } + + if isLoadingMore { + return + } + + isLoadingMore = true + + Task { @MainActor in + do { + let response = try await itemTagsService.allItemTags(shopId: shopId, page: meta.currentPage + 1) + itemTags.append(contentsOf: response.itemTags) + paginationMeta = response.paginationMeta + } catch { + Failure + .fetch(from: Self.self, reason: error.codedDescription) + .log() + } + + isLoadingMore = false + } + } + func fetchAll(shopId: String) async throws -> [ItemTag] { do { - itemTags = try await itemTagsService.allItemTags(shopId: shopId) + let response = try await itemTagsService.allItemTags(shopId: shopId) + itemTags = response.itemTags return itemTags } catch { Failure diff --git a/NativeAppTemplate/Data/Repositories/ItemTagRepositoryProtocol.swift b/NativeAppTemplate/Data/Repositories/ItemTagRepositoryProtocol.swift index c03abe3..c29e012 100644 --- a/NativeAppTemplate/Data/Repositories/ItemTagRepositoryProtocol.swift +++ b/NativeAppTemplate/Data/Repositories/ItemTagRepositoryProtocol.swift @@ -9,11 +9,15 @@ import SwiftUI var itemTags: [ItemTag] { get set } var state: DataState { get set } var isEmpty: Bool { get } + var paginationMeta: PaginationMeta? { get } + var isLoadingMore: Bool { get } init(itemTagsService: ItemTagsService) func findBy(id: String) -> ItemTag func reload(shopId: String) + func reloadPage(shopId: String, page: Int) + func loadNextPage(shopId: String) func fetchAll(shopId: String) async throws -> [ItemTag] func fetchDetail(id: String) async throws -> ItemTag func create(shopId: String, itemTag: ItemTag) async throws -> ItemTag diff --git a/NativeAppTemplate/Models/PaginationMeta.swift b/NativeAppTemplate/Models/PaginationMeta.swift new file mode 100644 index 0000000..d7f0e6f --- /dev/null +++ b/NativeAppTemplate/Models/PaginationMeta.swift @@ -0,0 +1,38 @@ +// +// PaginationMeta.swift +// NativeAppTemplate +// + +import Foundation + +struct PaginationMeta: Sendable { + let currentPage: Int + let totalPages: Int + let totalCount: Int + let limit: Int + + var hasMorePages: Bool { + currentPage < totalPages + } + + init(currentPage: Int, totalPages: Int, totalCount: Int, limit: Int) { + self.currentPage = currentPage + self.totalPages = totalPages + self.totalCount = totalCount + self.limit = limit + } + + init?(dictionary: [String: Any]) { + guard let currentPage = dictionary["current_page"] as? Int, + let totalPages = dictionary["total_pages"] as? Int, + let totalCount = dictionary["total_count"] as? Int, + let limit = dictionary["limit"] as? Int else { + return nil + } + + self.currentPage = currentPage + self.totalPages = totalPages + self.totalCount = totalCount + self.limit = limit + } +} diff --git a/NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift b/NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift index b0c3e72..9c41dc2 100644 --- a/NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift +++ b/NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift @@ -5,8 +5,13 @@ import Foundation +struct ItemTagsResponse: Sendable { + let itemTags: [ItemTag] + let paginationMeta: PaginationMeta? +} + struct GetItemTagsRequest: Request { - typealias Response = [ItemTag] + typealias Response = ItemTagsResponse // MARK: - Properties @@ -19,18 +24,27 @@ struct GetItemTagsRequest: Request { } var additionalHeaders: [String: String] = [:] + + var queryItems: [URLQueryItem] { + guard let page else { return [] } + return [URLQueryItem(name: "page", value: String(page))] + } + var body: Data? { nil } let shopId: String + let page: Int? // MARK: - Internal func handle(response: Data) throws -> Response { let json = try JSONSerialization.jsonObject(with: response) let doc = JSONAPIDocument(json) - return try doc.data.map { try ItemTagAdapter.process(resource: $0) } + let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) } + let paginationMeta = PaginationMeta(dictionary: doc.meta) + return ItemTagsResponse(itemTags: itemTags, paginationMeta: paginationMeta) } } diff --git a/NativeAppTemplate/Networking/Requests/Request.swift b/NativeAppTemplate/Networking/Requests/Request.swift index 31173a4..5cf101e 100644 --- a/NativeAppTemplate/Networking/Requests/Request.swift +++ b/NativeAppTemplate/Networking/Requests/Request.swift @@ -3,7 +3,7 @@ // NativeAppTemplate // -import struct Foundation.Data +import Foundation enum HTTPMethod: String { case GET @@ -19,6 +19,7 @@ protocol Request { var method: HTTPMethod { get } var path: String { get } var additionalHeaders: [String: String] { get } + var queryItems: [URLQueryItem] { get } var body: Data? { get } func handle(response: Data) throws -> Response @@ -30,6 +31,10 @@ extension Request { .GET } + var queryItems: [URLQueryItem] { + [] + } + var body: Data? { nil } diff --git a/NativeAppTemplate/Networking/Services/ItemTagsService.swift b/NativeAppTemplate/Networking/Services/ItemTagsService.swift index ff6414b..b144ac8 100644 --- a/NativeAppTemplate/Networking/Services/ItemTagsService.swift +++ b/NativeAppTemplate/Networking/Services/ItemTagsService.swift @@ -10,8 +10,8 @@ struct ItemTagsService: Service { extension ItemTagsService { // MARK: - Internal - func allItemTags(shopId: String) async throws -> GetItemTagsRequest.Response { - let request = GetItemTagsRequest(shopId: shopId) + func allItemTags(shopId: String, page: Int? = nil) async throws -> GetItemTagsRequest.Response { + let request = GetItemTagsRequest(shopId: shopId, page: page) return try await makeRequest(request: request) } diff --git a/NativeAppTemplate/Networking/Services/Service.swift b/NativeAppTemplate/Networking/Services/Service.swift index 7ce04db..b2f54ba 100644 --- a/NativeAppTemplate/Networking/Services/Service.swift +++ b/NativeAppTemplate/Networking/Services/Service.swift @@ -31,13 +31,17 @@ extension Service { pathURL = pathURL.appendingPathComponent(networkClient.environment.basePath) pathURL = pathURL.appendingPathComponent(request.path) - guard let components = URLComponents( + guard var components = URLComponents( url: pathURL, resolvingAgainstBaseURL: false ) else { throw URLError(.badURL) } + if !request.queryItems.isEmpty { + components.queryItems = request.queryItems + } + guard let url = components.url else { throw URLError(.badURL) } diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift index c601938..c809be8 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift @@ -55,24 +55,30 @@ private extension ItemTagListView { if viewModel.isEmpty { noResultsView } else { - List(viewModel.itemTags) { itemTag in - NavigationLink( - destination: ItemTagDetailView( - viewModel: viewModel.createItemTagDetailViewModel(itemTagId: itemTag.id) - ) - ) { - ItemTagListCardView( - itemTag: itemTag - ) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { viewModel.destroyItemTag(itemTagId: itemTag.id) } label: { - Label(String.delete, systemImage: "trash") - .labelStyle(.titleOnly) + List { + ForEach(viewModel.itemTags) { itemTag in + NavigationLink( + destination: ItemTagDetailView( + viewModel: viewModel.createItemTagDetailViewModel(itemTagId: itemTag.id) + ) + ) { + ItemTagListCardView( + itemTag: itemTag + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { viewModel.destroyItemTag(itemTagId: itemTag.id) } label: { + Label(String.delete, systemImage: "trash") + .labelStyle(.titleOnly) + } + .tint(.validationError) } - .tint(.validationError) } + .listRowBackground(Color.cardBackground.opacity(0.7)) + } + + if viewModel.hasMorePages { + loadMoreRow } - .listRowBackground(Color.cardBackground.opacity(0.7)) } .refreshable { viewModel.reload() @@ -102,6 +108,19 @@ private extension ItemTagListView { ) } + var loadMoreRow: some View { + HStack { + Spacer() + ProgressView() + .padding(NativeAppTemplateConstants.Spacing.xxs) + Spacer() + } + .listRowBackground(Color.clear) + .onAppear { + viewModel.loadMore() + } + } + var noResultsView: some View { VStack { Image(systemName: "01.square") diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift index e4019c1..5104afb 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift @@ -20,6 +20,18 @@ final class ItemTagListViewModel { itemTagRepository.itemTags } + var paginationMeta: PaginationMeta? { + itemTagRepository.paginationMeta + } + + var isLoadingMore: Bool { + itemTagRepository.isLoadingMore + } + + var hasMorePages: Bool { + paginationMeta?.hasMorePages ?? false + } + private let itemTagRepository: ItemTagRepositoryProtocol private let messageBus: MessageBus private let sessionController: SessionControllerProtocol @@ -46,7 +58,11 @@ final class ItemTagListViewModel { } func reload() { - itemTagRepository.reload(shopId: shop.id) + itemTagRepository.reloadPage(shopId: shop.id, page: 1) + } + + func loadMore() { + itemTagRepository.loadNextPage(shopId: shop.id) } func destroyItemTag(itemTagId: String) { diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift index fb8d165..6b55890 100644 --- a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift @@ -15,6 +15,9 @@ final class DemoItemTagRepository: ItemTagRepositoryProtocol { itemTags.isEmpty } + var paginationMeta: PaginationMeta? + var isLoadingMore = false + required init(itemTagsService: ItemTagsService) {} func findBy(id: String) -> ItemTag { @@ -30,6 +33,14 @@ final class DemoItemTagRepository: ItemTagRepositoryProtocol { state = .hasData } + func reloadPage(shopId: String, page: Int) { + reload(shopId: shopId) + } + + func loadNextPage(shopId: String) { + // No-op for demo + } + func fetchAll(shopId: String) async throws -> [ItemTag] { let allItemTags = fetchAll() return allItemTags.filter { $0.shopId == shopId } diff --git a/NativeAppTemplateTests/Models/PaginationMetaTest.swift b/NativeAppTemplateTests/Models/PaginationMetaTest.swift new file mode 100644 index 0000000..b51b565 --- /dev/null +++ b/NativeAppTemplateTests/Models/PaginationMetaTest.swift @@ -0,0 +1,69 @@ +// +// PaginationMetaTest.swift +// NativeAppTemplate +// + +@testable import NativeAppTemplate +import Testing + +@Suite +struct PaginationMetaTest { + @Test + func initWithValues() { + let meta = PaginationMeta(currentPage: 2, totalPages: 5, totalCount: 100, limit: 20) + #expect(meta.currentPage == 2) + #expect(meta.totalPages == 5) + #expect(meta.totalCount == 100) + #expect(meta.limit == 20) + } + + @Test + func hasMorePagesWhenNotOnLastPage() { + let meta = PaginationMeta(currentPage: 1, totalPages: 3, totalCount: 55, limit: 20) + #expect(meta.hasMorePages == true) + } + + @Test + func hasMorePagesIsFalseOnLastPage() { + let meta = PaginationMeta(currentPage: 3, totalPages: 3, totalCount: 55, limit: 20) + #expect(meta.hasMorePages == false) + } + + @Test + func hasMorePagesIsFalseWhenSinglePage() { + let meta = PaginationMeta(currentPage: 1, totalPages: 1, totalCount: 5, limit: 20) + #expect(meta.hasMorePages == false) + } + + @Test + func initFromValidDictionary() { + let dictionary: [String: Any] = [ + "current_page": 2, "total_pages": 5, "total_count": 100, "limit": 20 + ] + let meta = PaginationMeta(dictionary: dictionary) + #expect(meta != nil) + #expect(meta?.currentPage == 2) + #expect(meta?.totalPages == 5) + #expect(meta?.totalCount == 100) + #expect(meta?.limit == 20) + } + + @Test + func initFromDictionaryMissingKeysReturnsNil() { + let dictionary: [String: Any] = ["current_page": 1, "total_pages": 3] + #expect(PaginationMeta(dictionary: dictionary) == nil) + } + + @Test + func initFromEmptyDictionaryReturnsNil() { + #expect(PaginationMeta(dictionary: [:]) == nil) + } + + @Test + func initFromDictionaryWithWrongTypesReturnsNil() { + let dictionary: [String: Any] = [ + "current_page": "1", "total_pages": "3", "total_count": "55", "limit": "20" + ] + #expect(PaginationMeta(dictionary: dictionary) == nil) + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestItemTagRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestItemTagRepository.swift index f063cdf..ea3813b 100644 --- a/NativeAppTemplateTests/Testing/Repositories/TestItemTagRepository.swift +++ b/NativeAppTemplateTests/Testing/Repositories/TestItemTagRepository.swift @@ -14,6 +14,9 @@ final class TestItemTagRepository: ItemTagRepositoryProtocol { itemTags.isEmpty } + var paginationMeta: PaginationMeta? + var isLoadingMore = false + /// A test-only var error: NativeAppTemplateAPIError? @@ -36,6 +39,26 @@ final class TestItemTagRepository: ItemTagRepositoryProtocol { state = .hasData } + func reloadPage(shopId: String, page: Int) { + guard error == nil else { + state = .failed + return + } + + state = .loading + state = .hasData + } + + func loadNextPage(shopId: String) { + guard error == nil else { + state = .failed + return + } + + isLoadingMore = true + isLoadingMore = false + } + func fetchAll(shopId: String) async throws -> [ItemTag] { guard error == nil else { state = .failed diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift index 3796eb8..85c5228 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift @@ -98,6 +98,10 @@ struct ItemTagListViewModelTest { @Test func reloadCallsRepositoryWithShopId() { + itemTagRepository.paginationMeta = PaginationMeta( + currentPage: 1, totalPages: 3, totalCount: 55, limit: 20 + ) + let viewModel = ItemTagListViewModel( itemTagRepository: itemTagRepository, messageBus: messageBus, @@ -112,6 +116,97 @@ struct ItemTagListViewModelTest { // After reload, state should change to .hasData (success case) #expect(itemTagRepository.state == .hasData) + + // Verify paginationMeta is accessible through viewModel + #expect(viewModel.paginationMeta?.currentPage == 1) + #expect(viewModel.paginationMeta?.totalPages == 3) + } + + @Test + func hasMorePagesReflectsPaginationMeta() { + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + // No pagination meta — should not have more pages + #expect(viewModel.hasMorePages == false) + + // Set pagination meta with more pages + itemTagRepository.paginationMeta = PaginationMeta( + currentPage: 1, totalPages: 3, totalCount: 55, limit: 20 + ) + #expect(viewModel.hasMorePages == true) + + // Set pagination meta on last page + itemTagRepository.paginationMeta = PaginationMeta( + currentPage: 3, totalPages: 3, totalCount: 55, limit: 20 + ) + #expect(viewModel.hasMorePages == false) + } + + @Test + func isLoadingMoreReflectsRepository() { + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + #expect(viewModel.isLoadingMore == false) + + itemTagRepository.isLoadingMore = true + #expect(viewModel.isLoadingMore == true) + + itemTagRepository.isLoadingMore = false + #expect(viewModel.isLoadingMore == false) + } + + @Test + func loadMoreCallsRepository() { + itemTagRepository.paginationMeta = PaginationMeta( + currentPage: 1, totalPages: 3, totalCount: 55, limit: 20 + ) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + #expect(viewModel.hasMorePages == true) + + viewModel.loadMore() + + // loadNextPage was called (isLoadingMore toggled back to false in test double) + #expect(itemTagRepository.isLoadingMore == false) + } + + @Test + func reloadAfterDestroyResetsToPage1() async { + itemTagRepository.setItemTags(itemTags: itemTags) + itemTagRepository.paginationMeta = PaginationMeta( + currentPage: 2, totalPages: 3, totalCount: 55, limit: 20 + ) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + let destroyTask = Task { + viewModel.destroyItemTag(itemTagId: "1") + } + await destroyTask.value + + // After destroy, reload is called which resets state + #expect(itemTagRepository.state == .hasData) } @Test