summaryrefslogtreecommitdiff
path: root/Hutch/Extensions
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Extensions')
-rw-r--r--Hutch/Extensions/Date+Relative.swift10
-rw-r--r--Hutch/Extensions/DateFormatter+SRHT.swift39
-rw-r--r--Hutch/Extensions/ErrorViews.swift159
-rw-r--r--Hutch/Extensions/Int+ByteFormatting.swift8
-rw-r--r--Hutch/Extensions/KeychainHelper.swift103
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift50
6 files changed, 368 insertions, 1 deletions
diff --git a/Hutch/Extensions/Date+Relative.swift b/Hutch/Extensions/Date+Relative.swift
new file mode 100644
index 0000000..f06ba59
--- /dev/null
+++ b/Hutch/Extensions/Date+Relative.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+extension Date {
+ /// A short relative description like "2h ago", "3d ago", or "Jan 5, 2025".
+ var relativeDescription: String {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .abbreviated
+ return formatter.localizedString(for: self, relativeTo: .now)
+ }
+}
diff --git a/Hutch/Extensions/DateFormatter+SRHT.swift b/Hutch/Extensions/DateFormatter+SRHT.swift
new file mode 100644
index 0000000..c017b11
--- /dev/null
+++ b/Hutch/Extensions/DateFormatter+SRHT.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+extension DateFormatter {
+ /// Formatter for the sr.ht `Time` scalar: `%Y-%m-%dT%H:%M:%SZ` (UTC).
+ static let srht: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "UTC")
+ return formatter
+ }()
+
+ /// Fallback formatter that also accepts fractional seconds.
+ static let srhtFractional: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "UTC")
+ return formatter
+ }()
+}
+
+extension JSONDecoder.DateDecodingStrategy {
+ /// Tries the primary sr.ht format first, then falls back to fractional seconds.
+ static let srhtFlexible: JSONDecoder.DateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let string = try container.decode(String.self)
+ if let date = DateFormatter.srht.date(from: string) {
+ return date
+ }
+ if let date = DateFormatter.srhtFractional.date(from: string) {
+ return date
+ }
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Cannot decode date string: \(string)"
+ )
+ }
+}
diff --git a/Hutch/Extensions/ErrorViews.swift b/Hutch/Extensions/ErrorViews.swift
new file mode 100644
index 0000000..086c0de
--- /dev/null
+++ b/Hutch/Extensions/ErrorViews.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+
+// MARK: - SRHTErrorBanner ViewModifier
+
+/// Displays a dismissible error banner at the top of the screen.
+/// Attach to any view with `.srhtErrorBanner(error:)`.
+struct SRHTErrorBanner: ViewModifier {
+ @Binding var error: String?
+
+ func body(content: Content) -> some View {
+ content
+ .overlay(alignment: .top) {
+ if let message = error {
+ banner(message)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: error)
+ }
+
+ @ViewBuilder
+ private func banner(_ message: String) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.white)
+
+ Text(message)
+ .font(.subheadline)
+ .foregroundStyle(.white)
+ .lineLimit(3)
+
+ Spacer()
+
+ Button {
+ error = nil
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.white.opacity(0.8))
+ }
+ }
+ .padding(12)
+ .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12))
+ .padding(.horizontal, 12)
+ .padding(.top, 4)
+ }
+}
+
+extension View {
+ /// Attach an error banner that shows at the top when `error` is non-nil.
+ func srhtErrorBanner(error: Binding<String?>) -> some View {
+ modifier(SRHTErrorBanner(error: error))
+ }
+}
+
+// MARK: - Shared Screen States
+
+struct SRHTLoadingStateView: View {
+ let message: String
+
+ var body: some View {
+ VStack(spacing: 12) {
+ ProgressView()
+ Text(message)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+struct SRHTErrorStateView: View {
+ let title: String
+ let message: String
+ let retryAction: (() async -> Void)?
+
+ var body: some View {
+ ContentUnavailableView {
+ SwiftUI.Label(title, systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(message)
+ } actions: {
+ if let retryAction {
+ Button("Retry") {
+ Task { await retryAction() }
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+}
+
+// MARK: - NoConnectionView
+
+/// Empty state view shown when the device is offline. Includes a retry button.
+struct NoConnectionView: View {
+ var retryAction: () async -> Void
+
+ var body: some View {
+ ContentUnavailableView {
+ SwiftUI.Label("No Connection", systemImage: "wifi.slash")
+ } description: {
+ Text("Check your internet connection and try again.")
+ } actions: {
+ Button {
+ Task { await retryAction() }
+ } label: {
+ Text("Retry")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+}
+
+// MARK: - Connectivity Overlay
+
+/// ViewModifier that shows NoConnectionView when the device is offline and
+/// there is no content to display. When content exists, shows a subtle
+/// offline indicator instead.
+struct ConnectivityOverlay: ViewModifier {
+ @Environment(NetworkMonitor.self) private var networkMonitor
+ let hasContent: Bool
+ var retryAction: () async -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .overlay {
+ if !networkMonitor.isConnected, !hasContent {
+ NoConnectionView(retryAction: retryAction)
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ if !networkMonitor.isConnected, hasContent {
+ offlineBadge
+ }
+ }
+ }
+
+ private var offlineBadge: some View {
+ HStack(spacing: 6) {
+ Image(systemName: "wifi.slash")
+ .font(.caption2)
+ Text("Offline — showing cached data")
+ .font(.caption2)
+ }
+ .foregroundStyle(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(.orange.gradient, in: Capsule())
+ .padding(.bottom, 4)
+ }
+}
+
+extension View {
+ /// Overlay a no-connection view when offline with no content, or a
+ /// subtle offline badge when showing cached data.
+ func connectivityOverlay(hasContent: Bool, retryAction: @escaping () async -> Void) -> some View {
+ modifier(ConnectivityOverlay(hasContent: hasContent, retryAction: retryAction))
+ }
+}
diff --git a/Hutch/Extensions/Int+ByteFormatting.swift b/Hutch/Extensions/Int+ByteFormatting.swift
new file mode 100644
index 0000000..a17ece4
--- /dev/null
+++ b/Hutch/Extensions/Int+ByteFormatting.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+extension Int {
+ /// Human-readable byte size string (e.g. "1.2 MB", "340 KB").
+ var formattedByteCount: String {
+ ByteCountFormatter.string(fromByteCount: Int64(self), countStyle: .file)
+ }
+}
diff --git a/Hutch/Extensions/KeychainHelper.swift b/Hutch/Extensions/KeychainHelper.swift
new file mode 100644
index 0000000..01f8e5a
--- /dev/null
+++ b/Hutch/Extensions/KeychainHelper.swift
@@ -0,0 +1,103 @@
+import Foundation
+@preconcurrency import Security
+
+enum KeychainHelper: Sendable {
+
+ private static let service = "net.cleberg.Hutch"
+ private static let tokenAccount = "srht-access-token"
+
+ // MARK: - Save
+
+ static func saveToken(_ token: String) throws {
+ guard let data = token.data(using: .utf8) else {
+ throw KeychainError.encodingFailed
+ }
+
+ // Delete any existing item first
+ let deleteQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount
+ ]
+ SecItemDelete(deleteQuery as CFDictionary)
+
+ let addQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ ]
+
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
+ guard status == errSecSuccess else {
+ throw KeychainError.saveFailed(status)
+ }
+ }
+
+ // MARK: - Load
+
+ static func loadToken() -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let token = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return token
+ }
+
+ // MARK: - Delete
+
+ static func deleteToken() throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount
+ ]
+
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw KeychainError.deleteFailed(status)
+ }
+ }
+
+ static func deleteAll() throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service
+ ]
+
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw KeychainError.deleteFailed(status)
+ }
+ }
+}
+
+enum KeychainError: LocalizedError {
+ case encodingFailed
+ case saveFailed(OSStatus)
+ case deleteFailed(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .encodingFailed:
+ "Failed to encode token data."
+ case .saveFailed(let status):
+ "Keychain save failed with status \(status)."
+ case .deleteFailed(let status):
+ "Keychain delete failed with status \(status)."
+ }
+ }
+}
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index 53556de..f63352b 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -9,6 +9,41 @@ enum SRHTWebURL {
)
}
+ static func commit(repository: RepositorySummary, commitId: String) -> URL? {
+ userScopedURL(
+ host: "\(repository.service.rawValue).sr.ht",
+ ownerCanonicalName: repository.owner.canonicalName,
+ pathComponents: [repository.name, commitPathComponent(for: repository.service), commitId]
+ )
+ }
+
+ static func file(repository: RepositorySummary, revspec: String, path: String) -> URL? {
+ switch repository.service {
+ case .git:
+ return userScopedURL(
+ host: "git.sr.ht",
+ ownerCanonicalName: repository.owner.canonicalName,
+ pathComponents: [repository.name, "tree", revspec, "item"] + pathComponents(from: path)
+ )
+ case .hg:
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = "hg.sr.ht"
+
+ let ownerUsername = username(from: repository.owner.canonicalName)
+ let encodedRepository = repository.name.addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? repository.name
+ let encodedPath = path.split(separator: "/").map {
+ String($0).addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? String($0)
+ }.joined(separator: "/")
+
+ components.percentEncodedPath = "/~\(ownerUsername)/\(encodedRepository)/browse/\(encodedPath)"
+ components.queryItems = [URLQueryItem(name: "rev", value: revspec)]
+ return components.url
+ default:
+ return nil
+ }
+ }
+
static func build(jobId: Int, ownerCanonicalName: String) -> URL? {
userScopedURL(
host: "builds.sr.ht",
@@ -35,7 +70,7 @@ enum SRHTWebURL {
static func profile(canonicalName: String) -> URL? {
userScopedURL(
- host: "meta.sr.ht",
+ host: "sr.ht",
ownerCanonicalName: canonicalName,
pathComponents: []
)
@@ -76,9 +111,22 @@ enum SRHTWebURL {
return canonicalName
}
+ private static func commitPathComponent(for service: SRHTService) -> String {
+ switch service {
+ case .hg:
+ return "rev"
+ default:
+ return "commit"
+ }
+ }
+
private static let pathComponentCharacterSet: CharacterSet = {
var characterSet = CharacterSet.urlPathAllowed
characterSet.remove(charactersIn: "/")
return characterSet
}()
+
+ private static func pathComponents(from path: String) -> [String] {
+ path.split(separator: "/").map(String.init)
+ }
}