ios widget showing what is available at chucks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use more native swift ui

+216 -149
.DS_Store

This is a binary file and will not be displayed.

wasup-chucks.xcodeproj/project.xcworkspace/xcuserdata/kierank.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+20 -1
wasup-chucks/ChucksModels.swift
··· 217 217 // MARK: - TimeInterval Extension 218 218 219 219 extension TimeInterval { 220 - var countdownText: String { 220 + /// Compact format for widgets: "2h" or "45m" or "30s" 221 + var compactCountdown: String { 221 222 let totalSeconds = Int(self) 222 223 let hours = totalSeconds / 3600 223 224 let minutes = (totalSeconds % 3600) / 60 224 225 let seconds = totalSeconds % 60 225 226 226 227 if hours > 0 { 228 + return "\(hours)h" 229 + } else if minutes > 0 { 230 + return "\(minutes)m" 231 + } else { 232 + return "\(seconds)s" 233 + } 234 + } 235 + 236 + /// Expanded format for app: "2h 15m" or "45m" or "30s" 237 + var expandedCountdown: String { 238 + let totalSeconds = Int(self) 239 + let hours = totalSeconds / 3600 240 + let minutes = (totalSeconds % 3600) / 60 241 + let seconds = totalSeconds % 60 242 + 243 + if hours > 0 && minutes > 0 { 244 + return "\(hours)h \(minutes)m" 245 + } else if hours > 0 { 227 246 return "\(hours)h" 228 247 } else if minutes > 0 { 229 248 return "\(minutes)m"
+124 -95
wasup-chucks/ContentView.swift
··· 30 30 var body: some View { 31 31 NavigationStack { 32 32 ScrollView { 33 - VStack(spacing: 20) { 33 + VStack(spacing: 16) { 34 34 StatusCard(status: status) 35 35 36 36 ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) ··· 42 42 CurrentMealView(menu: todayMenu, slot: currentSlot, isOpen: status.isOpen) 43 43 } 44 44 } 45 - .padding(.horizontal) 45 + .padding(.horizontal, 16) 46 + .padding(.bottom, 16) 46 47 } 47 48 .navigationTitle("Wasup Chucks") 48 49 .onReceive(timer) { _ in ··· 81 82 struct StatusCard: View { 82 83 let status: ChucksStatus 83 84 85 + private var statusColor: Color { 86 + status.isOpen ? .green : .orange 87 + } 88 + 84 89 var body: some View { 85 90 VStack(spacing: 8) { 86 - HStack { 91 + HStack(spacing: 8) { 87 92 Image(systemName: status.isOpen ? status.currentPhase.icon : (status.nextPhase?.icon ?? "moon.zzz.fill")) 88 93 .font(.title2) 89 - .foregroundStyle(status.isOpen ? .green : .orange) 94 + .foregroundStyle(statusColor) 95 + .accessibilityHidden(true) 90 96 91 97 VStack(alignment: .leading, spacing: 2) { 92 98 Text(status.isOpen ? "Open" : "Closed") 93 - .font(.subheadline) 94 - .fontWeight(.semibold) 95 - .foregroundStyle(status.isOpen ? .green : .orange) 99 + .font(.subheadline.weight(.semibold)) 100 + .foregroundStyle(statusColor) 96 101 Text(status.isOpen ? status.currentPhase.rawValue : (status.nextPhase?.rawValue ?? "")) 97 102 .font(.caption) 98 103 .foregroundStyle(.secondary) ··· 100 105 101 106 Spacer() 102 107 } 108 + .accessibilityElement(children: .combine) 109 + .accessibilityLabel(status.isOpen ? "Chuck's is currently open for \(status.currentPhase.rawValue)" : "Chuck's is closed") 103 110 104 111 if let remaining = status.timeRemaining { 105 - Text(remaining.countdownText) 106 - .font(.system(size: 72, weight: .bold, design: .rounded)) 112 + Text(remaining.expandedCountdown) 113 + .font(.system(size: 64, weight: .bold, design: .rounded)) 107 114 .monospacedDigit() 115 + .contentTransition(.numericText()) 108 116 109 117 Text(status.isOpen ? "until \(status.currentPhase.shortName) ends" : "until \(status.nextPhase?.shortName ?? "open")") 110 118 .font(.subheadline) 111 119 .foregroundStyle(.secondary) 112 120 } 113 121 } 114 - .padding() 122 + .padding(16) 115 123 .frame(maxWidth: .infinity) 116 - .background(.regularMaterial) 117 - .clipShape(RoundedRectangle(cornerRadius: 16)) 124 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) 125 + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 2) 118 126 } 119 127 } 120 128 ··· 145 153 } 146 154 } 147 155 } 148 - .padding() 156 + .padding(16) 149 157 .frame(maxWidth: .infinity, alignment: .leading) 150 - .background(.regularMaterial) 151 - .clipShape(RoundedRectangle(cornerRadius: 16)) 158 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) 159 + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 2) 152 160 } 153 161 } 154 162 ··· 159 167 160 168 var body: some View { 161 169 Button(action: action) { 162 - VStack(spacing: 4) { 170 + VStack(spacing: 6) { 163 171 Image(systemName: meal.phase.icon) 164 172 .font(.title3) 173 + .accessibilityHidden(true) 165 174 166 175 Text(meal.phase.shortName) 167 - .font(.caption2) 168 - .fontWeight(.medium) 176 + .font(.caption.weight(.medium)) 169 177 170 178 Text("\(formatTime(meal.startHour, meal.startMinute))-\(formatTime(meal.endHour, meal.endMinute))") 171 179 .font(.caption2) 172 180 .foregroundStyle(.secondary) 173 181 } 174 182 .frame(maxWidth: .infinity) 175 - .padding(.vertical, 10) 183 + .padding(.vertical, 12) 184 + .background(isCurrent ? Color.green.opacity(0.15) : Color.clear, in: RoundedRectangle(cornerRadius: 12)) 176 185 .overlay( 177 - RoundedRectangle(cornerRadius: 10) 186 + RoundedRectangle(cornerRadius: 12) 178 187 .stroke(isCurrent ? Color.green : Color.clear, lineWidth: 2) 179 188 ) 180 189 } 181 190 .buttonStyle(.plain) 182 191 .foregroundStyle(isCurrent ? .green : .primary) 192 + .sensoryFeedback(.selection, trigger: meal.phase) 193 + .accessibilityLabel("\(meal.phase.shortName), \(formatTime(meal.startHour, meal.startMinute)) to \(formatTime(meal.endHour, meal.endMinute))\(isCurrent ? ", current meal" : "")") 194 + .accessibilityHint("Double tap to view menu") 183 195 } 184 196 185 197 func formatTime(_ hour: Int, _ minute: Int) -> String { ··· 200 212 @Environment(\.dismiss) private var dismiss 201 213 202 214 var venues: [VenueMenu] { 203 - // Only show meal-specific stations (not anytime) 204 215 menu.filter { $0.slot == meal.phase.apiSlot } 205 216 .sorted { $0.venue < $1.venue } 206 217 } 207 218 208 219 var body: some View { 209 220 NavigationStack { 210 - ScrollView { 211 - VStack(alignment: .leading, spacing: 16) { 212 - if venues.isEmpty { 213 - Text("No specific menu for \(meal.phase.rawValue)") 214 - .foregroundStyle(.secondary) 215 - .frame(maxWidth: .infinity, alignment: .center) 216 - .padding(.top, 40) 217 - } else { 221 + Group { 222 + if venues.isEmpty { 223 + ContentUnavailableView( 224 + "No Menu Available", 225 + systemImage: "fork.knife.circle", 226 + description: Text("No specific menu items for \(meal.phase.rawValue) today.") 227 + ) 228 + } else { 229 + List { 218 230 ForEach(venues) { venue in 219 - StationCard(venue: venue, highlightAsSpecial: venue.venue == "Home Cooking") 231 + Section { 232 + ForEach(venue.items) { item in 233 + HStack { 234 + Text(item.name) 235 + .font(.body) 236 + Spacer() 237 + AllergenRow(allergens: item.allergens) 238 + } 239 + .accessibilityElement(children: .combine) 240 + .accessibilityLabel("\(item.name), \(item.allergens.map { $0.alt }.joined(separator: ", "))") 241 + } 242 + } header: { 243 + HStack { 244 + if venue.venue == "Home Cooking" { 245 + Image(systemName: "star.fill") 246 + .foregroundStyle(.orange) 247 + .font(.caption2) 248 + } 249 + Text(venue.venue) 250 + } 251 + } 220 252 } 221 253 } 254 + .listStyle(.insetGrouped) 222 255 } 223 - .padding() 224 256 } 225 257 .navigationTitle(meal.phase.rawValue) 226 258 .navigationBarTitleDisplayMode(.inline) ··· 229 261 Button("Done") { 230 262 dismiss() 231 263 } 264 + .fontWeight(.semibold) 232 265 } 233 266 } 234 267 } 268 + .presentationDetents([.medium, .large]) 269 + .presentationDragIndicator(.visible) 235 270 } 236 271 } 237 272 ··· 262 297 } 263 298 264 299 var body: some View { 265 - VStack(alignment: .leading, spacing: 24) { 266 - // Meal-specific stations 300 + VStack(alignment: .leading, spacing: 16) { 267 301 if !mealSpecificVenues.isEmpty { 268 302 VStack(alignment: .leading, spacing: 12) { 269 - Label("\(mealLabel) Only", systemImage: "clock.fill") 303 + Label("\(mealLabel) Specials", systemImage: "clock.fill") 270 304 .font(.headline) 271 305 .foregroundStyle(.primary) 272 306 273 - ForEach(mealSpecificVenues, id: \.venue) { venue in 274 - VenueSection(venue: venue) 307 + ForEach(mealSpecificVenues) { venue in 308 + VenueSection(venue: venue, isHighlighted: venue.venue == "Home Cooking") 275 309 } 276 310 } 277 - .padding() 278 - .background(.regularMaterial) 279 - .clipShape(RoundedRectangle(cornerRadius: 12)) 311 + .padding(16) 312 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) 313 + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 2) 280 314 } 281 315 282 - // Divider between sections 283 316 if !mealSpecificVenues.isEmpty && !alwaysAvailableVenues.isEmpty { 284 - HStack { 285 - VStack { Divider() } 317 + HStack(spacing: 12) { 318 + Rectangle() 319 + .fill(.secondary.opacity(0.3)) 320 + .frame(height: 1) 286 321 Text("Always Available") 287 - .font(.caption) 322 + .font(.caption.weight(.medium)) 288 323 .foregroundStyle(.secondary) 289 - VStack { Divider() } 324 + Rectangle() 325 + .fill(.secondary.opacity(0.3)) 326 + .frame(height: 1) 290 327 } 291 - .padding(.vertical, 4) 328 + .padding(.vertical, 8) 292 329 } 293 330 294 - // Always available stations 295 331 if !alwaysAvailableVenues.isEmpty { 296 332 VStack(alignment: .leading, spacing: 12) { 297 - ForEach(alwaysAvailableVenues, id: \.venue) { venue in 298 - VenueSection(venue: venue) 333 + ForEach(alwaysAvailableVenues) { venue in 334 + VenueSection(venue: venue, isHighlighted: false) 299 335 } 300 336 } 301 - .padding() 302 - .background(.regularMaterial) 303 - .clipShape(RoundedRectangle(cornerRadius: 12)) 337 + .padding(16) 338 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) 339 + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 2) 304 340 } 305 341 } 306 342 } ··· 308 344 309 345 struct VenueSection: View { 310 346 let venue: VenueMenu 347 + let isHighlighted: Bool 311 348 @State private var isExpanded = true 312 349 313 350 var body: some View { 314 351 DisclosureGroup(isExpanded: $isExpanded) { 315 - VStack(alignment: .leading, spacing: 6) { 352 + VStack(alignment: .leading, spacing: 8) { 316 353 ForEach(venue.items) { item in 317 354 HStack(spacing: 8) { 318 - Text("•") 319 - .foregroundStyle(.secondary) 355 + Circle() 356 + .fill(.secondary.opacity(0.5)) 357 + .frame(width: 4, height: 4) 320 358 Text(item.name) 321 359 .font(.subheadline) 322 360 Spacer() 323 361 AllergenRow(allergens: item.allergens) 324 362 } 363 + .accessibilityElement(children: .combine) 364 + .accessibilityLabel("\(item.name)") 325 365 } 326 366 } 327 - .padding(.top, 4) 367 + .padding(.top, 8) 328 368 } label: { 329 - Text(venue.venue) 330 - .font(.subheadline) 331 - .fontWeight(.medium) 332 - } 333 - } 334 - } 335 - 336 - // MARK: - Station Card 337 - 338 - struct StationCard: View { 339 - let venue: VenueMenu 340 - let highlightAsSpecial: Bool 341 - 342 - var body: some View { 343 - VStack(alignment: .leading, spacing: 10) { 344 - HStack { 345 - if highlightAsSpecial { 369 + HStack(spacing: 6) { 370 + if isHighlighted { 346 371 Image(systemName: "star.fill") 347 372 .foregroundStyle(.orange) 348 373 .font(.caption) 374 + .accessibilityLabel("Featured") 349 375 } 350 376 Text(venue.venue) 351 - .font(.headline) 352 - Spacer() 353 - } 354 - 355 - ForEach(venue.items) { item in 356 - HStack(spacing: 8) { 357 - Text("•") 358 - .foregroundStyle(.secondary) 359 - Text(item.name) 360 - .font(.subheadline) 361 - Spacer() 362 - AllergenRow(allergens: item.allergens) 363 - } 377 + .font(.subheadline.weight(.semibold)) 364 378 } 365 379 } 366 - .padding() 367 - .frame(maxWidth: .infinity, alignment: .leading) 368 - .background(.regularMaterial) 369 - .clipShape(RoundedRectangle(cornerRadius: 12)) 380 + .sensoryFeedback(.selection, trigger: isExpanded) 370 381 } 371 382 } 372 383 ··· 377 388 378 389 var body: some View { 379 390 if !allergens.isEmpty { 380 - HStack(spacing: 2) { 391 + HStack(spacing: 4) { 381 392 ForEach(allergens, id: \.alt) { allergen in 382 393 AllergenBadge(allergen: allergen) 383 394 } 384 395 } 396 + .accessibilityElement(children: .combine) 397 + .accessibilityLabel("Contains: \(allergens.map { allergenName($0.alt) }.joined(separator: ", "))") 398 + } 399 + } 400 + 401 + private func allergenName(_ alt: String) -> String { 402 + switch alt { 403 + case "gluten": return "gluten" 404 + case "dairy": return "dairy" 405 + case "egg": return "egg" 406 + case "soy": return "soy" 407 + case "fish": return "fish" 408 + case "hasPeanut": return "peanuts" 409 + case "tree nut": return "tree nuts" 410 + case "hasShellfish": return "shellfish" 411 + case "vegetarian": return "vegetarian" 412 + case "gluten-free": return "gluten-free" 413 + default: return alt 385 414 } 386 415 } 387 416 } ··· 414 443 415 444 var body: some View { 416 445 Text(symbol) 417 - .font(.system(size: 8, weight: .bold)) 446 + .font(.system(size: 9, weight: .bold, design: .rounded)) 418 447 .foregroundStyle(color) 419 - .padding(.horizontal, 4) 420 - .padding(.vertical, 2) 421 - .background(color.opacity(0.15)) 422 - .clipShape(RoundedRectangle(cornerRadius: 4)) 448 + .padding(.horizontal, 5) 449 + .padding(.vertical, 3) 450 + .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 4)) 451 + .accessibilityHidden(true) 423 452 } 424 453 } 425 454
+72 -53
widget/widget.swift
··· 208 208 } 209 209 210 210 extension TimeInterval { 211 - var countdownText: String { 211 + /// Compact format for widgets: "2h" or "45m" or "30s" 212 + var compactCountdown: String { 212 213 let totalSeconds = Int(self) 213 214 let hours = totalSeconds / 3600 214 215 let minutes = (totalSeconds % 3600) / 60 ··· 264 265 return menu 265 266 } 266 267 267 - func getSpecials(for date: Date, phase: MealPhase) async throws -> [MenuItem] { 268 + func getSpecials(for date: Date, phase: MealPhase) async throws -> (items: [MenuItem], venueName: String) { 269 + let venueName = "Home Cooking" 268 270 let menu = try await fetchMenu() 269 271 let dateFormatter = DateFormatter() 270 272 dateFormatter.dateFormat = "yyyy-MM-dd" ··· 272 274 let dateKey = dateFormatter.string(from: date) 273 275 274 276 guard let dayMenu = menu[dateKey] else { 275 - return [] 277 + return ([], venueName) 276 278 } 277 279 278 - let homeCooking = dayMenu.filter { $0.venue == "Home Cooking" && $0.slot == phase.apiSlot } 279 - return homeCooking.flatMap { $0.items } 280 + let venue = dayMenu.filter { $0.venue == venueName && $0.slot == phase.apiSlot } 281 + return (venue.flatMap { $0.items }, venueName) 280 282 } 281 283 } 282 284 ··· 292 294 let date: Date 293 295 let status: ChucksStatus 294 296 let specials: [MenuItem] 297 + let venueName: String 295 298 } 296 299 297 300 struct ChucksProvider: TimelineProvider { ··· 299 302 ChucksEntry( 300 303 date: Date(), 301 304 status: ChucksStatus.calculate(), 302 - specials: [] 305 + specials: [], 306 + venueName: "Home Cooking" 303 307 ) 304 308 } 305 309 ··· 307 311 let entry = ChucksEntry( 308 312 date: Date(), 309 313 status: ChucksStatus.calculate(), 310 - specials: [] 314 + specials: [], 315 + venueName: "Home Cooking" 311 316 ) 312 317 completion(entry) 313 318 } ··· 316 321 Task { 317 322 let status = ChucksStatus.calculate() 318 323 var specials: [MenuItem] = [] 324 + var venueName = "Home Cooking" 319 325 320 326 let phase = status.isOpen ? status.currentPhase : (status.nextPhase ?? .lunch) 321 327 let menuDate = status.isOpen ? Date() : (status.nextPhaseStart ?? Date()) 322 328 if phase != .closed { 323 329 do { 324 - specials = try await ChucksService.shared.getSpecials(for: menuDate, phase: phase) 330 + let result = try await ChucksService.shared.getSpecials(for: menuDate, phase: phase) 331 + specials = result.items 332 + venueName = result.venueName 325 333 } catch { 326 334 print("Failed to fetch specials: \(error)") 327 335 } ··· 330 338 let entry = ChucksEntry( 331 339 date: Date(), 332 340 status: status, 333 - specials: specials 341 + specials: specials, 342 + venueName: venueName 334 343 ) 335 344 336 345 let nextUpdate: Date ··· 353 362 struct SmallWidgetView: View { 354 363 let entry: ChucksEntry 355 364 365 + private var statusColor: Color { 366 + entry.status.isOpen ? .green : .orange 367 + } 368 + 356 369 var body: some View { 357 370 ZStack { 358 - // Status indicator in top-left corner 359 371 VStack { 360 - HStack { 372 + HStack(spacing: 4) { 361 373 Image(systemName: entry.status.isOpen ? entry.status.currentPhase.icon : (entry.status.nextPhase?.icon ?? "moon.zzz.fill")) 362 - .foregroundStyle(entry.status.isOpen ? .green : .orange) 374 + .foregroundStyle(statusColor) 375 + .accessibilityHidden(true) 363 376 Text(entry.status.isOpen ? "Open" : "Closed") 364 - .font(.caption) 365 - .fontWeight(.semibold) 366 - .foregroundStyle(entry.status.isOpen ? .green : .orange) 377 + .font(.caption.weight(.semibold)) 378 + .foregroundStyle(statusColor) 367 379 Spacer() 368 380 } 381 + .accessibilityElement(children: .combine) 382 + .accessibilityLabel(entry.status.isOpen ? "Chuck's is open for \(entry.status.currentPhase.shortName)" : "Chuck's is closed") 369 383 Spacer() 370 384 } 371 385 372 - // Centered countdown 373 386 VStack(spacing: 4) { 374 387 if let remaining = entry.status.timeRemaining { 375 - Text(remaining.countdownText) 388 + Text(remaining.compactCountdown) 376 389 .font(.system(size: 48, weight: .bold, design: .rounded)) 377 390 .monospacedDigit() 378 391 .minimumScaleFactor(0.5) ··· 401 414 struct MediumWidgetView: View { 402 415 let entry: ChucksEntry 403 416 417 + private var statusColor: Color { 418 + entry.status.isOpen ? .green : .orange 419 + } 420 + 404 421 var body: some View { 405 - HStack(spacing: 12) { 406 - // Left side - big countdown 422 + HStack(spacing: 16) { 407 423 VStack(spacing: 4) { 408 - HStack { 424 + HStack(spacing: 4) { 409 425 Image(systemName: entry.status.isOpen ? entry.status.currentPhase.icon : (entry.status.nextPhase?.icon ?? "moon.zzz.fill")) 410 - .foregroundStyle(entry.status.isOpen ? .green : .orange) 426 + .foregroundStyle(statusColor) 427 + .accessibilityHidden(true) 411 428 Text(entry.status.isOpen ? "Open" : "Closed") 412 - .font(.caption) 413 - .fontWeight(.semibold) 414 - .foregroundStyle(entry.status.isOpen ? .green : .orange) 429 + .font(.caption.weight(.semibold)) 430 + .foregroundStyle(statusColor) 415 431 } 432 + .accessibilityElement(children: .combine) 433 + .accessibilityLabel(entry.status.isOpen ? "Chuck's is open" : "Chuck's is closed") 416 434 417 435 if let remaining = entry.status.timeRemaining { 418 - Text(remaining.countdownText) 436 + Text(remaining.compactCountdown) 419 437 .font(.system(size: 44, weight: .bold, design: .rounded)) 420 438 .monospacedDigit() 421 439 .minimumScaleFactor(0.5) ··· 431 449 .font(.caption2) 432 450 .foregroundStyle(.secondary) 433 451 } 434 - 435 452 } 436 453 .frame(maxWidth: .infinity) 437 454 438 455 Divider() 439 456 440 - // Right side - specials list 441 - VStack(alignment: .leading, spacing: 2) { 442 - Text("Home Cooking") 443 - .font(.caption) 444 - .fontWeight(.semibold) 457 + VStack(alignment: .leading, spacing: 4) { 458 + Text(entry.venueName) 459 + .font(.caption.weight(.semibold)) 445 460 .foregroundStyle(.secondary) 446 - .padding(.bottom, 2) 447 461 448 462 if entry.specials.isEmpty { 449 463 Spacer() ··· 452 466 .foregroundStyle(.tertiary) 453 467 Spacer() 454 468 } else { 455 - ForEach(entry.specials) { item in 469 + ForEach(entry.specials.prefix(4)) { item in 456 470 Text("• \(item.name)") 457 471 .font(.caption2) 472 + .lineLimit(1) 458 473 } 459 474 Spacer(minLength: 0) 460 475 } 461 476 } 462 477 .frame(maxWidth: .infinity, alignment: .leading) 478 + .accessibilityElement(children: .combine) 479 + .accessibilityLabel("Today's specials from \(entry.venueName): \(entry.specials.map { $0.name }.joined(separator: ", "))") 463 480 } 464 481 } 465 482 } ··· 467 484 // MARK: - Large Widget View 468 485 struct LargeWidgetView: View { 469 486 let entry: ChucksEntry 487 + 488 + private var statusColor: Color { 489 + entry.status.isOpen ? .green : .orange 490 + } 470 491 471 492 var body: some View { 472 493 VStack(spacing: 16) { 473 - // Top section - status and countdown 474 494 HStack(alignment: .center) { 475 - // Left - status 476 - HStack { 495 + HStack(spacing: 8) { 477 496 Image(systemName: entry.status.isOpen ? entry.status.currentPhase.icon : (entry.status.nextPhase?.icon ?? "moon.zzz.fill")) 478 497 .font(.title2) 479 - .foregroundStyle(entry.status.isOpen ? .green : .orange) 498 + .foregroundStyle(statusColor) 499 + .accessibilityHidden(true) 480 500 481 501 VStack(alignment: .leading, spacing: 2) { 482 502 Text(entry.status.isOpen ? "Open" : "Closed") 483 - .font(.caption) 484 - .fontWeight(.semibold) 485 - .foregroundStyle(entry.status.isOpen ? .green : .orange) 503 + .font(.caption.weight(.semibold)) 504 + .foregroundStyle(statusColor) 486 505 487 506 Text(entry.status.isOpen ? entry.status.currentPhase.shortName : (entry.status.nextPhase?.shortName ?? "")) 488 - .font(.headline) 489 - .fontWeight(.bold) 507 + .font(.headline.weight(.bold)) 490 508 } 491 509 } 510 + .accessibilityElement(children: .combine) 511 + .accessibilityLabel(entry.status.isOpen ? "Chuck's is open for \(entry.status.currentPhase.shortName)" : "Chuck's is closed, next meal is \(entry.status.nextPhase?.shortName ?? "tomorrow")") 492 512 493 513 Spacer() 494 514 495 - // Right - big countdown 496 515 if let remaining = entry.status.timeRemaining { 497 516 VStack(alignment: .trailing, spacing: 2) { 498 - Text(remaining.countdownText) 517 + Text(remaining.compactCountdown) 499 518 .font(.system(size: 48, weight: .bold, design: .rounded)) 500 519 .monospacedDigit() 501 520 ··· 508 527 509 528 Divider() 510 529 511 - // Bottom section - specials 512 530 VStack(alignment: .leading, spacing: 8) { 513 - Text("Home Cooking") 514 - .font(.subheadline) 515 - .fontWeight(.semibold) 531 + Text(entry.venueName) 532 + .font(.subheadline.weight(.semibold)) 516 533 .foregroundStyle(.secondary) 517 534 518 535 if entry.specials.isEmpty { ··· 526 543 } 527 544 Spacer() 528 545 } else { 529 - VStack(alignment: .leading, spacing: 4) { 530 - ForEach(entry.specials) { item in 546 + VStack(alignment: .leading, spacing: 6) { 547 + ForEach(entry.specials.prefix(6)) { item in 531 548 Text("• \(item.name)") 532 549 .font(.callout) 533 550 } ··· 535 552 } 536 553 } 537 554 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 555 + .accessibilityElement(children: .combine) 556 + .accessibilityLabel("Today's specials from \(entry.venueName): \(entry.specials.map { $0.name }.joined(separator: ", "))") 538 557 } 539 558 } 540 559 } ··· 576 595 #Preview("Small", as: .systemSmall) { 577 596 ChucksWidget() 578 597 } timeline: { 579 - ChucksEntry(date: Date(), status: ChucksStatus.calculate(), specials: []) 598 + ChucksEntry(date: Date(), status: ChucksStatus.calculate(), specials: [], venueName: "Home Cooking") 580 599 } 581 600 582 601 #Preview("Medium", as: .systemMedium) { ··· 587 606 MenuItem(name: "Sausage Patties", allergens: []), 588 607 MenuItem(name: "Tater Tots", allergens: []), 589 608 MenuItem(name: "Biscuits", allergens: []) 590 - ]) 609 + ], venueName: "Home Cooking") 591 610 } 592 611 593 612 #Preview("Large", as: .systemLarge) { ··· 600 619 MenuItem(name: "Biscuits", allergens: []), 601 620 MenuItem(name: "Country Gravy", allergens: []), 602 621 MenuItem(name: "Hash Browns", allergens: []) 603 - ]) 622 + ], venueName: "Home Cooking") 604 623 }