From 8ad1c2049b504873db19f429433ae8c073ae973f Mon Sep 17 00:00:00 2001 From: dadachi Date: Fri, 3 Apr 2026 16:31:08 +0900 Subject: [PATCH 1/2] Add pagination support for item tags list Add PaginationMeta model, query parameter support in networking layer, and infinite scroll in ItemTagListView. ShopDetailView continues fetching all items without pagination for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- NativeAppTemplate.xcodeproj/project.pbxproj | 4 + .../Data/Repositories/ItemTagRepository.swift | 59 ++++++- .../ItemTagRepositoryProtocol.swift | 4 + NativeAppTemplate/Models/PaginationMeta.swift | 38 +++++ .../Networking/Requests/ItemTagsRequest.swift | 18 +- .../Networking/Requests/Request.swift | 7 +- .../Networking/Services/ItemTagsService.swift | 4 +- .../Networking/Services/Service.swift | 6 +- .../ItemTag List/ItemTagListView.swift | 49 ++++-- .../ItemTag List/ItemTagListViewModel.swift | 18 +- .../Repositories/DemoItemTagRepository.swift | 11 ++ .../Models/PaginationMetaTest.swift | 69 ++++++++ .../Repositories/TestItemTagRepository.swift | 23 +++ .../ItemTagListViewModelTest.swift | 95 +++++++++++ doc/pagination-item-tags.md | 161 ++++++++++++++++++ 15 files changed, 542 insertions(+), 24 deletions(-) create mode 100644 NativeAppTemplate/Models/PaginationMeta.swift create mode 100644 NativeAppTemplateTests/Models/PaginationMetaTest.swift create mode 100644 doc/pagination-item-tags.md 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 diff --git a/doc/pagination-item-tags.md b/doc/pagination-item-tags.md new file mode 100644 index 0000000..38905c1 --- /dev/null +++ b/doc/pagination-item-tags.md @@ -0,0 +1,161 @@ +# Pagination for ItemTags Index API + +## Context + +The `GET /api/v1/shopkeeper/shops/{shop_id}/item_tags` endpoint currently returns all item tags without pagination. Adding Pagy pagination with backward-compatible behavior so existing clients continue working. + +## Current State + +- **Pagy 43** already installed and configured (`config/initializers/pagy.rb`: default limit 20) +- **`Pagy::Method`** already included in `Display::BaseController` — not yet in the shopkeeper API base controller +- **Response format:** JSON:API via `jsonapi-serializer` gem (`{ data: [...], included: [...] }`) +- **Neither iOS nor Android** clients send pagination params or parse pagination metadata + +## API Changes + +### Request + +New optional query parameter: +- `page` (integer) — page number, defaults to 1 + +When `page` param is present, returns 20 items per page (Pagy default). +When `page` param is absent, returns up to 1000 items (backward compat — remove once clients are updated). + +### Response + +New `meta` key added to top-level JSON:API response: + +```json +{ + "data": [...], + "included": [...], + "meta": { + "current_page": 1, + "total_pages": 3, + "total_count": 55, + "limit": 20 + } +} +``` + +## Backend Implementation (Rails API) + +### Files to modify + +1. **`app/controllers/api/v1/shopkeeper/base_controller.rb`** + - Add `include Pagy::Backend` + - Add private `pagy_meta(pagy)` helper + +2. **`app/controllers/api/v1/shopkeeper/item_tags_controller.rb`** + - Update `index` action to use `pagy()` with backward-compat limit logic + - Add `meta` option to serializer + +3. **`test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb`** + - Test pagination meta presence + - Test pagination with explicit page param + - Test overflow returns empty data + - Test backward compat (no page param returns large limit) + +4. **`docs/openapi.yaml`** + - Add `page` query parameter to item_tags index + - Add `meta` object to response schema + +### Code changes + +**base_controller.rb** — add after existing includes: +```ruby +include Pagy::Method + +# in private section: +def pagy_meta(pagy) + { + current_page: pagy.page, + total_pages: pagy.pages, + total_count: pagy.count, + limit: pagy.limit + } +end +``` + +**item_tags_controller.rb** — replace index: +```ruby +def index + authorize ItemTag + + @pagy, @item_tags = pagy( + @shop.item_tags.order(queue_number: :asc).includes(:shop), + limit: params[:page].present? ? Pagy::OPTIONS[:limit] : 1000 + ) + + options = {} + options[:include] = [:shop] + options[:meta] = pagy_meta(@pagy) + render json: ItemTagSerializer.new(@item_tags, options).serializable_hash +end +``` + +## iOS Client Changes + +### Usage of `GET /shops/{shop_id}/item_tags` + +This endpoint is used in two places: +1. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`** — item tag management list (should paginate) +2. **`UI/Shop Detail/ShopDetailView.swift`** — shop overview (should retrieve all item_tags, no `page` param) + +ShopDetailView should continue calling without `page` param to get all items (backward-compat limit 1000). Only ItemTagListView should send `page` param for paginated results. + +### Files to modify + +1. **`Networking/Requests/ItemTagsRequest.swift`** — `GetItemTagsRequest` + - Add optional `page` query parameter + +2. **`Networking/JSONAPI/JSONAPIDocument.swift`** (or create `PaginationMeta`) + - Parse `meta` from response into a pagination struct + +3. **`Models/PaginationMeta.swift`** (new) + - Struct: `currentPage`, `totalPages`, `totalCount`, `limit` + +4. **`Data/Repositories/ItemTagRepository.swift`** + - Update `reload(shopId:)` to accept optional page param + - Store pagination meta alongside item tags + - Add `loadMore(shopId:)` or `loadPage(shopId:page:)` method + +5. **`UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift`** + - Implement "load more" or infinite scroll logic + - Track current page and whether more pages exist + +6. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`** + - Add scroll-to-bottom trigger for loading next page + - Show loading indicator during pagination + +7. **`UI/Shop Detail/ShopDetailView.swift`** (or its ViewModel) + - No changes needed — continue calling without `page` param to get all items + +## Android Client Changes + +### Files to modify + +1. **`data/item_tag/ItemTagApi.kt`** + - Add `@Query("page") page: Int?` parameter to `getItemTags()` + +2. **`data/item_tag/model/Meta.kt`** (or new `PaginationMeta.kt`) + - Parse pagination fields from `meta` object (already has a `Meta` class — may need to add pagination fields) + +3. **`data/item_tag/ItemTagRepositoryImpl.kt`** + - Accept page parameter in fetch methods + - Store pagination state + +4. **`ui/shop_settings/item_tag_list/ItemTagListViewModel.kt`** + - Implement pagination state management + - Add `loadMore()` function + +5. **`ui/shop_settings/item_tag_list/ItemTagListScreen.kt`** (or equivalent composable) + - Add infinite scroll / load more UI + +## Migration Strategy + +1. Deploy API with backward-compat (large limit when no page param) — **do this first** +2. Update iOS and Android clients: + - ItemTagListView/Screen: send `page` param and handle `meta` for pagination + - ShopDetailView/Screen: keep calling without `page` param (gets all items) +3. The backward-compat large limit should remain long-term since ShopDetailView needs all items From a2fcca7132ab216998c26be41b4bc129e14d5647 Mon Sep 17 00:00:00 2001 From: dadachi Date: Fri, 3 Apr 2026 18:00:12 +0900 Subject: [PATCH 2/2] Remove pagination spec doc Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/pagination-item-tags.md | 161 ------------------------------------ 1 file changed, 161 deletions(-) delete mode 100644 doc/pagination-item-tags.md diff --git a/doc/pagination-item-tags.md b/doc/pagination-item-tags.md deleted file mode 100644 index 38905c1..0000000 --- a/doc/pagination-item-tags.md +++ /dev/null @@ -1,161 +0,0 @@ -# Pagination for ItemTags Index API - -## Context - -The `GET /api/v1/shopkeeper/shops/{shop_id}/item_tags` endpoint currently returns all item tags without pagination. Adding Pagy pagination with backward-compatible behavior so existing clients continue working. - -## Current State - -- **Pagy 43** already installed and configured (`config/initializers/pagy.rb`: default limit 20) -- **`Pagy::Method`** already included in `Display::BaseController` — not yet in the shopkeeper API base controller -- **Response format:** JSON:API via `jsonapi-serializer` gem (`{ data: [...], included: [...] }`) -- **Neither iOS nor Android** clients send pagination params or parse pagination metadata - -## API Changes - -### Request - -New optional query parameter: -- `page` (integer) — page number, defaults to 1 - -When `page` param is present, returns 20 items per page (Pagy default). -When `page` param is absent, returns up to 1000 items (backward compat — remove once clients are updated). - -### Response - -New `meta` key added to top-level JSON:API response: - -```json -{ - "data": [...], - "included": [...], - "meta": { - "current_page": 1, - "total_pages": 3, - "total_count": 55, - "limit": 20 - } -} -``` - -## Backend Implementation (Rails API) - -### Files to modify - -1. **`app/controllers/api/v1/shopkeeper/base_controller.rb`** - - Add `include Pagy::Backend` - - Add private `pagy_meta(pagy)` helper - -2. **`app/controllers/api/v1/shopkeeper/item_tags_controller.rb`** - - Update `index` action to use `pagy()` with backward-compat limit logic - - Add `meta` option to serializer - -3. **`test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb`** - - Test pagination meta presence - - Test pagination with explicit page param - - Test overflow returns empty data - - Test backward compat (no page param returns large limit) - -4. **`docs/openapi.yaml`** - - Add `page` query parameter to item_tags index - - Add `meta` object to response schema - -### Code changes - -**base_controller.rb** — add after existing includes: -```ruby -include Pagy::Method - -# in private section: -def pagy_meta(pagy) - { - current_page: pagy.page, - total_pages: pagy.pages, - total_count: pagy.count, - limit: pagy.limit - } -end -``` - -**item_tags_controller.rb** — replace index: -```ruby -def index - authorize ItemTag - - @pagy, @item_tags = pagy( - @shop.item_tags.order(queue_number: :asc).includes(:shop), - limit: params[:page].present? ? Pagy::OPTIONS[:limit] : 1000 - ) - - options = {} - options[:include] = [:shop] - options[:meta] = pagy_meta(@pagy) - render json: ItemTagSerializer.new(@item_tags, options).serializable_hash -end -``` - -## iOS Client Changes - -### Usage of `GET /shops/{shop_id}/item_tags` - -This endpoint is used in two places: -1. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`** — item tag management list (should paginate) -2. **`UI/Shop Detail/ShopDetailView.swift`** — shop overview (should retrieve all item_tags, no `page` param) - -ShopDetailView should continue calling without `page` param to get all items (backward-compat limit 1000). Only ItemTagListView should send `page` param for paginated results. - -### Files to modify - -1. **`Networking/Requests/ItemTagsRequest.swift`** — `GetItemTagsRequest` - - Add optional `page` query parameter - -2. **`Networking/JSONAPI/JSONAPIDocument.swift`** (or create `PaginationMeta`) - - Parse `meta` from response into a pagination struct - -3. **`Models/PaginationMeta.swift`** (new) - - Struct: `currentPage`, `totalPages`, `totalCount`, `limit` - -4. **`Data/Repositories/ItemTagRepository.swift`** - - Update `reload(shopId:)` to accept optional page param - - Store pagination meta alongside item tags - - Add `loadMore(shopId:)` or `loadPage(shopId:page:)` method - -5. **`UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift`** - - Implement "load more" or infinite scroll logic - - Track current page and whether more pages exist - -6. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`** - - Add scroll-to-bottom trigger for loading next page - - Show loading indicator during pagination - -7. **`UI/Shop Detail/ShopDetailView.swift`** (or its ViewModel) - - No changes needed — continue calling without `page` param to get all items - -## Android Client Changes - -### Files to modify - -1. **`data/item_tag/ItemTagApi.kt`** - - Add `@Query("page") page: Int?` parameter to `getItemTags()` - -2. **`data/item_tag/model/Meta.kt`** (or new `PaginationMeta.kt`) - - Parse pagination fields from `meta` object (already has a `Meta` class — may need to add pagination fields) - -3. **`data/item_tag/ItemTagRepositoryImpl.kt`** - - Accept page parameter in fetch methods - - Store pagination state - -4. **`ui/shop_settings/item_tag_list/ItemTagListViewModel.kt`** - - Implement pagination state management - - Add `loadMore()` function - -5. **`ui/shop_settings/item_tag_list/ItemTagListScreen.kt`** (or equivalent composable) - - Add infinite scroll / load more UI - -## Migration Strategy - -1. Deploy API with backward-compat (large limit when no page param) — **do this first** -2. Update iOS and Android clients: - - ItemTagListView/Screen: send `page` param and handle `meta` for pagination - - ShopDetailView/Screen: keep calling without `page` param (gets all items) -3. The backward-compat large limit should remain long-term since ShopDetailView needs all items