krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

1354f2464180ece6d13296a543a964ddf6d2d393

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T00:47:57Z

feat: add theme and high-density display modes

Refs: https://todo.sr.ht/~ccleberg/hutch/24
Implements: https://todo.sr.ht/~ccleberg/hutch/25
 Hutch.xcodeproj/project.pbxproj                   | 16 ++---
 Hutch/App/AppStorageKeys.swift                    |  2 +
 Hutch/App/HutchApp.swift                          |  5 ++
 Hutch/App/ThemeManager.swift                      | 76 +++++++++++++++++++++++
 Hutch/Views/Builds/BuildDetailView.swift          |  1 +
 Hutch/Views/Builds/BuildListView.swift            |  1 +
 Hutch/Views/Home/HomeView.swift                   |  1 +
 Hutch/Views/Inbox/InboxView.swift                 |  1 +
 Hutch/Views/Lookup/LookupView.swift               |  1 +
 Hutch/Views/More/MoreView.swift                   |  1 +
 Hutch/Views/More/ProfileView.swift                |  1 +
 Hutch/Views/Pastes/PasteDetailView.swift          |  1 +
 Hutch/Views/Pastes/PasteListView.swift            |  1 +
 Hutch/Views/Repositories/RepositoryListView.swift |  1 +
 Hutch/Views/Settings/SettingsView.swift           | 25 ++++++++
 15 files changed, 126 insertions(+), 8 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 51c63c4..4328148 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 38;
+				CURRENT_PROJECT_VERSION = 39;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.16.2;
+				MARKETING_VERSION = 2.17.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 38;
+				CURRENT_PROJECT_VERSION = 39;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.16.2;
+				MARKETING_VERSION = 2.17.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 38;
+				CURRENT_PROJECT_VERSION = 39;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.16.2;
+				MARKETING_VERSION = 2.17.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 38;
+				CURRENT_PROJECT_VERSION = 39;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.16.2;
+				MARKETING_VERSION = 2.17.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index ff309d4..eaf54f0 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -13,4 +13,6 @@ enum AppStorageKeys {
     static let homeBuildsExpanded = "homeBuildsExpanded"
     static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
     static let buildsRepoFilter = "buildsRepoFilter"
+    static let appTheme = "appTheme"
+    static let displayDensity = "displayDensity"
 }
diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift
index e65833c..016ddd0 100644
--- a/Hutch/App/HutchApp.swift
+++ b/Hutch/App/HutchApp.swift
@@ -4,12 +4,17 @@ import SwiftUI
 struct HutchApp: App {
     @State private var appState = AppState()
     @State private var networkMonitor = NetworkMonitor()
+    @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
+    @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
 
     var body: some Scene {
         WindowGroup {
             RootView()
                 .environment(appState)
                 .environment(networkMonitor)
+                .environment(\.displayDensity, displayDensity)
+                .environment(\.defaultMinListRowHeight, displayDensity == .compact ? 36 : 44)
+                .preferredColorScheme(appTheme.colorScheme)
                 .onOpenURL { url in
                     if let link = DeepLink(url: url) {
                         appState.pendingDeepLink = link
diff --git a/Hutch/App/ThemeManager.swift b/Hutch/App/ThemeManager.swift
new file mode 100644
index 0000000..ee74921
--- /dev/null
+++ b/Hutch/App/ThemeManager.swift
@@ -0,0 +1,76 @@
+import SwiftUI
+
+// MARK: - Theme & Density Types
+
+enum AppTheme: String, CaseIterable, Identifiable {
+    case system
+    case light
+    case dark
+
+    var id: String { rawValue }
+
+    var label: String {
+        switch self {
+        case .system: "System"
+        case .light: "Light"
+        case .dark: "Dark"
+        }
+    }
+
+    var colorScheme: ColorScheme? {
+        switch self {
+        case .system: nil
+        case .light: .light
+        case .dark: .dark
+        }
+    }
+}
+
+enum DisplayDensity: String, CaseIterable, Identifiable {
+    case standard
+    case compact
+
+    var id: String { rawValue }
+
+    var label: String {
+        switch self {
+        case .standard: "Standard"
+        case .compact: "Compact"
+        }
+    }
+}
+
+// MARK: - Environment Keys
+
+private struct DisplayDensityKey: EnvironmentKey {
+    static let defaultValue: DisplayDensity = .standard
+}
+
+extension EnvironmentValues {
+    var displayDensity: DisplayDensity {
+        get { self[DisplayDensityKey.self] }
+        set { self[DisplayDensityKey.self] = newValue }
+    }
+}
+
+// MARK: - Themed List Modifier
+
+/// Combined modifier for List/Form that applies compact section spacing.
+/// Apply once per List or Form.
+struct ThemedListStyle: ViewModifier {
+    @Environment(\.displayDensity) private var density
+
+    func body(content: Content) -> some View {
+        content
+            .listSectionSpacing(density == .compact ? .compact : .default)
+            .contentMargins(.vertical, density == .compact ? 2 : 8, for: .scrollContent)
+            .controlSize(density == .compact ? .small : .regular)
+    }
+}
+
+extension View {
+    /// Apply themed appearance (density spacing) to a List or Form.
+    func themedList() -> some View {
+        modifier(ThemedListStyle())
+    }
+}
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 5070536..2f0bc6a 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -392,6 +392,7 @@ private struct EditResubmitBuildSheet: View {
                     .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
                 }
             }
+            .themedList()
         }
     }
 }
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index b53d64b..2423406 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -162,6 +162,7 @@ struct BuildListView: View {
                 .listRowSeparator(.hidden)
             }
         }
+        .themedList()
         .listStyle(.plain)
         .searchable(
             text: $vm.searchText,
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 180f0f3..bbae296 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -65,6 +65,7 @@ struct HomeView: View {
             assignedTicketsSection(viewModel)
             recentBuildsSection(viewModel)
         }
+        .themedList()
         .listStyle(.insetGrouped)
         .overlay {
             if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index 2447a6e..f8753d7 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -68,6 +68,7 @@ struct InboxView: View {
                 }
             }
         }
+        .themedList()
         .searchable(
             text: $vm.searchText,
             placement: .navigationBarDrawer(displayMode: .always),
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index e0adc6b..bfb532f 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -411,6 +411,7 @@ struct LookupView: View {
                 }
             }
         }
+        .themedList()
         .formStyle(.grouped)
         .srhtErrorBanner(error: $vm.error)
         .sheet(item: vm.resultBinding) { result in
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index 6325954..caec3d5 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -63,6 +63,7 @@ struct MoreView: View {
                 Text("These SourceHut services are not supported in-app, as the SourceHut API does not support them, and will open in your browser.")
             }
         }
+        .themedList()
         .navigationTitle("More")
         .refreshable {
             await ensureViewModel().loadSystemStatus(forceRefresh: true)
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index 53c5ce3..673b298 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -87,6 +87,7 @@ struct ProfileView: View {
                 patSection(viewModel)
             }
         }
+        .themedList()
         .overlay {
             if viewModel.isLoading, viewModel.profile == nil {
                 SRHTLoadingStateView(message: "Loading profile…")
diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift
index 19cfd52..a73e784 100644
--- a/Hutch/Views/Pastes/PasteDetailView.swift
+++ b/Hutch/Views/Pastes/PasteDetailView.swift
@@ -259,6 +259,7 @@ private struct PasteVisibilitySheet: View {
                     ProgressView()
                 }
             }
+            .themedList()
         }
     }
 
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
index 22f6ff9..ffd49ab 100644
--- a/Hutch/Views/Pastes/PasteListView.swift
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -110,6 +110,7 @@ struct PasteListView: View {
                 .listRowSeparator(.hidden)
             }
         }
+        .themedList()
         .listStyle(.plain)
         .searchable(
             text: $vm.searchText,
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index aec0357..fb090b2 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -84,6 +84,7 @@ struct RepositoryListView: View {
                 .listRowSeparator(.hidden)
             }
         }
+        .themedList()
         .listStyle(.plain)
         .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories")
         .overlay {
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 4fb3f9c..5abac90 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -2,16 +2,20 @@ import SwiftUI
 
 struct SettingsView: View {
     @Environment(AppState.self) private var appState
+    @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
+    @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
     @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
     @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
     @State private var pendingDestructiveAction: SettingsDestructiveAction?
 
     var body: some View {
         Form {
+            appearanceSection()
             behaviorSection()
             authenticationSection()
             aboutSection()
         }
+        .themedList()
         .navigationTitle("Settings")
         .alert(
             pendingDestructiveAction?.title ?? "",
@@ -44,6 +48,26 @@ struct SettingsView: View {
         }
     }
 
+    @ViewBuilder
+    private func appearanceSection() -> some View {
+        Section {
+            Picker("Theme", selection: $appTheme) {
+                ForEach(AppTheme.allCases) { theme in
+                    Text(theme.label).tag(theme)
+                }
+            }
+            Picker("Density", selection: $displayDensity) {
+                ForEach(DisplayDensity.allCases) { density in
+                    Text(density.label).tag(density)
+                }
+            }
+        } header: {
+            Text("Appearance")
+        } footer: {
+            Text("Compact density reduces spacing throughout the app.")
+        }
+    }
+
     @ViewBuilder
     private func behaviorSection() -> some View {
         Section {
@@ -192,6 +216,7 @@ private struct AboutView: View {
                     .foregroundStyle(.secondary)
             }
         }
+        .themedList()
         .navigationTitle("About")
         .navigationBarTitleDisplayMode(.inline)
     }