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: add favorites

+260 -19
+53
ios/Shared/ChucksShared.swift
··· 6 6 // 7 7 8 8 import Foundation 9 + public import Combine 9 10 10 11 // MARK: - API Models 11 12 ··· 283 284 } else { 284 285 return "\(seconds)s" 285 286 } 287 + } 288 + } 289 + 290 + // MARK: - Favorites Store 291 + 292 + public class FavoritesStore: ObservableObject { 293 + private static let appGroupID = "group.sh.dunkirk.wasup-chucks" 294 + 295 + private static var defaults: UserDefaults { 296 + UserDefaults(suiteName: appGroupID) ?? .standard 297 + } 298 + 299 + private static let itemsKey = "favoriteItems" 300 + private static let keywordsKey = "favoriteKeywords" 301 + 302 + @Published public var favoriteItems: Set<String> { 303 + didSet { Self.defaults.set(Array(favoriteItems), forKey: Self.itemsKey) } 304 + } 305 + 306 + @Published public var favoriteKeywords: Set<String> { 307 + didSet { Self.defaults.set(Array(favoriteKeywords), forKey: Self.keywordsKey) } 308 + } 309 + 310 + public init() { 311 + let items = Self.defaults.stringArray(forKey: Self.itemsKey) ?? [] 312 + let keywords = Self.defaults.stringArray(forKey: Self.keywordsKey) ?? [] 313 + self.favoriteItems = Set(items) 314 + self.favoriteKeywords = Set(keywords) 315 + } 316 + 317 + public func isFavorite(_ item: MenuItem) -> Bool { 318 + if favoriteItems.contains(item.name) { return true } 319 + let lowered = item.name.lowercased() 320 + return favoriteKeywords.contains { lowered.contains($0.lowercased()) } 321 + } 322 + 323 + public func toggleItem(_ name: String) { 324 + if favoriteItems.contains(name) { 325 + favoriteItems.remove(name) 326 + } else { 327 + favoriteItems.insert(name) 328 + } 329 + } 330 + 331 + public func addKeyword(_ keyword: String) { 332 + let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) 333 + guard !trimmed.isEmpty else { return } 334 + favoriteKeywords.insert(trimmed) 335 + } 336 + 337 + public func removeKeyword(_ keyword: String) { 338 + favoriteKeywords.remove(keyword) 286 339 } 287 340 } 288 341
+4 -4
ios/wasup-chucks.xcodeproj/project.pbxproj
··· 386 386 ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 387 387 CODE_SIGN_ENTITLEMENTS = "wasup-chucks/wasup-chucks.entitlements"; 388 388 CODE_SIGN_STYLE = Automatic; 389 - CURRENT_PROJECT_VERSION = 4; 389 + CURRENT_PROJECT_VERSION = 5; 390 390 DEVELOPMENT_TEAM = M67B42LX8D; 391 391 ENABLE_PREVIEWS = YES; 392 392 GENERATE_INFOPLIST_FILE = YES; ··· 422 422 ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 423 423 CODE_SIGN_ENTITLEMENTS = "wasup-chucks/wasup-chucks.entitlements"; 424 424 CODE_SIGN_STYLE = Automatic; 425 - CURRENT_PROJECT_VERSION = 4; 425 + CURRENT_PROJECT_VERSION = 5; 426 426 DEVELOPMENT_TEAM = M67B42LX8D; 427 427 ENABLE_PREVIEWS = YES; 428 428 GENERATE_INFOPLIST_FILE = YES; ··· 457 457 ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; 458 458 CODE_SIGN_ENTITLEMENTS = widgetExtension.entitlements; 459 459 CODE_SIGN_STYLE = Automatic; 460 - CURRENT_PROJECT_VERSION = 4; 460 + CURRENT_PROJECT_VERSION = 5; 461 461 DEVELOPMENT_TEAM = M67B42LX8D; 462 462 GENERATE_INFOPLIST_FILE = YES; 463 463 INFOPLIST_FILE = widget/Info.plist; ··· 488 488 ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; 489 489 CODE_SIGN_ENTITLEMENTS = widgetExtension.entitlements; 490 490 CODE_SIGN_STYLE = Automatic; 491 - CURRENT_PROJECT_VERSION = 4; 491 + CURRENT_PROJECT_VERSION = 5; 492 492 DEVELOPMENT_TEAM = M67B42LX8D; 493 493 GENERATE_INFOPLIST_FILE = YES; 494 494 INFOPLIST_FILE = widget/Info.plist;
+203 -15
ios/wasup-chucks/ContentView.swift
··· 19 19 @State private var isLoading = true 20 20 @State private var loadError: Error? = nil 21 21 @State private var selectedMeal: MealSchedule? = nil 22 + @StateObject private var favoritesStore = FavoritesStore() 23 + @State private var showFavoritesManager = false 22 24 @Environment(\.horizontalSizeClass) private var horizontalSizeClass 23 25 24 26 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() ··· 93 95 .animation(.easeInOut, value: selectedDateIndex) 94 96 } 95 97 ToolbarItem(placement: .topBarTrailing) { 98 + HStack(spacing: 12) { 99 + Button { 100 + showFavoritesManager = true 101 + } label: { 102 + Image(systemName: "star.fill") 103 + } 104 + .tint(.orange) 105 + Button { 106 + withAnimation { selectedDateIndex += 1 } 107 + selectedFutureMeal = .breakfast 108 + } label: { 109 + Image(systemName: "chevron.right") 110 + } 111 + .disabled(selectedDateIndex >= availableDates.count - 1) 112 + } 113 + } 114 + } else { 115 + ToolbarItem(placement: .topBarTrailing) { 96 116 Button { 97 - withAnimation { selectedDateIndex += 1 } 98 - selectedFutureMeal = .breakfast 117 + showFavoritesManager = true 99 118 } label: { 100 - Image(systemName: "chevron.right") 119 + Image(systemName: "star.fill") 101 120 } 102 - .disabled(selectedDateIndex >= availableDates.count - 1) 121 + .tint(.orange) 103 122 } 104 123 } 105 124 } ··· 110 129 await loadMenu() 111 130 } 112 131 .sheet(item: $selectedMeal) { meal in 113 - MealDetailSheet(meal: meal, menu: todayMenu) 132 + MealDetailSheet(meal: meal, menu: todayMenu, favoritesStore: favoritesStore) 133 + } 134 + .sheet(isPresented: $showFavoritesManager) { 135 + FavoritesManagerSheet(favoritesStore: favoritesStore) 114 136 } 115 137 } 116 138 } ··· 128 150 isLoading: isLoading, 129 151 loadError: loadError, 130 152 isRegularWidth: isRegularWidth, 153 + favoritesStore: favoritesStore, 131 154 onRetry: { Task { await loadMenu() } } 132 155 ) 133 156 } else { ··· 138 161 isLoading: isLoading, 139 162 loadError: loadError, 140 163 isRegularWidth: isRegularWidth, 164 + favoritesStore: favoritesStore, 141 165 onRetry: { Task { await loadMenu() } } 142 166 ) 143 167 } ··· 210 234 let isLoading: Bool 211 235 let loadError: Error? 212 236 let isRegularWidth: Bool 237 + @ObservedObject var favoritesStore: FavoritesStore 213 238 let onRetry: () -> Void 214 239 215 240 var body: some View { ··· 233 258 } else if let error = loadError { 234 259 ErrorCard(error: error, retry: onRetry) 235 260 } else { 236 - CurrentMealView(menu: todayMenu, slot: currentSlot, isOpen: status.isOpen, isRegularWidth: isRegularWidth) 261 + CurrentMealView(menu: todayMenu, slot: currentSlot, isOpen: status.isOpen, isRegularWidth: isRegularWidth, favoritesStore: favoritesStore) 237 262 } 238 263 } 239 264 } ··· 246 271 let isLoading: Bool 247 272 let loadError: Error? 248 273 let isRegularWidth: Bool 274 + @ObservedObject var favoritesStore: FavoritesStore 249 275 let onRetry: () -> Void 250 276 251 277 var body: some View { ··· 261 287 } else if let error = loadError { 262 288 ErrorCard(error: error, retry: onRetry) 263 289 } else { 264 - CurrentMealView(menu: menu, slot: selectedFutureMeal.apiSlot, isOpen: true, isRegularWidth: isRegularWidth) 290 + CurrentMealView(menu: menu, slot: selectedFutureMeal.apiSlot, isOpen: true, isRegularWidth: isRegularWidth, favoritesStore: favoritesStore) 265 291 } 266 292 } 267 293 } ··· 482 508 struct MealDetailSheet: View { 483 509 let meal: MealSchedule 484 510 let menu: [VenueMenu] 511 + @ObservedObject var favoritesStore: FavoritesStore 485 512 @Environment(\.dismiss) private var dismiss 486 - 513 + 487 514 var venues: [VenueMenu] { 488 515 menu.filter { $0.slot == meal.phase.apiSlot } 489 516 .sorted { $0.venue < $1.venue } 490 517 } 491 - 518 + 492 519 var body: some View { 493 520 NavigationStack { 494 521 Group { ··· 519 546 Section { 520 547 ForEach(venue.items) { item in 521 548 HStack { 549 + Button { 550 + favoritesStore.toggleItem(item.name) 551 + } label: { 552 + Image(systemName: favoritesStore.isFavorite(item) ? "star.fill" : "star") 553 + .foregroundStyle(favoritesStore.isFavorite(item) ? .orange : .secondary) 554 + .font(.subheadline) 555 + } 556 + .buttonStyle(.plain) 522 557 Text(item.name) 523 558 .font(.body) 524 559 Spacer() ··· 558 593 let slot: String 559 594 let isOpen: Bool 560 595 let isRegularWidth: Bool 596 + @ObservedObject var favoritesStore: FavoritesStore 561 597 562 598 var mealSpecificVenues: [VenueMenu] { 563 599 menu.filter { $0.slot == slot } ··· 578 614 } 579 615 } 580 616 617 + var favoriteMatches: [(item: MenuItem, venueName: String)] { 618 + let allVenues = mealSpecificVenues + alwaysAvailableVenues 619 + var matches: [(item: MenuItem, venueName: String)] = [] 620 + for venue in allVenues { 621 + for item in venue.items where favoritesStore.isFavorite(item) { 622 + matches.append((item: item, venueName: venue.venue)) 623 + } 624 + } 625 + return matches 626 + } 627 + 581 628 var body: some View { 582 629 VStack(alignment: .leading, spacing: 16) { 630 + // Your Favorites Section 631 + if !favoriteMatches.isEmpty { 632 + VStack(alignment: .leading, spacing: 12) { 633 + Label("Your Favorites", systemImage: "star.fill") 634 + .font(.headline) 635 + .foregroundStyle(.orange) 636 + .padding(.horizontal, 4) 637 + 638 + VStack(alignment: .leading, spacing: 8) { 639 + ForEach(favoriteMatches, id: \.item.id) { match in 640 + HStack(spacing: 8) { 641 + Image(systemName: "star.fill") 642 + .foregroundStyle(.orange) 643 + .font(.caption) 644 + VStack(alignment: .leading, spacing: 2) { 645 + Text(match.item.name) 646 + .font(.subheadline) 647 + Text(match.venueName) 648 + .font(.caption) 649 + .foregroundStyle(.secondary) 650 + } 651 + Spacer() 652 + AllergenRow(allergens: match.item.allergens) 653 + } 654 + } 655 + } 656 + .padding(16) 657 + .frame(maxWidth: .infinity, alignment: .leading) 658 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) 659 + .shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2) 660 + } 661 + } 662 + 583 663 // Meal Specials Section 584 664 if !mealSpecificVenues.isEmpty { 585 665 VStack(alignment: .leading, spacing: 12) { ··· 589 669 .padding(.horizontal, 4) 590 670 591 671 MasonryLayout(mealSpecificVenues, columns: isRegularWidth ? 2 : 1, spacing: 12) { venue in 592 - VenueCard(venue: venue) 672 + VenueCard(venue: venue, favoritesStore: favoritesStore) 593 673 } 594 674 } 595 675 } ··· 613 693 } 614 694 615 695 MasonryLayout(alwaysAvailableVenues, columns: isRegularWidth ? 2 : 1, spacing: 12) { venue in 616 - VenueCard(venue: venue) 696 + VenueCard(venue: venue, favoritesStore: favoritesStore) 617 697 } 618 698 } 619 699 } ··· 665 745 666 746 struct VenueCard: View { 667 747 let venue: VenueMenu 748 + @ObservedObject var favoritesStore: FavoritesStore 668 749 @State private var isExpanded = true 669 750 670 751 var body: some View { 671 752 DisclosureGroup(isExpanded: $isExpanded) { 672 753 VStack(alignment: .leading, spacing: 8) { 673 754 ForEach(venue.items) { item in 755 + let isFav = favoritesStore.isFavorite(item) 674 756 HStack(spacing: 8) { 675 - Circle() 676 - .fill(.secondary.opacity(0.5)) 677 - .frame(width: 4, height: 4) 757 + Button { 758 + favoritesStore.toggleItem(item.name) 759 + } label: { 760 + Image(systemName: isFav ? "star.fill" : "star") 761 + .foregroundStyle(isFav ? .orange : .secondary) 762 + .font(.caption) 763 + } 764 + .buttonStyle(.plain) 678 765 Text(item.name) 679 766 .font(.subheadline) 680 767 Spacer() 681 768 AllergenRow(allergens: item.allergens) 682 769 } 770 + .padding(.vertical, 2) 771 + .padding(.horizontal, 4) 772 + .background(isFav ? Color.orange.opacity(0.08) : Color.clear, in: RoundedRectangle(cornerRadius: 6)) 683 773 .accessibilityElement(children: .combine) 684 - .accessibilityLabel("\(item.name)") 774 + .accessibilityLabel("\(item.name)\(isFav ? ", favorited" : "")") 685 775 } 686 776 } 687 777 .padding(.top, 8) ··· 695 785 .frame(maxWidth: .infinity, alignment: .leading) 696 786 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) 697 787 .shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2) 788 + } 789 + } 790 + 791 + // MARK: - Favorites Manager Sheet 792 + 793 + struct FavoritesManagerSheet: View { 794 + @ObservedObject var favoritesStore: FavoritesStore 795 + @Environment(\.dismiss) private var dismiss 796 + @State private var newKeyword = "" 797 + 798 + var sortedKeywords: [String] { 799 + favoritesStore.favoriteKeywords.sorted() 800 + } 801 + 802 + var sortedItems: [String] { 803 + favoritesStore.favoriteItems.sorted() 804 + } 805 + 806 + var body: some View { 807 + NavigationStack { 808 + List { 809 + Section { 810 + HStack { 811 + TextField("Add keyword (e.g. fish, pizza)", text: $newKeyword) 812 + .textInputAutocapitalization(.never) 813 + .autocorrectionDisabled() 814 + .submitLabel(.done) 815 + .onSubmit { addKeyword() } 816 + Button { 817 + addKeyword() 818 + } label: { 819 + Image(systemName: "plus.circle.fill") 820 + .foregroundStyle(.orange) 821 + } 822 + .disabled(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 823 + } 824 + } header: { 825 + Text("Keywords") 826 + } footer: { 827 + Text("Items containing a keyword will be highlighted as favorites.") 828 + } 829 + 830 + if !sortedKeywords.isEmpty { 831 + Section("Current Keywords") { 832 + ForEach(sortedKeywords, id: \.self) { keyword in 833 + HStack { 834 + Image(systemName: "tag.fill") 835 + .foregroundStyle(.orange) 836 + .font(.caption) 837 + Text(keyword) 838 + } 839 + } 840 + .onDelete { offsets in 841 + for index in offsets { 842 + favoritesStore.removeKeyword(sortedKeywords[index]) 843 + } 844 + } 845 + } 846 + } 847 + 848 + if !sortedItems.isEmpty { 849 + Section("Favorited Items") { 850 + ForEach(sortedItems, id: \.self) { item in 851 + HStack { 852 + Image(systemName: "star.fill") 853 + .foregroundStyle(.orange) 854 + .font(.caption) 855 + Text(item) 856 + } 857 + } 858 + .onDelete { offsets in 859 + for index in offsets { 860 + favoritesStore.toggleItem(sortedItems[index]) 861 + } 862 + } 863 + } 864 + } 865 + } 866 + .navigationTitle("Favorites") 867 + .navigationBarTitleDisplayMode(.inline) 868 + .toolbar { 869 + ToolbarItem(placement: .topBarTrailing) { 870 + Button("Done") { 871 + dismiss() 872 + } 873 + .fontWeight(.semibold) 874 + } 875 + } 876 + } 877 + .presentationDetents([.medium, .large]) 878 + .presentationDragIndicator(.visible) 879 + } 880 + 881 + private func addKeyword() { 882 + let trimmed = newKeyword.trimmingCharacters(in: .whitespacesAndNewlines) 883 + guard !trimmed.isEmpty else { return } 884 + favoritesStore.addKeyword(trimmed) 885 + newKeyword = "" 698 886 } 699 887 } 700 888