-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathiPadAdaptive.swift
More file actions
230 lines (208 loc) · 9.15 KB
/
Copy pathiPadAdaptive.swift
File metadata and controls
230 lines (208 loc) · 9.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
//
// iPadAdaptive.swift
// hackertracker
//
// iPad-only adaptive layout helpers. Detection is by
// UIDevice.userInterfaceIdiom == .pad so iPhones (including Pro Max in
// landscape, which reports a `.regular` horizontal size class) are
// intentionally NOT included -- iPhone UI is unchanged by these modifiers.
//
// Apple HIG references:
// - Readable line lengths: aim for roughly 50-75 characters per line.
// - Centered content columns with generous side margins on iPad.
// - Adaptive grids that grow column count with available width.
//
// Implementation note: cannot use a ViewModifier(content:) struct here
// because our hackertracker module has its own `Content` model, which
// shadows the ViewModifier protocol's `Content` associated type and
// fails name resolution inside `body(content:)`. Use direct View
// extensions instead.
//
import MarkdownUI
import SwiftUI
extension View {
/// On iPad: constrains the content to `maxWidth` (default 740pt) and
/// centers it horizontally inside the available space. On iPhone: no-op.
/// Place this *inside* a ScrollView around the row content so the
/// scroll surface stays full-width while the visible content sits in a
/// readable centered column.
@MainActor
@ViewBuilder
func iPadReadableContent(maxWidth: CGFloat = 740) -> some View {
if UIDevice.current.userInterfaceIdiom == .pad {
self
.frame(maxWidth: maxWidth)
.frame(maxWidth: .infinity)
} else {
self
}
}
/// Detail-screen rounded cards on iPad read as "floating" tiles below
/// a flat nav bar, which makes the right detail pane look misaligned
/// from the left list. On iPad this returns the given radius as 0
/// (flat); on iPhone it passes through unchanged.
@MainActor
func iPadFlatCorners(_ radius: CGFloat = 15) -> some View {
self.cornerRadius(UIDevice.current.userInterfaceIdiom == .pad ? 0 : radius)
}
/// Replace the system base background with the active theme's
/// background color. Hides the default ScrollView background so
/// the themed color shows through; the result extends under the
/// nav bar (whose .ultraThinMaterial picks up the tint) and the
/// safe-area edges.
///
/// Apply this at the root of each tab's body, e.g.:
/// `NavigationStack { ... }.themedBackground(themeManager)`
func themedBackground(_ themeManager: ThemeManager) -> some View {
self
.scrollContentBackground(.hidden)
.background(themeManager.background, ignoresSafeAreaEdges: .all)
}
/// Apply the active theme's font design (system / monospaced /
/// rounded) to MarkdownUI body text. MarkdownUI doesn't pick up
/// SwiftUI's `.font(_:)` environment default, so each `Markdown(_:)`
/// call site needs an explicit text-style override to match the
/// rest of the themed UI.
func themedMarkdown(_ themeManager: ThemeManager) -> some View {
self.markdownTextStyle(\.text) {
FontFamily(.system(themeManager.fontDesign))
}
}
/// Replace the default nav-bar title slot with a `Text` styled
/// by the active theme's heading font. UIKit's
/// `UINavigationBarAppearance` proxy gets reset by the per-view
/// `.toolbarBackground(.ultraThinMaterial, for: .navigationBar)`
/// calls scattered through the codebase, so a global appearance
/// alone never sticks. A principal ToolbarItem renders inside the
/// SwiftUI toolbar slot and is unaffected by background tweaks.
///
/// Still pairs with `.navigationTitle(_:)` (kept by the caller)
/// so back-button labels, large-title fallbacks, and accessibility
/// have a string to fall back to.
func themedNavTitle(_ title: String, _ themeManager: ThemeManager) -> some View {
self.toolbar {
ToolbarItem(placement: .principal) {
Text(title).font(themeManager.headingFont)
}
}
}
}
enum IPadAdaptive {
/// Single source of truth for the iPad split-view sidebar width.
/// Used by Schedule (EventsView), Speakers, All Content, Merch,
/// and Communities (OrgsView). Bumped from the per-view 380/420
/// hodgepodge to a single 500pt so the left list panel uses
/// roughly 42% of an 11" iPad's landscape width, matching the
/// visual density of the Communities grid and keeping the right
/// detail pane from looking sparse.
static let sidebarWidth: CGFloat = 500
/// `true` when running on iPad. Use sparingly -- prefer the
/// `iPadReadableContent` modifier when possible.
///
/// `@MainActor` because `UIDevice.current` is main-actor isolated
/// under Swift 6 strict concurrency. All current call sites are
/// SwiftUI view bodies, which are already on the main actor.
@MainActor
static var isIPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
/// Grid columns sized by minimum width. Gives 2 columns on every
/// iPhone width (170 * 2 + spacing < 390), grows on iPad portrait
/// (~4-5 columns at 820pt) and iPad landscape (~6 columns at 1180pt).
static func adaptiveGridColumns(minimum: CGFloat = 170,
alignment: Alignment = .top) -> [GridItem] {
[GridItem(.adaptive(minimum: minimum), alignment: alignment)]
}
}
// MARK: - iPad split-view selection plumbing
/// Environment value carrying a `Binding<Int?>` for content selection in
/// an iPad NavigationSplitView. EventData / ContentData rows read this --
/// when non-nil, a tap sets the binding's wrappedValue instead of pushing
/// a NavigationLink to ContentDetailView. iPhone leaves the env value
/// `nil` so rows keep their existing NavigationStack push behavior.
private struct IPadContentSelectionKey: EnvironmentKey {
static let defaultValue: Binding<Int?>? = nil
}
extension EnvironmentValues {
var iPadContentSelection: Binding<Int?>? {
get { self[IPadContentSelectionKey.self] }
set { self[IPadContentSelectionKey.self] = newValue }
}
}
/// Environment value carrying a `Binding<Int?>` for speaker selection on
/// iPad. SpeakersView's row Buttons set this; SpeakersView's detail
/// column renders SpeakerDetailView(id: $0).
private struct IPadSpeakerSelectionKey: EnvironmentKey {
static let defaultValue: Binding<Int?>? = nil
}
extension EnvironmentValues {
var iPadSpeakerSelection: Binding<Int?>? {
get { self[IPadSpeakerSelectionKey.self] }
set { self[IPadSpeakerSelectionKey.self] = newValue }
}
}
/// Environment value carrying a `Binding<String?>` for org selection on iPad.
/// Org IDs are Firestore @DocumentID strings, not Ints.
private struct IPadOrgSelectionKey: EnvironmentKey {
static let defaultValue: Binding<String?>? = nil
}
extension EnvironmentValues {
var iPadOrgSelection: Binding<String?>? {
get { self[IPadOrgSelectionKey.self] }
set { self[IPadOrgSelectionKey.self] = newValue }
}
}
/// Environment value carrying a `Binding<Int?>` for merch product selection
/// on iPad.
private struct IPadProductSelectionKey: EnvironmentKey {
static let defaultValue: Binding<Int?>? = nil
}
extension EnvironmentValues {
var iPadProductSelection: Binding<Int?>? {
get { self[IPadProductSelectionKey.self] }
set { self[IPadProductSelectionKey.self] = newValue }
}
}
/// Environment value carrying a `Binding<UUID?>` for custom-event
/// selection on iPad. EventsView's row branch sets this when the
/// tapped event is locally-stored (event.customEventID is non-nil)
/// so the detail lands in the right pane instead of pushing onto
/// the sidebar's NavigationStack (which would replace the list).
private struct IPadCustomEventSelectionKey: EnvironmentKey {
static let defaultValue: Binding<UUID?>? = nil
}
extension EnvironmentValues {
var iPadCustomEventSelection: Binding<UUID?>? {
get { self[IPadCustomEventSelectionKey.self] }
set { self[IPadCustomEventSelectionKey.self] = newValue }
}
}
// MARK: - Notes badge plumbing
//
// EventCellView and ContentCellView each need to know "does this row
// have a saved private Note?" Per-cell @FetchRequest worked in some
// contexts but not others (LazyVStack reentrancy / SwiftUI macro
// timing). The robust fix is the same parent-publishes-into-env
// pattern we use for iPad selection bindings: the parent view holds
// a single FetchedResults<Note> + computes a Set of targetIDs, then
// publishes the set via .environment(\.noteEventIDs, ...) /
// .environment(\.noteContentIDs, ...). Cells read the value directly.
//
// Defaults to empty so cells used in screens that don't publish (e.g.
// GlobalSearchView) silently skip the pencil badge.
private struct NoteEventIDsKey: EnvironmentKey {
static let defaultValue: Set<Int32> = []
}
private struct NoteContentIDsKey: EnvironmentKey {
static let defaultValue: Set<Int32> = []
}
extension EnvironmentValues {
var noteEventIDs: Set<Int32> {
get { self[NoteEventIDsKey.self] }
set { self[NoteEventIDsKey.self] = newValue }
}
var noteContentIDs: Set<Int32> {
get { self[NoteContentIDsKey.self] }
set { self[NoteContentIDsKey.self] = newValue }
}
}