// // RecordsPage.swift // Food Ninja // // Created by Albert on 2025/7/4. // import SwiftUI import UserNotifications import SwiftData import UIKit struct RecordsPage: View { @Query var records: [Record] @Environment(\.modelContext) private var modelContext @State private var selectedRecord: Record? @State private var searchText: String = "" @State private var draftRecord: Record? // Temporary state variables for editing/creating records @State private var tempName: String = "" @State private var tempEmoji: String = "🍽️" @State private var tempCategory: String = "" @State private var tempLocation: String = "" @State private var tempExpiration: Date = Date() @State private var tempNotifTime: Date = Date() @State private var tempNotes: String = "" var filteredRecords: [Record] { records .filter { searchText.isEmpty || $0.name.localizedCaseInsensitiveContains(searchText) || $0.location.localizedCaseInsensitiveContains(searchText) || $0.category.localizedCaseInsensitiveContains(searchText) } .sorted { let now = Date() let isExpired0 = $0.expirationDate < now let isExpired1 = $1.expirationDate < now if isExpired0 != isExpired1 { return isExpired0 } if $0.expirationDate != $1.expirationDate { return $0.expirationDate < $1.expirationDate } else { return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } } var body: some View { mainView } private var mainView: some View { NavigationSplitView { List(selection: $selectedRecord) { ForEach(filteredRecords, id: \.self) { record in HStack(alignment: .top) { VStack(alignment: .leading) { HStack { Text(record.emoji + " " + record.name) .font(.headline) if record.isIncomplete { Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) .help("record_incomplete_tooltip".localized) } } let daysDiff = Calendar.current.dateComponents([.day], from: Date(), to: record.expirationDate).day ?? 0 if Calendar.current.isDateInToday(record.expirationDate) { Text("exp_today".localized) .font(.subheadline) .foregroundStyle(.orange) } else if daysDiff < 0 { Text("exp_already".localized(with: abs(daysDiff))) .font(.subheadline) .foregroundStyle(.red) } else { Text("exp_in".localized(with: daysDiff + 1)) .font(.subheadline) .foregroundStyle(.orange) } let meta = [record.category, record.location].filter { !$0.isEmpty }.joined(separator: " • ") if !meta.isEmpty { Text(meta) .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 4) Spacer() } .tag(record) } .onDelete { indexSet in for index in indexSet { let record = filteredRecords[index] removeNotifications(for: record) modelContext.delete(record) } } } .searchable(text: $searchText) .navigationTitle("tbrecords".localized) .toolbar { ToolbarItem(placement: .topBarTrailing) { EditButton() } ToolbarItem(placement: .topBarTrailing) { Button(action: { let newExpiration = Date() let newCategory = "cat_general".localized let newDraft = Record( name: "", expirationDate: newExpiration, category: newCategory, location: "", emoji: "🍽️", notes: "" ) newDraft.isIncomplete = true draftRecord = newDraft selectedRecord = newDraft // Set temp variables for new draft tempName = "" tempEmoji = "🍽️" tempCategory = newCategory tempLocation = "" tempExpiration = newExpiration tempNotifTime = DateComponents(calendar: .current, hour: 8, minute: 0).date ?? Date() tempNotes = "" }) { Image(systemName: "plus") } } } } detail: { if let draft = draftRecord { Group { recordForm(for: draft) .onAppear { // Copy values into temp variables if not already matching if tempName != draft.name { tempName = draft.name } if tempEmoji != draft.emoji { tempEmoji = draft.emoji } if tempCategory != draft.category { tempCategory = draft.category } if tempLocation != draft.location { tempLocation = draft.location } if tempExpiration != draft.expirationDate { tempExpiration = draft.expirationDate } // notificationTime may be nil, fallback to 8:00 let notifTime = draft.notificationTime ?? DateComponents(calendar: .current, hour: 8, minute: 0).date ?? Date() if abs(tempNotifTime.timeIntervalSince1970 - notifTime.timeIntervalSince1970) > 1 { tempNotifTime = notifTime } if tempNotes != draft.notes { tempNotes = draft.notes } } } .navigationTitle("rec_edit_header".localized) .toolbar { ToolbarItem(placement: .topBarTrailing) { if !tempName.trimmingCharacters(in: .whitespaces).isEmpty, !tempLocation.trimmingCharacters(in: .whitespaces).isEmpty, Calendar.current.startOfDay(for: tempExpiration) >= Calendar.current.startOfDay(for: Date()) { Button(action: { if let dr = draftRecord { dr.name = tempName dr.emoji = tempEmoji dr.category = tempCategory dr.location = tempLocation dr.expirationDate = tempExpiration dr.notificationTime = tempNotifTime dr.notes = tempNotes if dr.isIncomplete, !dr.name.isEmpty, !dr.location.isEmpty, dr.expirationDate > Date.distantPast { dr.isIncomplete = false } // Insert into modelContext modelContext.insert(dr) scheduleNotifications(for: dr) draftRecord = nil selectedRecord = nil } }) { Text("rec_save".localized) } } } } } else if let record = selectedRecord { Group { recordForm(for: record) .onAppear { tempName = record.name tempEmoji = record.emoji tempCategory = record.category tempLocation = record.location tempExpiration = record.expirationDate tempNotifTime = record.notificationTime ?? DateComponents(calendar: .current, hour: 8, minute: 0).date ?? Date() tempNotes = record.notes } } .navigationTitle("rec_edit_header".localized) .toolbar { ToolbarItem(placement: .topBarTrailing) { if !tempName.trimmingCharacters(in: .whitespaces).isEmpty, !tempLocation.trimmingCharacters(in: .whitespaces).isEmpty, Calendar.current.startOfDay(for: tempExpiration) >= Calendar.current.startOfDay(for: Date()) { Button(action: { record.name = tempName record.emoji = tempEmoji record.category = tempCategory record.location = tempLocation record.expirationDate = tempExpiration record.notificationTime = tempNotifTime record.notes = tempNotes if record.isIncomplete, !record.name.isEmpty, !record.location.isEmpty, record.expirationDate > Date.distantPast { record.isIncomplete = false } removeNotifications(for: record) modelContext.insert(record) scheduleNotifications(for: record) selectedRecord = nil }) { Text("rec_save".localized) } } } } } else { ContentUnavailableView("no_selected_rec".localized, systemImage: "doc.plaintext", description: Text("choose_rec".localized)) } } .onAppear { requestNotificationPermissionIfNeeded() } .onReceive(NotificationCenter.default.publisher(for: .didTapNotification)) { notification in if let idString = notification.object as? String, let uuid = UUID(uuidString: idString) { selectedRecord = records.first(where: { $0.id == uuid }) } } } // MARK: - Notification Scheduling func scheduleNotifications(for record: Record) { // Skip incomplete records (drafts) guard !record.isIncomplete else { return } let center = UNUserNotificationCenter.current() let content = UNMutableNotificationContent() content.sound = .default let templates = [ "notif_template_7_12", "notif_template_3_5", "notif_template_1_2", "notif_template_today", "notif_template_expired", "notif_template_30_31", "notif_template_over30" // index 6 ] // Calculate dayOffsets including monthly notifications (>30 days before) var dayOffsets: [Int] = [-1, 0, 1, 3, 7, 30] // Add additional monthly notifications for over 30 days before expiration let now = Calendar.current.startOfDay(for: Date()) let expDate = Calendar.current.startOfDay(for: record.expirationDate) let diffInDays = Calendar.current.dateComponents([.day], from: now, to: expDate).day ?? 0 if diffInDays >= 60 { var offset = 60 while offset <= diffInDays { dayOffsets.append(offset) offset += 30 } } for offset in dayOffsets { guard let triggerDate = Calendar.current.date(byAdding: .day, value: -offset, to: expDate), triggerDate >= now else { continue } var triggerComponents = Calendar.current.dateComponents([.year, .month, .day], from: triggerDate) if let notifTime = record.notificationTime { let timeComponents = Calendar.current.dateComponents([.hour, .minute], from: notifTime) triggerComponents.hour = timeComponents.hour triggerComponents.minute = timeComponents.minute } else { triggerComponents.hour = 8 triggerComponents.minute = 0 } let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false) let id = "exp_\(record.id.uuidString)_\(offset)" content.body = String(format: NSLocalizedString(templates[offsetToIndex(offset)], comment: ""), record.name) let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) center.add(request) // Debug print: show when the notification will be triggered if let scheduledDate = Calendar.current.date(from: trigger.dateComponents) { print("📆 Scheduled notification for \(record.name) at offset \(offset): \(scheduledDate)") } } } func removeNotifications(for record: Record) { let center = UNUserNotificationCenter.current() let ids = [-1, 0, 1, 3, 7, 30].map { "exp_\(record.id.uuidString)_\($0)" } center.removePendingNotificationRequests(withIdentifiers: ids) } private func offsetToIndex(_ offset: Int) -> Int { switch offset { case let x where x > 31: return 6 // New index for "over 30 days" case 30...31: return 5 case 7...12: return 0 case 3...5: return 1 case 1...2: return 2 case 0: return 3 default: return 4 } } // MARK: - Notification Permission Request func requestNotificationPermissionIfNeeded() { UNUserNotificationCenter.current().getNotificationSettings { settings in switch settings.authorizationStatus { case .notDetermined: UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if let error = error { print("Notification auth error: \(error)") } else { print("Notification permission granted: \(granted)") } } case .denied: DispatchQueue.main.async { let alert = UIAlertController(title: "notif_denied_title".localized, message: "notif_denied_body".localized, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "notif_open_settings".localized, style: .default, handler: { _ in if let settingsURL = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settingsURL) } })) alert.addAction(UIAlertAction(title: "notif_cancel".localized, style: .cancel)) if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let root = scene.windows.first?.rootViewController { root.present(alert, animated: true) } } case .authorized, .provisional, .ephemeral: break // Already authorized @unknown default: break } } } @ViewBuilder private func recordForm(for record: Record) -> some View { Form { Section("rec_product".localized) { TextField("rec_name".localized, text: $tempName) ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach([ "🍽️", "🍇", "🍈", "🍉", "🍊", "🍋", "🍋‍🟩", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", "🍓", "🫐", "🥝", "🍅", "🫒", "🥥", "🥑", "🍆", "🥔", "🥕", "🌽", "🌶️", "🫑", "🥒", "🥬", "🥦", "🧄", "🧅", "🥜", "🫘", "🌰", "🫚", "🫛", "🫜", "🍄‍🟫", "🍞", "🥐", "🥖", "🫓", "🥨", "🥯", "🥞", "🧇", "🧀", "🍖", "🍗", "🥩", "🥓", "🍔", "🍟", "🍕", "🌭", "🥪", "🌮", "🌯", "🫔", "🥙", "🧆", "🥚", "🍳", "🥘", "🍲", "🫕", "🥗", "🍿", "🧈", "🧂", "🥫", "🍝", "🍱", "🍘", "🍙", "🍚", "🍛", "🍜", "🍠", "🍢", "🍣", "🍤", "🍥", "🥮", "🍡", "🥟", "🥠", "🍦", "🍧", "🍨", "🍩", "🍪", "🎂", "🍰", "🧁", "🥧", "🍫", "🍬", "🍭", "🍮", "🍯", "🍼", "🥛", "☕", "🫖", "🍵", "🍶", "🍷", "🍸", "🍹", "🍺", "🥃", "🥤", "🧋", "🧃", "🧉", "💊", "🤔" ], id: \.self) { emoji in Text(emoji) .font(.largeTitle) .padding(6) .background(tempEmoji == emoji ? Color.accentColor.opacity(0.2) : Color.clear) .clipShape(Circle()) .onTapGesture { tempEmoji = emoji } } } } } Section("rec_details".localized) { Picker("rec_cat".localized, selection: $tempCategory) { ForEach(["cat_general".localized, "cat_dairy".localized, "cat_veg".localized, "cat_fruit".localized, "cat_meat".localized, "cat_sea".localized, "cat_bakery".localized, "cat_grains".localized, "cat_drinks".localized, "cat_snacks".localized, "cat_frozen".localized, "cat_medic".localized, "cat_others".localized], id: \.self) { Text($0) } } TextField("rec_location".localized, text: $tempLocation) DatePicker("rec_exp".localized, selection: $tempExpiration, displayedComponents: .date) DatePicker("rec_notif_time".localized, selection: $tempNotifTime, displayedComponents: .hourAndMinute) } Section("rec_notes".localized) { TextEditor(text: $tempNotes) } } } } extension Notification.Name { static let didTapNotification = Notification.Name("didTapNotification") }