gitbay/Repos/PagedListModel.swift

v1.6.0
gitbay-ios/gitbay/Repos/PagedListModel.swift history · blame · raw

88 lines · 3158 bytes

 1import Foundation
 2import Observation
 3
 4/// Accumulated pages of one list command (`repo list`, `issue list`,
 5/// `mr list`). Cursors are opaque and server-minted; the model only
 6/// hands back what `next` carried.
 7@Observable
 8@MainActor
 9final class PagedListModel<Element: Decodable & Sendable> {
10
11    private(set) var state: LoadState<[Element]> = .loading
12    private(set) var isLoadingMore = false
13    private var next: String?
14
15    var hasMore: Bool { next != nil }
16
17    /// True until a page, or the empty state, is on screen. A list's
18    /// `.task` runs again when a pushed screen is popped; loading only
19    /// while this is true keeps the pages and scroll position the user
20    /// came back to. A failed first page has nothing to keep.
21    var needsLoad: Bool {
22        switch state {
23        case .loaded, .empty: false
24        case .loading, .failed: true
25        }
26    }
27
28    private let client: GitbayClient
29    private let pageSize: Int
30    /// The list command minus paging flags. Set by the owning view model
31    /// when a filter changes; a change only takes effect via `reload()`.
32    var argv: [String]
33    var emptyMessage: String
34
35    init(
36        client: GitbayClient,
37        argv: [String],
38        emptyMessage: String,
39        pageSize: Int = 50
40    ) {
41        self.client = client
42        self.argv = argv
43        self.emptyMessage = emptyMessage
44        self.pageSize = pageSize
45    }
46
47    // An explicit deinit: with the deployment target at iOS 18 the Swift
48    // 6.3 optimizer crashes inlining the synthesized one of this generic
49    // @Observable class (isCallerAndCalleeLayoutConstraintsCompatible),
50    // which only a Release build reaches.
51    deinit {}
52
53    func reload() async {
54        state = .loading
55        next = nil
56        await fetch(cursor: nil, appendingTo: [])
57    }
58
59    /// Fetch the next page. Safe to call repeatedly from row-appear
60    /// triggers; it no-ops while a fetch is in flight or at the end.
61    func loadMore() async {
62        guard let cursor = next, !isLoadingMore, let loaded = state.value else { return }
63        isLoadingMore = true
64        defer { isLoadingMore = false }
65        await fetch(cursor: cursor, appendingTo: loaded)
66    }
67
68    private func fetch(cursor: String?, appendingTo existing: [Element]) async {
69        do {
70            let page = try await client.readPage(
71                argv, of: Element.self, limit: pageSize, cursor: cursor
72            )
73            next = page.next
74            let all = existing + page.items
75            state = all.isEmpty ? .empty(emptyMessage) : .loaded(all)
76        } catch {
77            // A failed first page is the screen's state; a failed later
78            // page keeps what is on screen and leaves the cursor for a
79            // retry from the same trigger. A cancelled fetch (superseded
80            // by a newer reload, e.g. a fast filter change) is neither —
81            // it must not paint a stale failure over whatever the
82            // replacement fetch already showed or is about to show.
83            if existing.isEmpty, !Task.isCancelled {
84                state = .from(error)
85            }
86        }
87    }
88}