Commit 9d4296fb07
Unsigned
Layout: unified · split
HutchWidgetExtension/NeedsAttentionWidget.swift +106 −26
| @@ -44,7 +44,7 @@ struct NeedsAttentionWidget: Widget { | ||
| 44 | 44 | } |
| 45 | 45 | .configurationDisplayName("Needs Attention") |
| 46 | 46 | .description("Unread inbox threads, assigned tickets, and failed builds.") |
| 47 | .supportedFamilies([.systemSmall, .systemMedium]) | |
| 47 | .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) | |
| 48 | 48 | } |
| 49 | 49 | } |
| 50 | 50 | |
| @@ -61,6 +61,8 @@ private struct NeedsAttentionWidgetView: View { | ||
| 61 | 61 | switch family { |
| 62 | 62 | case .systemMedium: |
| 63 | 63 | mediumView(snapshot: snapshot) |
| 64 | case .systemLarge: | |
| 65 | largeView(snapshot: snapshot) | |
| 64 | 66 | default: |
| 65 | 67 | smallView(snapshot: snapshot) |
| 66 | 68 | } |
| @@ -75,17 +77,6 @@ private struct NeedsAttentionWidgetView: View { | ||
| 75 | 77 | } |
| 76 | 78 | } |
| 77 | 79 | |
| 78 | private var header: some View { | |
| 79 | VStack(alignment: .leading, spacing: 1) { | |
| 80 | Text("Hutch") | |
| 81 | .font(.caption.weight(.semibold)) | |
| 82 | .foregroundStyle(.secondary) | |
| 83 | Text("Needs Attention") | |
| 84 | .font(.headline) | |
| 85 | .lineLimit(1) | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | 80 | private func smallView(snapshot: NeedsAttentionSnapshot) -> some View { |
| 90 | 81 | VStack(alignment: .leading, spacing: 12) { |
| 91 | 82 | Spacer(minLength: 0) |
| @@ -108,9 +99,7 @@ private struct NeedsAttentionWidgetView: View { | ||
| 108 | 99 | } |
| 109 | 100 | |
| 110 | 101 | private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { |
| 111 | VStack(alignment: .leading, spacing: 18) { | |
| 112 | header | |
| 113 | .padding(.top, 6) | |
| 102 | VStack(alignment: .leading, spacing: 0) { | |
| 114 | 103 | HStack(alignment: .top, spacing: 12) { |
| 115 | 104 | metricColumn( |
| 116 | 105 | count: snapshot.unreadInboxThreads, |
| @@ -137,23 +126,47 @@ private struct NeedsAttentionWidgetView: View { | ||
| 137 | 126 | .padding(.bottom, 16) |
| 138 | 127 | } |
| 139 | 128 | |
| 140 | private var clearState: some View { | |
| 141 | VStack(alignment: .leading, spacing: 12) { | |
| 142 | header | |
| 129 | private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { | |
| 130 | VStack(alignment: .leading, spacing: 18) { | |
| 131 | metricRow( | |
| 132 | count: snapshot.unreadInboxThreads, | |
| 133 | label: "Unread threads", | |
| 134 | systemImage: "tray", | |
| 135 | tint: unreadTint(for: snapshot.unreadInboxThreads) | |
| 136 | ) | |
| 137 | metricRow( | |
| 138 | count: snapshot.assignedOpenTickets, | |
| 139 | label: "Assigned tickets", | |
| 140 | systemImage: "ticket" | |
| 141 | ) | |
| 142 | metricRow( | |
| 143 | count: snapshot.failedBuilds, | |
| 144 | label: "Failed builds", | |
| 145 | systemImage: "hammer", | |
| 146 | tint: failedTint(for: snapshot.failedBuilds) | |
| 147 | ) | |
| 143 | 148 | Spacer(minLength: 0) |
| 144 | Text("All clear") | |
| 145 | .font(.title3.weight(.semibold)) | |
| 146 | Text("No unread threads, assigned tickets, or failed builds.") | |
| 147 | .font(.caption) | |
| 148 | .foregroundStyle(.secondary) | |
| 149 | .fixedSize(horizontal: false, vertical: true) | |
| 150 | 149 | } |
| 151 | .padding() | |
| 150 | .padding(20) | |
| 151 | } | |
| 152 | ||
| 153 | private var clearState: some View { | |
| 154 | Group { | |
| 155 | switch family { | |
| 156 | case .systemSmall: | |
| 157 | smallClearState | |
| 158 | case .systemMedium: | |
| 159 | mediumClearState | |
| 160 | case .systemLarge: | |
| 161 | largeClearState | |
| 162 | default: | |
| 163 | smallClearState | |
| 164 | } | |
| 165 | } | |
| 152 | 166 | } |
| 153 | 167 | |
| 154 | 168 | private var fallbackState: some View { |
| 155 | 169 | VStack(alignment: .leading, spacing: 12) { |
| 156 | header | |
| 157 | 170 | Spacer(minLength: 0) |
| 158 | 171 | Text("Open Hutch") |
| 159 | 172 | .font(.title3.weight(.semibold)) |
| @@ -202,6 +215,73 @@ private struct NeedsAttentionWidgetView: View { | ||
| 202 | 215 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| 203 | 216 | } |
| 204 | 217 | |
| 218 | private func metricRow(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View { | |
| 219 | HStack(alignment: .center, spacing: 12) { | |
| 220 | Image(systemName: systemImage) | |
| 221 | .font(.title3.weight(.semibold)) | |
| 222 | .foregroundStyle(tint) | |
| 223 | .frame(width: 24, alignment: .leading) | |
| 224 | Text(displayCount(count)) | |
| 225 | .font(.system(size: 30, weight: .semibold, design: .rounded)) | |
| 226 | .monospacedDigit() | |
| 227 | .foregroundStyle(tint) | |
| 228 | Text(label) | |
| 229 | .font(.body) | |
| 230 | .foregroundStyle(.secondary) | |
| 231 | Spacer(minLength: 0) | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | private var smallClearState: some View { | |
| 236 | VStack(spacing: 6) { | |
| 237 | Spacer(minLength: 0) | |
| 238 | Image(systemName: "checkmark.circle.fill") | |
| 239 | .font(.system(size: 52, weight: .regular)) | |
| 240 | .foregroundStyle(clearStateTint) | |
| 241 | Text("0 items") | |
| 242 | .font(.caption2.weight(.semibold)) | |
| 243 | .foregroundStyle(.secondary) | |
| 244 | .lineLimit(1) | |
| 245 | Spacer(minLength: 0) | |
| 246 | } | |
| 247 | .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 248 | .padding() | |
| 249 | } | |
| 250 | ||
| 251 | private var mediumClearState: some View { | |
| 252 | VStack(spacing: 10) { | |
| 253 | Image(systemName: "checkmark.circle.fill") | |
| 254 | .font(.system(size: 26, weight: .regular)) | |
| 255 | .foregroundStyle(clearStateTint) | |
| 256 | Text("Nothing to review") | |
| 257 | .font(.body.weight(.semibold)) | |
| 258 | .foregroundStyle(.primary) | |
| 259 | .lineLimit(1) | |
| 260 | .minimumScaleFactor(0.9) | |
| 261 | } | |
| 262 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) | |
| 263 | .padding() | |
| 264 | } | |
| 265 | ||
| 266 | private var largeClearState: some View { | |
| 267 | VStack(spacing: 12) { | |
| 268 | Image(systemName: "checkmark.circle.fill") | |
| 269 | .font(.system(size: 38, weight: .regular)) | |
| 270 | .foregroundStyle(clearStateTint) | |
| 271 | Text("Nothing to review") | |
| 272 | .font(.title3.weight(.semibold)) | |
| 273 | .foregroundStyle(.primary) | |
| 274 | .lineLimit(1) | |
| 275 | .minimumScaleFactor(0.9) | |
| 276 | } | |
| 277 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) | |
| 278 | .padding() | |
| 279 | } | |
| 280 | ||
| 281 | private var clearStateTint: Color { | |
| 282 | Color(.systemGreen).opacity(0.75) | |
| 283 | } | |
| 284 | ||
| 205 | 285 | private func displayCount(_ count: Int?) -> String { |
| 206 | 286 | guard let count else { return "—" } |
| 207 | 287 | return "\(count)" |