import SwiftUI import SwiftData /// 自由周期提醒的创建 / 编辑表单。 /// `reminder == nil` 为新建;否则为编辑(多一个删除按钮)。 /// 本地 @State 暂存,保存时才写 SwiftData + 调度通知;取消即丢弃。 struct CustomReminderEditSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss /// nil = 新建模式。 let reminder: CustomReminder? /// 新建模式下的预填(如从「用药记录」点药进来,预填「吃药:药名」+ 用法备注)。 /// 编辑模式(reminder != nil)忽略。 private let prefillTitle: String private let prefillNote: String @State private var title = "" @State private var note = "" @State private var pickedTime: Date = .now /// 多选频率(每日/每周/每月/每年 可同时勾选)。 @State private var frequencies: Set = [.daily] @State private var weekdays: Set = Set(1...7) /// 每月多选日期(1...31)。 @State private var monthDays: Set = [1] @State private var dayOfMonth = 1 // 仅「每年」用 @State private var month = 1 @State private var hydrated = false @State private var showAuthDeniedAlert = false /// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。 private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)] init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") { self.reminder = reminder self.prefillTitle = prefillTitle self.prefillNote = prefillNote } private var isEditing: Bool { reminder != nil } private var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) } private var canSave: Bool { guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false } if frequencies.contains(.weekly) && weekdays.isEmpty { return false } if frequencies.contains(.monthly) && monthDays.isEmpty { return false } return true } var body: some View { NavigationStack { Form { Section { TextField(String(appLoc: "做点什么?例:跑步5公里 / 吃2片护肝片"), text: $title, axis: .vertical) .lineLimit(1...3) TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical) .lineLimit(1...3) .foregroundStyle(Tj.Palette.text2) } Section { frequencyChips frequencyDetail } header: { Text("重复") } footer: { Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。") } Section { timePresetRow DatePicker(String(appLoc: "时间"), selection: $pickedTime, displayedComponents: .hourAndMinute) } header: { Text("时间") } if isEditing { Section { Button(role: .destructive) { deleteReminder() } label: { Label(String(appLoc: "删除提醒"), systemImage: "trash") } } } } .scrollContentBackground(.hidden) .background(Tj.Palette.sand.ignoresSafeArea()) .navigationTitle(isEditing ? String(appLoc: "编辑提醒") : String(appLoc: "新建提醒")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button(String(appLoc: "取消")) { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button(String(appLoc: "保存")) { save() } .fontWeight(.semibold) .disabled(!canSave) } } .onAppear(perform: hydrate) .onChange(of: month) { _, newMonth in // 切月份后,把超出该月最大天数的「日」收回(避免「2月31日」这种永不触发的组合)。 let maxD = Self.daysInMonth(newMonth) if dayOfMonth > maxD { dayOfMonth = maxD } } .alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) { Button(String(appLoc: "好")) { dismiss() } } message: { Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。") } } } // MARK: - 频率多选 chip private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly] private func freqLabel(_ f: CustomReminder.Frequency) -> String { switch f { case .daily: return String(appLoc: "每日") case .weekly: return String(appLoc: "每周") case .monthly: return String(appLoc: "每月") case .yearly: return String(appLoc: "每年") } } private var frequencyChips: some View { HStack(spacing: 8) { ForEach(Self.freqOrder, id: \.self) { f in let on = frequencies.contains(f) Button { if on { frequencies.remove(f) } else { frequencies.insert(f) } } label: { Text(freqLabel(f)) .font(.tjScaled( 13, weight: on ? .semibold : .regular)) .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 32) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(on ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1) ) } .buttonStyle(.plain) } } .listRowBackground(Color.clear) } // MARK: - 频率子控件(随勾选项展开,可同时出现多组) @ViewBuilder private var frequencyDetail: some View { if frequencies.contains(.weekly) { subCaption(String(appLoc: "每周 · 选星期几")) weekdayRow } if frequencies.contains(.monthly) { subCaption(String(appLoc: "每月 · 选日期(可多选)")) monthDayGrid if monthDays.contains(where: { $0 >= 29 }) { skipHint } } if frequencies.contains(.yearly) { subCaption(String(appLoc: "每年 · 选月/日")) Picker(String(appLoc: "月份"), selection: $month) { ForEach(1...12, id: \.self) { mo in Text(String(appLoc: "\(mo)月")).tag(mo) } } Picker(String(appLoc: "日期"), selection: $dayOfMonth) { ForEach(1...Self.daysInMonth(month), id: \.self) { d in Text(String(appLoc: "\(d)日")).tag(d) } } if month == 2 && dayOfMonth == 29 { skipHint } // 仅闰年的 2/29 } } private func subCaption(_ text: String) -> some View { Text(text) .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) .listRowBackground(Color.clear) } /// 每月日期多选网格(1...31,7 列)。 private var monthDayGrid: some View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) { ForEach(1...31, id: \.self) { d in let on = monthDays.contains(d) Button { if on { monthDays.remove(d) } else { monthDays.insert(d) } } label: { Text("\(d)") .font(.tjScaled( 12, weight: on ? .semibold : .regular)) .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 30) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(on ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: 6, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1) ) } .buttonStyle(.plain) } } .listRowBackground(Color.clear) } private var skipHint: some View { Text(String(appLoc: "部分月份无此日,该月将跳过")) .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } /// 某月最大天数(2 月取 29,允许设闰年 2/29)。 private static func daysInMonth(_ month: Int) -> Int { switch month { case 2: return 29 case 4, 6, 9, 11: return 30 default: return 31 } } // MARK: - 时间快捷预设 private var timePresetRow: some View { let cal = Calendar.current let curH = cal.component(.hour, from: pickedTime) let curM = cal.component(.minute, from: pickedTime) return HStack(spacing: 8) { ForEach(Array(timePresets.enumerated()), id: \.offset) { _, preset in let on = curH == preset.h && curM == preset.m Button { pickedTime = cal.date(bySettingHour: preset.h, minute: preset.m, second: 0, of: pickedTime) ?? pickedTime } label: { Text(String(format: "%d:%02d", preset.h, preset.m)) .font(.tjScaled( 13, weight: on ? .semibold : .regular)) .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 30) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(on ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1) ) } .buttonStyle(.plain) } } .listRowBackground(Color.clear) } // MARK: - 周几选择(与 RemindersListView 同款) private var weekdayRow: some View { let names = [ String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), String(appLoc: "日"), ] let values = [2, 3, 4, 5, 6, 7, 1] return HStack(spacing: 6) { ForEach(Array(values.enumerated()), id: \.offset) { idx, w in let on = weekdays.contains(w) Button { if on { weekdays.remove(w) } else { weekdays.insert(w) } } label: { Text(names[idx]) .font(.tjScaled( 13, weight: on ? .semibold : .regular)) .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 30) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(on ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1) ) } .buttonStyle(.plain) } } .listRowBackground(Color.clear) } // MARK: - 数据 private func hydrate() { guard !hydrated else { return } hydrated = true if let r = reminder { title = r.title note = r.note frequencies = r.frequencies weekdays = Set(r.weekdays) monthDays = Set(r.monthlyDays) dayOfMonth = r.dayOfMonth month = r.month pickedTime = Calendar.current.date( bySettingHour: r.hour, minute: r.minute, second: 0, of: .now ) ?? .now } else { // 新建模式:从调用方带进来的预填(药名 / 用法)。 title = prefillTitle note = prefillNote } } private func save() { guard canSave else { return } let cal = Calendar.current let hour = cal.component(.hour, from: pickedTime) let minute = cal.component(.minute, from: pickedTime) let sortedDays = weekdays.sorted() let sortedMonthDays = monthDays.sorted() let target: CustomReminder if let r = reminder { r.title = trimmedTitle r.note = note.trimmingCharacters(in: .whitespacesAndNewlines) r.hour = hour r.minute = minute r.weekdays = sortedDays r.frequencies = frequencies // 写 frequenciesRaw(+ 代表 frequencyRaw) r.monthlyDays = sortedMonthDays // 写 monthDays r.dayOfMonth = dayOfMonth // 仅每年用 r.month = month r.updatedAt = .now target = r } else { let new = CustomReminder( title: trimmedTitle, note: note.trimmingCharacters(in: .whitespacesAndNewlines), hour: hour, minute: minute, weekdays: sortedDays, dayOfMonth: dayOfMonth, month: month ) new.frequencies = frequencies new.monthlyDays = sortedMonthDays ctx.insert(new) target = new } try? ctx.save() Task { @MainActor in let state = await ReminderService.requestAuthorization() await ReminderService.sync(target) if state == .denied { showAuthDeniedAlert = true } else { dismiss() } } } private func deleteReminder() { guard let r = reminder else { return } ReminderService.cancel(customId: r.id) ctx.delete(r) try? ctx.save() dismiss() } } #Preview("新建") { CustomReminderEditSheet() .modelContainer(for: [CustomReminder.self], inMemory: true) }