feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时 - 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的 SME2和A17的NEON加速 - 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制 - 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构 - 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX - 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理 - 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
385 lines
15 KiB
Swift
385 lines
15 KiB
Swift
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<CustomReminder.Frequency> = [.daily]
|
|
@State private var weekdays: Set<Int> = Set(1...7)
|
|
/// 每月多选日期(1...31)。
|
|
@State private var monthDays: Set<Int> = [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)
|
|
}
|