gitbay/Discovery/ProfileViewModel.swift

v1.0.4
gitbay-ios/gitbay/Discovery/ProfileViewModel.swift history · blame · raw

115 lines · 4090 bytes

  1import Foundation
  2import Observation
  3
  4/// `profile show <name>` — the whole profile in one read: description,
  5/// links, org membership (or an org's members), the repositories the
  6/// account may see, and a year of activity.
  7@Observable
  8@MainActor
  9final class ProfileViewModel {
 10
 11    nonisolated struct Profile: Decodable, Sendable, Hashable {
 12        let name: String
 13        let kind: String // user | org
 14        let description: String?
 15        let website: String?
 16        /// Long-form profile text, rendered between the header and the
 17        /// activity graph the way the web renders it.
 18        let about: String?
 19        let aboutFormat: String?
 20        let links: [Link]?
 21        let orgs: [Member]?
 22        let members: [Member]?
 23        let repos: [Repo]
 24        let activity: [Day]?
 25        let activityTotal: Int
 26
 27        enum CodingKeys: String, CodingKey {
 28            case name, kind, description, website, about, links, orgs, members, repos, activity
 29            case aboutFormat = "about_format"
 30            case activityTotal = "activity_total"
 31        }
 32
 33        var isOrg: Bool { kind == "org" }
 34
 35        /// The filename the about text is rendered under. The server has
 36        /// none to dispatch on either, so the stored format picks the
 37        /// extension and anything but org is markdown.
 38        var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" }
 39
 40        /// One of the free-form links a profile carries beside its
 41        /// website. The server caps the list at five.
 42        nonisolated struct Link: Decodable, Sendable, Hashable {
 43            let label: String?
 44            let url: String
 45
 46            /// Labels are optional; a link without one shows its URL.
 47            var title: String {
 48                if let label, !label.isEmpty { return label }
 49                return url
 50            }
 51        }
 52
 53        nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
 54            let name: String
 55            let role: String?
 56            var id: String { name }
 57        }
 58
 59        nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable {
 60            let path: String
 61            let visibility: String
 62            let description: String?
 63            let archived: Bool?
 64            let topics: [String]?
 65            let license: String?
 66            let defaultBranch: String?
 67            let updated: String?
 68
 69            enum CodingKeys: String, CodingKey {
 70                case path, visibility, description, archived, topics, license, updated
 71                case defaultBranch = "default_branch"
 72            }
 73
 74            var id: String { path }
 75            var isPrivate: Bool { visibility == "private" }
 76            var isArchived: Bool { archived ?? false }
 77            var name: String { String(path.split(separator: "/").last ?? "") }
 78
 79            /// "main · MIT · updated 2026-08-30", the line the web's repo
 80            /// rows carry. Parts the server did not report are left out.
 81            var meta: String {
 82                var parts: [String] = []
 83                if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) }
 84                if let license, !license.isEmpty { parts.append(license) }
 85                if let updated, !updated.isEmpty { parts.append("updated \(updated)") }
 86                return parts.joined(separator: " · ")
 87            }
 88        }
 89
 90        /// One day's contributions. Days with none are omitted, so the
 91        /// calendar is filled in by the view.
 92        nonisolated struct Day: Decodable, Sendable, Hashable {
 93            let date: String
 94            let count: Int
 95        }
 96    }
 97
 98    private(set) var state: LoadState<Profile> = .loading
 99
100    private let client: GitbayClient
101    let name: String
102
103    init(client: GitbayClient, name: String) {
104        self.client = client
105        self.name = name
106    }
107
108    func load() async {
109        do {
110            state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
111        } catch {
112            state = .from(error)
113        }
114    }
115}