diff options
Diffstat (limited to 'Hutch/Extensions')
| -rw-r--r-- | Hutch/Extensions/Date+Relative.swift | 10 | ||||
| -rw-r--r-- | Hutch/Extensions/DateFormatter+SRHT.swift | 39 | ||||
| -rw-r--r-- | Hutch/Extensions/ErrorViews.swift | 159 | ||||
| -rw-r--r-- | Hutch/Extensions/Int+ByteFormatting.swift | 8 | ||||
| -rw-r--r-- | Hutch/Extensions/KeychainHelper.swift | 103 | ||||
| -rw-r--r-- | Hutch/Extensions/SRHTWebURL.swift | 50 |
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) + } } |
