Skip to content

Commit 8ad1c20

Browse files
dadachiclaude
andcommitted
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) <noreply@anthropic.com>
1 parent af7412d commit 8ad1c20

15 files changed

Lines changed: 542 additions & 24 deletions

File tree

NativeAppTemplate.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
017278612D7D83E700CE424F /* ItemTagData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785D2D7D83E700CE424F /* ItemTagData.swift */; };
8383
017278622D7D83E700CE424F /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785C2D7D83E700CE424F /* ItemTag.swift */; };
8484
017278632D7D83E700CE424F /* ItemTagState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785F2D7D83E700CE424F /* ItemTagState.swift */; };
85+
4A8DA0DEF6F142C3A127058A /* PaginationMeta.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */; };
8586
017278642D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785E2D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift */; };
8687
017278652D7D83E700CE424F /* ItemTagType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278602D7D83E700CE424F /* ItemTagType.swift */; };
8788
017278682D7D83F600CE424F /* ScanState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278672D7D83F600CE424F /* ScanState.swift */; };
@@ -260,6 +261,7 @@
260261
0172785D2D7D83E700CE424F /* ItemTagData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagData.swift; sourceTree = "<group>"; };
261262
0172785E2D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagInfoFromNdefMessage.swift; sourceTree = "<group>"; };
262263
0172785F2D7D83E700CE424F /* ItemTagState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagState.swift; sourceTree = "<group>"; };
264+
2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationMeta.swift; sourceTree = "<group>"; };
263265
017278602D7D83E700CE424F /* ItemTagType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagType.swift; sourceTree = "<group>"; };
264266
017278672D7D83F600CE424F /* ScanState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanState.swift; sourceTree = "<group>"; };
265267
0172786A2D7D840A00CE424F /* ShowTagInfoScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowTagInfoScanResult.swift; sourceTree = "<group>"; };
@@ -587,6 +589,7 @@
587589
0172785F2D7D83E700CE424F /* ItemTagState.swift */,
588590
017278602D7D83E700CE424F /* ItemTagType.swift */,
589591
01B526532AF4E36400655131 /* MainTab.swift */,
592+
2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */,
590593
017278082D7D4F7400CE424F /* Onboarding.swift */,
591594
017278672D7D83F600CE424F /* ScanState.swift */,
592595
01B526552AF4E82A00655131 /* ScrollToTopID.swift */,
@@ -1053,6 +1056,7 @@
10531056
0199CD252E07510200109DC6 /* ItemTagRepositoryProtocol.swift in Sources */,
10541057
0199CD262E07510200109DC6 /* ShopRepositoryProtocol.swift in Sources */,
10551058
017278632D7D83E700CE424F /* ItemTagState.swift in Sources */,
1059+
4A8DA0DEF6F142C3A127058A /* PaginationMeta.swift in Sources */,
10561060
017278642D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift in Sources */,
10571061
017278652D7D83E700CE424F /* ItemTagType.swift in Sources */,
10581062
0172046625AA82BF008FD63B /* MessageBarView.swift in Sources */,

NativeAppTemplate/Data/Repositories/ItemTagRepository.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import SwiftUI
1010

1111
var itemTags: [ItemTag] = []
1212
var state: DataState = .initial
13+
var paginationMeta: PaginationMeta?
14+
var isLoadingMore = false
1315

1416
required init(itemTagsService: ItemTagsService) {
1517
self.itemTagsService = itemTagsService
@@ -37,7 +39,9 @@ import SwiftUI
3739

3840
Task { @MainActor in
3941
do {
40-
itemTags = try await itemTagsService.allItemTags(shopId: shopId)
42+
let response = try await itemTagsService.allItemTags(shopId: shopId)
43+
itemTags = response.itemTags
44+
paginationMeta = response.paginationMeta
4145
state = .hasData
4246
} catch {
4347
state = .failed
@@ -48,9 +52,60 @@ import SwiftUI
4852
}
4953
}
5054

55+
func reloadPage(shopId: String, page: Int) {
56+
if Task.isCancelled {
57+
return
58+
}
59+
60+
if state == .loading {
61+
return
62+
}
63+
64+
state = .loading
65+
66+
Task { @MainActor in
67+
do {
68+
let response = try await itemTagsService.allItemTags(shopId: shopId, page: page)
69+
itemTags = response.itemTags
70+
paginationMeta = response.paginationMeta
71+
state = .hasData
72+
} catch {
73+
state = .failed
74+
Failure
75+
.fetch(from: Self.self, reason: error.codedDescription)
76+
.log()
77+
}
78+
}
79+
}
80+
81+
func loadNextPage(shopId: String) {
82+
guard let meta = paginationMeta, meta.hasMorePages else { return }
83+
84+
if isLoadingMore {
85+
return
86+
}
87+
88+
isLoadingMore = true
89+
90+
Task { @MainActor in
91+
do {
92+
let response = try await itemTagsService.allItemTags(shopId: shopId, page: meta.currentPage + 1)
93+
itemTags.append(contentsOf: response.itemTags)
94+
paginationMeta = response.paginationMeta
95+
} catch {
96+
Failure
97+
.fetch(from: Self.self, reason: error.codedDescription)
98+
.log()
99+
}
100+
101+
isLoadingMore = false
102+
}
103+
}
104+
51105
func fetchAll(shopId: String) async throws -> [ItemTag] {
52106
do {
53-
itemTags = try await itemTagsService.allItemTags(shopId: shopId)
107+
let response = try await itemTagsService.allItemTags(shopId: shopId)
108+
itemTags = response.itemTags
54109
return itemTags
55110
} catch {
56111
Failure

NativeAppTemplate/Data/Repositories/ItemTagRepositoryProtocol.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ import SwiftUI
99
var itemTags: [ItemTag] { get set }
1010
var state: DataState { get set }
1111
var isEmpty: Bool { get }
12+
var paginationMeta: PaginationMeta? { get }
13+
var isLoadingMore: Bool { get }
1214

1315
init(itemTagsService: ItemTagsService)
1416

1517
func findBy(id: String) -> ItemTag
1618
func reload(shopId: String)
19+
func reloadPage(shopId: String, page: Int)
20+
func loadNextPage(shopId: String)
1721
func fetchAll(shopId: String) async throws -> [ItemTag]
1822
func fetchDetail(id: String) async throws -> ItemTag
1923
func create(shopId: String, itemTag: ItemTag) async throws -> ItemTag
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// PaginationMeta.swift
3+
// NativeAppTemplate
4+
//
5+
6+
import Foundation
7+
8+
struct PaginationMeta: Sendable {
9+
let currentPage: Int
10+
let totalPages: Int
11+
let totalCount: Int
12+
let limit: Int
13+
14+
var hasMorePages: Bool {
15+
currentPage < totalPages
16+
}
17+
18+
init(currentPage: Int, totalPages: Int, totalCount: Int, limit: Int) {
19+
self.currentPage = currentPage
20+
self.totalPages = totalPages
21+
self.totalCount = totalCount
22+
self.limit = limit
23+
}
24+
25+
init?(dictionary: [String: Any]) {
26+
guard let currentPage = dictionary["current_page"] as? Int,
27+
let totalPages = dictionary["total_pages"] as? Int,
28+
let totalCount = dictionary["total_count"] as? Int,
29+
let limit = dictionary["limit"] as? Int else {
30+
return nil
31+
}
32+
33+
self.currentPage = currentPage
34+
self.totalPages = totalPages
35+
self.totalCount = totalCount
36+
self.limit = limit
37+
}
38+
}

NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55

66
import Foundation
77

8+
struct ItemTagsResponse: Sendable {
9+
let itemTags: [ItemTag]
10+
let paginationMeta: PaginationMeta?
11+
}
12+
813
struct GetItemTagsRequest: Request {
9-
typealias Response = [ItemTag]
14+
typealias Response = ItemTagsResponse
1015

1116
// MARK: - Properties
1217

@@ -19,18 +24,27 @@ struct GetItemTagsRequest: Request {
1924
}
2025

2126
var additionalHeaders: [String: String] = [:]
27+
28+
var queryItems: [URLQueryItem] {
29+
guard let page else { return [] }
30+
return [URLQueryItem(name: "page", value: String(page))]
31+
}
32+
2233
var body: Data? {
2334
nil
2435
}
2536

2637
let shopId: String
38+
let page: Int?
2739

2840
// MARK: - Internal
2941

3042
func handle(response: Data) throws -> Response {
3143
let json = try JSONSerialization.jsonObject(with: response)
3244
let doc = JSONAPIDocument(json)
33-
return try doc.data.map { try ItemTagAdapter.process(resource: $0) }
45+
let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) }
46+
let paginationMeta = PaginationMeta(dictionary: doc.meta)
47+
return ItemTagsResponse(itemTags: itemTags, paginationMeta: paginationMeta)
3448
}
3549
}
3650

NativeAppTemplate/Networking/Requests/Request.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// NativeAppTemplate
44
//
55

6-
import struct Foundation.Data
6+
import Foundation
77

88
enum HTTPMethod: String {
99
case GET
@@ -19,6 +19,7 @@ protocol Request {
1919
var method: HTTPMethod { get }
2020
var path: String { get }
2121
var additionalHeaders: [String: String] { get }
22+
var queryItems: [URLQueryItem] { get }
2223
var body: Data? { get }
2324

2425
func handle(response: Data) throws -> Response
@@ -30,6 +31,10 @@ extension Request {
3031
.GET
3132
}
3233

34+
var queryItems: [URLQueryItem] {
35+
[]
36+
}
37+
3338
var body: Data? {
3439
nil
3540
}

NativeAppTemplate/Networking/Services/ItemTagsService.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ struct ItemTagsService: Service {
1010
extension ItemTagsService {
1111
// MARK: - Internal
1212

13-
func allItemTags(shopId: String) async throws -> GetItemTagsRequest.Response {
14-
let request = GetItemTagsRequest(shopId: shopId)
13+
func allItemTags(shopId: String, page: Int? = nil) async throws -> GetItemTagsRequest.Response {
14+
let request = GetItemTagsRequest(shopId: shopId, page: page)
1515
return try await makeRequest(request: request)
1616
}
1717

NativeAppTemplate/Networking/Services/Service.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@ extension Service {
3131
pathURL = pathURL.appendingPathComponent(networkClient.environment.basePath)
3232
pathURL = pathURL.appendingPathComponent(request.path)
3333

34-
guard let components = URLComponents(
34+
guard var components = URLComponents(
3535
url: pathURL,
3636
resolvingAgainstBaseURL: false
3737
) else {
3838
throw URLError(.badURL)
3939
}
4040

41+
if !request.queryItems.isEmpty {
42+
components.queryItems = request.queryItems
43+
}
44+
4145
guard let url = components.url
4246
else { throw URLError(.badURL) }
4347

NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,24 +55,30 @@ private extension ItemTagListView {
5555
if viewModel.isEmpty {
5656
noResultsView
5757
} else {
58-
List(viewModel.itemTags) { itemTag in
59-
NavigationLink(
60-
destination: ItemTagDetailView(
61-
viewModel: viewModel.createItemTagDetailViewModel(itemTagId: itemTag.id)
62-
)
63-
) {
64-
ItemTagListCardView(
65-
itemTag: itemTag
66-
)
67-
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
68-
Button(role: .destructive) { viewModel.destroyItemTag(itemTagId: itemTag.id) } label: {
69-
Label(String.delete, systemImage: "trash")
70-
.labelStyle(.titleOnly)
58+
List {
59+
ForEach(viewModel.itemTags) { itemTag in
60+
NavigationLink(
61+
destination: ItemTagDetailView(
62+
viewModel: viewModel.createItemTagDetailViewModel(itemTagId: itemTag.id)
63+
)
64+
) {
65+
ItemTagListCardView(
66+
itemTag: itemTag
67+
)
68+
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
69+
Button(role: .destructive) { viewModel.destroyItemTag(itemTagId: itemTag.id) } label: {
70+
Label(String.delete, systemImage: "trash")
71+
.labelStyle(.titleOnly)
72+
}
73+
.tint(.validationError)
7174
}
72-
.tint(.validationError)
7375
}
76+
.listRowBackground(Color.cardBackground.opacity(0.7))
77+
}
78+
79+
if viewModel.hasMorePages {
80+
loadMoreRow
7481
}
75-
.listRowBackground(Color.cardBackground.opacity(0.7))
7682
}
7783
.refreshable {
7884
viewModel.reload()
@@ -102,6 +108,19 @@ private extension ItemTagListView {
102108
)
103109
}
104110

111+
var loadMoreRow: some View {
112+
HStack {
113+
Spacer()
114+
ProgressView()
115+
.padding(NativeAppTemplateConstants.Spacing.xxs)
116+
Spacer()
117+
}
118+
.listRowBackground(Color.clear)
119+
.onAppear {
120+
viewModel.loadMore()
121+
}
122+
}
123+
105124
var noResultsView: some View {
106125
VStack {
107126
Image(systemName: "01.square")

NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ final class ItemTagListViewModel {
2020
itemTagRepository.itemTags
2121
}
2222

23+
var paginationMeta: PaginationMeta? {
24+
itemTagRepository.paginationMeta
25+
}
26+
27+
var isLoadingMore: Bool {
28+
itemTagRepository.isLoadingMore
29+
}
30+
31+
var hasMorePages: Bool {
32+
paginationMeta?.hasMorePages ?? false
33+
}
34+
2335
private let itemTagRepository: ItemTagRepositoryProtocol
2436
private let messageBus: MessageBus
2537
private let sessionController: SessionControllerProtocol
@@ -46,7 +58,11 @@ final class ItemTagListViewModel {
4658
}
4759

4860
func reload() {
49-
itemTagRepository.reload(shopId: shop.id)
61+
itemTagRepository.reloadPage(shopId: shop.id, page: 1)
62+
}
63+
64+
func loadMore() {
65+
itemTagRepository.loadNextPage(shopId: shop.id)
5066
}
5167

5268
func destroyItemTag(itemTagId: String) {

0 commit comments

Comments
 (0)