Commit 59b53afae0

59b53afae046158c24eae03bb7e21d535dd6f8ad

parent: 7718ac2b7f

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 00:47 UTC

feat: add theme and high-density display modes

Refs: https://todo.sr.ht/~ccleberg/hutch/24
Implements: https://todo.sr.ht/~ccleberg/hutch/25

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 38; 518 CURRENT_PROJECT_VERSION = 39;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.16.2; 535 MARKETING_VERSION = 2.17.0;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 38; 555 CURRENT_PROJECT_VERSION = 39;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.16.2; 572 MARKETING_VERSION = 2.17.0;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 38; 635 CURRENT_PROJECT_VERSION = 39;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.16.2; 645 MARKETING_VERSION = 2.17.0;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 38; 664 CURRENT_PROJECT_VERSION = 39;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.16.2; 674 MARKETING_VERSION = 2.17.0;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +2
@@ -13,4 +13,6 @@ enum AppStorageKeys {
13 static let homeBuildsExpanded = "homeBuildsExpanded" 13 static let homeBuildsExpanded = "homeBuildsExpanded"
14 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" 14 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
15 static let buildsRepoFilter = "buildsRepoFilter" 15 static let buildsRepoFilter = "buildsRepoFilter"
16 static let appTheme = "appTheme"
17 static let displayDensity = "displayDensity"
16} 18}
Hutch/App/HutchApp.swift +5
@@ -4,12 +4,17 @@ import SwiftUI
4struct HutchApp: App { 4struct HutchApp: App {
5 @State private var appState = AppState() 5 @State private var appState = AppState()
6 @State private var networkMonitor = NetworkMonitor() 6 @State private var networkMonitor = NetworkMonitor()
7 @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
8 @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
7 9
8 var body: some Scene { 10 var body: some Scene {
9 WindowGroup { 11 WindowGroup {
10 RootView() 12 RootView()
11 .environment(appState) 13 .environment(appState)
12 .environment(networkMonitor) 14 .environment(networkMonitor)
15 .environment(\.displayDensity, displayDensity)
16 .environment(\.defaultMinListRowHeight, displayDensity == .compact ? 36 : 44)
17 .preferredColorScheme(appTheme.colorScheme)
13 .onOpenURL { url in 18 .onOpenURL { url in
14 if let link = DeepLink(url: url) { 19 if let link = DeepLink(url: url) {
15 appState.pendingDeepLink = link 20 appState.pendingDeepLink = link
Hutch/App/ThemeManager.swift added +76
@@ -0,0 +1,76 @@
1import SwiftUI
2
3// MARK: - Theme & Density Types
4
5enum AppTheme: String, CaseIterable, Identifiable {
6 case system
7 case light
8 case dark
9
10 var id: String { rawValue }
11
12 var label: String {
13 switch self {
14 case .system: "System"
15 case .light: "Light"
16 case .dark: "Dark"
17 }
18 }
19
20 var colorScheme: ColorScheme? {
21 switch self {
22 case .system: nil
23 case .light: .light
24 case .dark: .dark
25 }
26 }
27}
28
29enum DisplayDensity: String, CaseIterable, Identifiable {
30 case standard
31 case compact
32
33 var id: String { rawValue }
34
35 var label: String {
36 switch self {
37 case .standard: "Standard"
38 case .compact: "Compact"
39 }
40 }
41}
42
43// MARK: - Environment Keys
44
45private struct DisplayDensityKey: EnvironmentKey {
46 static let defaultValue: DisplayDensity = .standard
47}
48
49extension EnvironmentValues {
50 var displayDensity: DisplayDensity {
51 get { self[DisplayDensityKey.self] }
52 set { self[DisplayDensityKey.self] = newValue }
53 }
54}
55
56// MARK: - Themed List Modifier
57
58/// Combined modifier for List/Form that applies compact section spacing.
59/// Apply once per List or Form.
60struct ThemedListStyle: ViewModifier {
61 @Environment(\.displayDensity) private var density
62
63 func body(content: Content) -> some View {
64 content
65 .listSectionSpacing(density == .compact ? .compact : .default)
66 .contentMargins(.vertical, density == .compact ? 2 : 8, for: .scrollContent)
67 .controlSize(density == .compact ? .small : .regular)
68 }
69}
70
71extension View {
72 /// Apply themed appearance (density spacing) to a List or Form.
73 func themedList() -> some View {
74 modifier(ThemedListStyle())
75 }
76}
Hutch/Views/Builds/BuildDetailView.swift +1
@@ -392,6 +392,7 @@ private struct EditResubmitBuildSheet: View {
392 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild) 392 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
393 } 393 }
394 } 394 }
395 .themedList()
395 } 396 }
396 } 397 }
397} 398}
Hutch/Views/Builds/BuildListView.swift +1
@@ -162,6 +162,7 @@ struct BuildListView: View {
162 .listRowSeparator(.hidden) 162 .listRowSeparator(.hidden)
163 } 163 }
164 } 164 }
165 .themedList()
165 .listStyle(.plain) 166 .listStyle(.plain)
166 .searchable( 167 .searchable(
167 text: $vm.searchText, 168 text: $vm.searchText,
Hutch/Views/Home/HomeView.swift +1
@@ -65,6 +65,7 @@ struct HomeView: View {
65 assignedTicketsSection(viewModel) 65 assignedTicketsSection(viewModel)
66 recentBuildsSection(viewModel) 66 recentBuildsSection(viewModel)
67 } 67 }
68 .themedList()
68 .listStyle(.insetGrouped) 69 .listStyle(.insetGrouped)
69 .overlay { 70 .overlay {
70 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && 71 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
Hutch/Views/Inbox/InboxView.swift +1
@@ -68,6 +68,7 @@ struct InboxView: View {
68 } 68 }
69 } 69 }
70 } 70 }
71 .themedList()
71 .searchable( 72 .searchable(
72 text: $vm.searchText, 73 text: $vm.searchText,
73 placement: .navigationBarDrawer(displayMode: .always), 74 placement: .navigationBarDrawer(displayMode: .always),
Hutch/Views/Lookup/LookupView.swift +1
@@ -411,6 +411,7 @@ struct LookupView: View {
411 } 411 }
412 } 412 }
413 } 413 }
414 .themedList()
414 .formStyle(.grouped) 415 .formStyle(.grouped)
415 .srhtErrorBanner(error: $vm.error) 416 .srhtErrorBanner(error: $vm.error)
416 .sheet(item: vm.resultBinding) { result in 417 .sheet(item: vm.resultBinding) { result in
Hutch/Views/More/MoreView.swift +1
@@ -63,6 +63,7 @@ struct MoreView: View {
63 Text("These SourceHut services are not supported in-app, as the SourceHut API does not support them, and will open in your browser.") 63 Text("These SourceHut services are not supported in-app, as the SourceHut API does not support them, and will open in your browser.")
64 } 64 }
65 } 65 }
66 .themedList()
66 .navigationTitle("More") 67 .navigationTitle("More")
67 .refreshable { 68 .refreshable {
68 await ensureViewModel().loadSystemStatus(forceRefresh: true) 69 await ensureViewModel().loadSystemStatus(forceRefresh: true)
Hutch/Views/More/ProfileView.swift +1
@@ -87,6 +87,7 @@ struct ProfileView: View {
87 patSection(viewModel) 87 patSection(viewModel)
88 } 88 }
89 } 89 }
90 .themedList()
90 .overlay { 91 .overlay {
91 if viewModel.isLoading, viewModel.profile == nil { 92 if viewModel.isLoading, viewModel.profile == nil {
92 SRHTLoadingStateView(message: "Loading profile…") 93 SRHTLoadingStateView(message: "Loading profile…")
Hutch/Views/Pastes/PasteDetailView.swift +1
@@ -259,6 +259,7 @@ private struct PasteVisibilitySheet: View {
259 ProgressView() 259 ProgressView()
260 } 260 }
261 } 261 }
262 .themedList()
262 } 263 }
263 } 264 }
264 265
Hutch/Views/Pastes/PasteListView.swift +1
@@ -110,6 +110,7 @@ struct PasteListView: View {
110 .listRowSeparator(.hidden) 110 .listRowSeparator(.hidden)
111 } 111 }
112 } 112 }
113 .themedList()
113 .listStyle(.plain) 114 .listStyle(.plain)
114 .searchable( 115 .searchable(
115 text: $vm.searchText, 116 text: $vm.searchText,
Hutch/Views/Repositories/RepositoryListView.swift +1
@@ -84,6 +84,7 @@ struct RepositoryListView: View {
84 .listRowSeparator(.hidden) 84 .listRowSeparator(.hidden)
85 } 85 }
86 } 86 }
87 .themedList()
87 .listStyle(.plain) 88 .listStyle(.plain)
88 .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories") 89 .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories")
89 .overlay { 90 .overlay {
Hutch/Views/Settings/SettingsView.swift +25
@@ -2,16 +2,20 @@ import SwiftUI
2 2
3struct SettingsView: View { 3struct SettingsView: View {
4 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
5 @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
6 @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
5 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 7 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
6 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true 8 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
7 @State private var pendingDestructiveAction: SettingsDestructiveAction? 9 @State private var pendingDestructiveAction: SettingsDestructiveAction?
8 10
9 var body: some View { 11 var body: some View {
10 Form { 12 Form {
13 appearanceSection()
11 behaviorSection() 14 behaviorSection()
12 authenticationSection() 15 authenticationSection()
13 aboutSection() 16 aboutSection()
14 } 17 }
18 .themedList()
15 .navigationTitle("Settings") 19 .navigationTitle("Settings")
16 .alert( 20 .alert(
17 pendingDestructiveAction?.title ?? "", 21 pendingDestructiveAction?.title ?? "",
@@ -44,6 +48,26 @@ struct SettingsView: View {
44 } 48 }
45 } 49 }
46 50
51 @ViewBuilder
52 private func appearanceSection() -> some View {
53 Section {
54 Picker("Theme", selection: $appTheme) {
55 ForEach(AppTheme.allCases) { theme in
56 Text(theme.label).tag(theme)
57 }
58 }
59 Picker("Density", selection: $displayDensity) {
60 ForEach(DisplayDensity.allCases) { density in
61 Text(density.label).tag(density)
62 }
63 }
64 } header: {
65 Text("Appearance")
66 } footer: {
67 Text("Compact density reduces spacing throughout the app.")
68 }
69 }
70
47 @ViewBuilder 71 @ViewBuilder
48 private func behaviorSection() -> some View { 72 private func behaviorSection() -> some View {
49 Section { 73 Section {
@@ -192,6 +216,7 @@ private struct AboutView: View {
192 .foregroundStyle(.secondary) 216 .foregroundStyle(.secondary)
193 } 217 }
194 } 218 }
219 .themedList()
195 .navigationTitle("About") 220 .navigationTitle("About")
196 .navigationBarTitleDisplayMode(.inline) 221 .navigationBarTitleDisplayMode(.inline)
197 } 222 }