feat(tasks): natural language task input with inline token highlighting
Replace full-form AddTaskSheet with a compact NL input sheet. Type "meeting today at 1500" — the words "today" and "at 1500" highlight inline in blue, the toolbar shows "Today, 15:00", and the mic button becomes a submit arrow when the title is non-empty. Parser handles: today/tomorrow, weekday names (mon–sun), times as at 3pm / at 15:00 / at 1500 / 3:30pm. Clean title strips the date tokens before saving. Sheet uses height(500) detent so it floats above the keyboard.
This commit is contained in:
@@ -186,7 +186,7 @@ struct CalendarView: View {
|
|||||||
.sheet(isPresented: $showAddTask) {
|
.sheet(isPresented: $showAddTask) {
|
||||||
AddTaskSheet(prefilledDate: selectedDate)
|
AddTaskSheet(prefilledDate: selectedDate)
|
||||||
.environmentObject(taskVM)
|
.environmentObject(taskVM)
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.height(500), .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showCalendarConnect) {
|
.sheet(isPresented: $showCalendarConnect) {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct MatrixView: View {
|
|||||||
.sheet(isPresented: $showAddTask) {
|
.sheet(isPresented: $showAddTask) {
|
||||||
AddTaskSheet()
|
AddTaskSheet()
|
||||||
.environmentObject(taskVM)
|
.environmentObject(taskVM)
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.height(500), .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ struct TodayView: View {
|
|||||||
.sheet(isPresented: $showAddTask) {
|
.sheet(isPresented: $showAddTask) {
|
||||||
AddTaskSheet()
|
AddTaskSheet()
|
||||||
.environmentObject(taskVM)
|
.environmentObject(taskVM)
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.height(500), .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showWorkoutSession) {
|
.sheet(isPresented: $showWorkoutSession) {
|
||||||
@@ -741,20 +741,218 @@ struct TaskRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NL Task Parser
|
||||||
|
|
||||||
|
struct NLParsed {
|
||||||
|
var date: Date?
|
||||||
|
var hasTime: Bool = false
|
||||||
|
var tokenRanges: [NSRange] = []
|
||||||
|
var hasDate: Bool { date != nil }
|
||||||
|
|
||||||
|
var dateLabel: String {
|
||||||
|
guard let d = date else { return "No Date" }
|
||||||
|
let cal = Calendar.current
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
if hasTime {
|
||||||
|
fmt.dateFormat = cal.isDateInToday(d) ? "'Today,' HH:mm"
|
||||||
|
: cal.isDateInTomorrow(d) ? "'Tomorrow,' HH:mm" : "MMM d, HH:mm"
|
||||||
|
} else {
|
||||||
|
if cal.isDateInToday(d) { return "Today" }
|
||||||
|
if cal.isDateInTomorrow(d) { return "Tomorrow" }
|
||||||
|
fmt.dateFormat = "EEE, MMM d"
|
||||||
|
}
|
||||||
|
return fmt.string(from: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NLTaskParser {
|
||||||
|
|
||||||
|
static func parse(_ input: String) -> NLParsed {
|
||||||
|
var result = NLParsed()
|
||||||
|
guard !input.isEmpty else { return result }
|
||||||
|
let lower = input.lowercased()
|
||||||
|
let cal = Calendar.current
|
||||||
|
var comps = cal.dateComponents([.year, .month, .day], from: Date())
|
||||||
|
var rawRanges: [NSRange] = []
|
||||||
|
var foundDay = false
|
||||||
|
|
||||||
|
// ── Day keywords ───────────────────────────────────────────────────
|
||||||
|
let dayTable: [(String, Int)] = [
|
||||||
|
("\\btoday\\b", 0),
|
||||||
|
("\\btomorrow\\b", 1),
|
||||||
|
("\\bmon(day)?\\b", daysUntil(2)),
|
||||||
|
("\\btue(sday)?\\b", daysUntil(3)),
|
||||||
|
("\\bwed(nesday)?\\b", daysUntil(4)),
|
||||||
|
("\\bthu(rsday)?\\b", daysUntil(5)),
|
||||||
|
("\\bfri(day)?\\b", daysUntil(6)),
|
||||||
|
("\\bsat(urday)?\\b", daysUntil(7)),
|
||||||
|
("\\bsun(day)?\\b", daysUntil(1)),
|
||||||
|
]
|
||||||
|
for (pat, offset) in dayTable {
|
||||||
|
if let r = match(pat, in: lower) {
|
||||||
|
let d = cal.date(byAdding: .day, value: offset, to: Date()) ?? Date()
|
||||||
|
comps = cal.dateComponents([.year, .month, .day], from: d)
|
||||||
|
rawRanges.append(r); foundDay = true; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Time patterns ──────────────────────────────────────────────────
|
||||||
|
let timePatterns = [
|
||||||
|
"at\\s+(\\d{4})\\b", // at 1500
|
||||||
|
"at\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)?", // at 15:00 / at 3:30pm
|
||||||
|
"at\\s+(\\d{1,2})\\s*(am|pm)", // at 3pm
|
||||||
|
"\\b(\\d{1,2}):(\\d{2})\\s*(am|pm)\\b", // 3:30pm
|
||||||
|
"\\b(\\d{1,2})\\s*(am|pm)\\b", // 3pm
|
||||||
|
]
|
||||||
|
var foundTime = false
|
||||||
|
for pat in timePatterns {
|
||||||
|
if let (r, h, m) = parseTime(pat, in: lower) {
|
||||||
|
comps.hour = h; comps.minute = m; comps.second = 0
|
||||||
|
rawRanges.append(r); foundTime = true; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundDay || foundTime {
|
||||||
|
result.date = cal.date(from: comps)
|
||||||
|
result.hasTime = foundTime
|
||||||
|
result.tokenRanges = expand(rawRanges, in: lower)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cleanTitle(_ text: String, ranges: [NSRange]) -> String {
|
||||||
|
var s = text as NSString
|
||||||
|
for r in ranges.sorted(by: { $0.location > $1.location }) where r.location + r.length <= s.length {
|
||||||
|
s = s.replacingCharacters(in: r, with: "") as NSString
|
||||||
|
}
|
||||||
|
return (s as String).trimmingCharacters(in: .whitespaces)
|
||||||
|
.replacingOccurrences(of: " ", with: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func daysUntil(_ target: Int) -> Int {
|
||||||
|
let today = Calendar.current.component(.weekday, from: Date())
|
||||||
|
let d = target - today
|
||||||
|
return d <= 0 ? d + 7 : d
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func match(_ pattern: String, in text: String) -> NSRange? {
|
||||||
|
guard let rx = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||||
|
return rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text))?.range
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseTime(_ pattern: String, in text: String) -> (NSRange, Int, Int)? {
|
||||||
|
guard let rx = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||||
|
let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text))
|
||||||
|
else { return nil }
|
||||||
|
let full = (text as NSString).substring(with: m.range).lowercased()
|
||||||
|
var h = 0, mn = 0
|
||||||
|
|
||||||
|
if m.numberOfRanges >= 2, let r1 = Range(m.range(at: 1), in: text) {
|
||||||
|
let s1 = String(text[r1])
|
||||||
|
if s1.count == 4 { // packed 1500
|
||||||
|
h = Int(s1.prefix(2)) ?? 0; mn = Int(s1.suffix(2)) ?? 0
|
||||||
|
} else {
|
||||||
|
h = Int(s1) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.numberOfRanges >= 3, m.range(at: 2).location != NSNotFound,
|
||||||
|
let r2 = Range(m.range(at: 2), in: text) {
|
||||||
|
mn = Int(String(text[r2])) ?? mn
|
||||||
|
}
|
||||||
|
let isPM = full.contains("pm") && h < 12
|
||||||
|
let isAM = full.contains("am") && h == 12
|
||||||
|
if isPM { h += 12 }; if isAM { h = 0 }
|
||||||
|
guard h >= 0 && h < 24 && mn >= 0 && mn < 60 else { return nil }
|
||||||
|
return (m.range, h, mn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// widen ranges to absorb leading space so clean-strip leaves no double spaces
|
||||||
|
private static func expand(_ ranges: [NSRange], in text: String) -> [NSRange] {
|
||||||
|
let ns = text as NSString
|
||||||
|
return ranges.map { r in
|
||||||
|
if r.location > 0 && ns.character(at: r.location - 1) == 32 {
|
||||||
|
return NSRange(location: r.location - 1, length: r.length + 1)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NL Task Field
|
||||||
|
|
||||||
|
struct NLTaskField: UIViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
let placeholder: String
|
||||||
|
var highlightRanges: [NSRange]
|
||||||
|
var onReturn: (() -> Void)? = nil
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UITextView {
|
||||||
|
let tv = UITextView()
|
||||||
|
tv.delegate = context.coordinator
|
||||||
|
tv.isScrollEnabled = false
|
||||||
|
tv.backgroundColor = .clear
|
||||||
|
tv.textContainerInset = .zero
|
||||||
|
tv.textContainer.lineFragmentPadding = 0
|
||||||
|
tv.font = UIFont.systemFont(ofSize: 18)
|
||||||
|
tv.textColor = .placeholderText
|
||||||
|
tv.text = placeholder
|
||||||
|
tv.autocorrectionType = .yes
|
||||||
|
tv.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
return tv
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ tv: UITextView, context: Context) {
|
||||||
|
guard context.coordinator.isEditing else {
|
||||||
|
if text.isEmpty {
|
||||||
|
tv.text = placeholder
|
||||||
|
tv.textColor = .placeholderText
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let sel = tv.selectedRange
|
||||||
|
let attr = NSMutableAttributedString(string: text)
|
||||||
|
let full = NSRange(location: 0, length: attr.length)
|
||||||
|
attr.addAttributes([.font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.label], range: full)
|
||||||
|
for r in highlightRanges where r.location + r.length <= attr.length {
|
||||||
|
attr.addAttributes([
|
||||||
|
.backgroundColor: UIColor.systemBlue.withAlphaComponent(0.13),
|
||||||
|
.foregroundColor: UIColor.systemBlue,
|
||||||
|
], range: r)
|
||||||
|
}
|
||||||
|
tv.attributedText = attr
|
||||||
|
tv.selectedRange = sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UITextViewDelegate {
|
||||||
|
var parent: NLTaskField
|
||||||
|
var isEditing = false
|
||||||
|
init(_ p: NLTaskField) { parent = p }
|
||||||
|
|
||||||
|
func textViewDidBeginEditing(_ tv: UITextView) {
|
||||||
|
isEditing = true
|
||||||
|
if tv.textColor == .placeholderText { tv.text = ""; tv.textColor = .label }
|
||||||
|
}
|
||||||
|
func textViewDidEndEditing(_ tv: UITextView) {
|
||||||
|
isEditing = false
|
||||||
|
if tv.text.isEmpty { tv.text = parent.placeholder; tv.textColor = .placeholderText }
|
||||||
|
}
|
||||||
|
func textViewDidChange(_ tv: UITextView) { parent.text = tv.text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Add Task Sheet
|
// MARK: - Add Task Sheet
|
||||||
|
|
||||||
struct AddTaskSheet: View {
|
struct AddTaskSheet: View {
|
||||||
@EnvironmentObject var taskVM: TaskViewModel
|
@EnvironmentObject var taskVM: TaskViewModel
|
||||||
@Environment(\.colorScheme) var cs
|
@Environment(\.colorScheme) var cs
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
@State private var title = ""
|
@State private var rawText = ""
|
||||||
@State private var hasDueDate: Bool
|
@State private var parsed = NLParsed()
|
||||||
@State private var dueDate: Date
|
|
||||||
@State private var selectedQuadrant: Quadrant
|
@State private var selectedQuadrant: Quadrant
|
||||||
@State private var selectedCategory: TaskCategory = .reminder
|
@State private var selectedCategory: TaskCategory = .reminder
|
||||||
@State private var selectedColor: TaskColor = .orange
|
|
||||||
@State private var isRecurring = false
|
|
||||||
@FocusState private var titleFocused: Bool
|
|
||||||
|
|
||||||
var prefilledDate: Date?
|
var prefilledDate: Date?
|
||||||
var prefilledQuadrant: Quadrant
|
var prefilledQuadrant: Quadrant
|
||||||
@@ -762,266 +960,134 @@ struct AddTaskSheet: View {
|
|||||||
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
|
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
|
||||||
self.prefilledDate = prefilledDate
|
self.prefilledDate = prefilledDate
|
||||||
self.prefilledQuadrant = prefilledQuadrant
|
self.prefilledQuadrant = prefilledQuadrant
|
||||||
_hasDueDate = State(initialValue: prefilledDate != nil)
|
|
||||||
_dueDate = State(initialValue: prefilledDate ?? Date())
|
|
||||||
_selectedQuadrant = State(initialValue: prefilledQuadrant)
|
_selectedQuadrant = State(initialValue: prefilledQuadrant)
|
||||||
|
if let d = prefilledDate {
|
||||||
|
var p = NLParsed(); p.date = d; _parsed = State(initialValue: p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canAdd: Bool {
|
private var cleanTitle: String { NLTaskParser.cleanTitle(rawText, ranges: parsed.tokenRanges) }
|
||||||
!title.trimmingCharacters(in: .whitespaces).isEmpty
|
private var canAdd: Bool { !cleanTitle.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// ── Header ──
|
// ── Text input ──────────────────────────────────────────────────
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("New Task")
|
NLTaskField(
|
||||||
.font(AppFonts.sans(17, weight: .bold))
|
text: $rawText,
|
||||||
.foregroundColor(AppColors.text(cs))
|
placeholder: "What would you like to do?",
|
||||||
Spacer()
|
highlightRanges: parsed.tokenRanges
|
||||||
Button("Cancel") { dismiss() }
|
)
|
||||||
.font(AppFonts.sans(13))
|
.frame(minHeight: 28, maxHeight: 110)
|
||||||
|
.onChange(of: rawText) { _ in
|
||||||
|
withAnimation(KisaniSpring.micro) { parsed = NLTaskParser.parse(rawText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Description")
|
||||||
|
.font(AppFonts.sans(14))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
// ── Toolbar ─────────────────────────────────────────────────────
|
||||||
Divider().background(AppColors.border(cs))
|
Divider().background(AppColors.border(cs))
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
HStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
// Date chip
|
||||||
|
Button { } label: {
|
||||||
// ── Task name ──
|
HStack(spacing: 5) {
|
||||||
TaskFormBlock(label: "TASK") {
|
Image(systemName: "calendar")
|
||||||
TextField("What needs to be done?", text: $title)
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.font(AppFonts.sans(14))
|
Text(parsed.hasDate ? parsed.dateLabel : "No Date")
|
||||||
.foregroundColor(AppColors.text(cs))
|
.font(AppFonts.sans(13, weight: .semibold))
|
||||||
.focused($titleFocused)
|
|
||||||
.padding(13)
|
|
||||||
.background(AppColors.surface2(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: AppRadius.small)
|
|
||||||
.stroke(titleFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.foregroundColor(parsed.hasDate ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||||
|
.background(parsed.hasDate ? AppColors.accentSoft : Color.clear)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.animation(KisaniSpring.micro, value: parsed.hasDate)
|
||||||
|
|
||||||
// ── Due date ──
|
Spacer(minLength: 4)
|
||||||
TaskFormBlock(label: "DUE DATE") {
|
|
||||||
VStack(spacing: hasDueDate ? 10 : 0) {
|
|
||||||
HStack {
|
|
||||||
Text("Set a due date")
|
|
||||||
.font(AppFonts.sans(13))
|
|
||||||
.foregroundColor(AppColors.text2(cs))
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: $hasDueDate)
|
|
||||||
.labelsHidden()
|
|
||||||
.tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
if hasDueDate {
|
|
||||||
DatePicker("", selection: $dueDate, displayedComponents: .date)
|
|
||||||
.datePickerStyle(.graphical)
|
|
||||||
.tint(AppColors.accent)
|
|
||||||
.padding(.top, -4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(13)
|
|
||||||
.background(AppColors.surface2(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: AppRadius.small)
|
|
||||||
.stroke(AppColors.border(cs), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.animation(KisaniSpring.snappy, value: hasDueDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Priority ──
|
// Priority
|
||||||
TaskFormBlock(label: "PRIORITY") {
|
Menu {
|
||||||
HStack(spacing: 7) {
|
ForEach(Quadrant.allCases) { q in
|
||||||
ForEach(Quadrant.allCases) { q in
|
Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: {
|
||||||
let isSel = selectedQuadrant == q
|
Label(q.rawValue, systemImage: selectedQuadrant == q ? "checkmark" : "")
|
||||||
let color = quadrantColor(q)
|
|
||||||
Button {
|
|
||||||
withAnimation(KisaniSpring.micro) { selectedQuadrant = q }
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 3) {
|
|
||||||
Text(quadrantRoman(q))
|
|
||||||
.font(AppFonts.mono(13, weight: .heavy))
|
|
||||||
.foregroundColor(isSel ? color : AppColors.text3(cs))
|
|
||||||
Text(quadrantShort(q))
|
|
||||||
.font(AppFonts.mono(7.5, weight: .bold))
|
|
||||||
.foregroundColor(isSel ? color : AppColors.text3(cs))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.8)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 9)
|
|
||||||
.background(isSel ? color.opacity(0.10) : AppColors.surface2(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 9)
|
|
||||||
.stroke(isSel ? color : AppColors.border(cs),
|
|
||||||
lineWidth: isSel ? 1.5 : 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: selectedQuadrant == .urgent ? "flag.fill" : "flag")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(selectedQuadrant == .urgent ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
.frame(width: 42, height: 44)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// ── Category ──
|
// Category
|
||||||
TaskFormBlock(label: "CATEGORY") {
|
Menu {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in
|
||||||
HStack(spacing: 6) {
|
Button { withAnimation(KisaniSpring.micro) { selectedCategory = cat } } label: {
|
||||||
ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom],
|
Label(cat.rawValue, systemImage: selectedCategory == cat ? "checkmark" : "")
|
||||||
id: \.self) { cat in
|
|
||||||
let isSel = selectedCategory == cat
|
|
||||||
Button {
|
|
||||||
withAnimation(KisaniSpring.micro) { selectedCategory = cat }
|
|
||||||
} label: {
|
|
||||||
Text(cat.rawValue)
|
|
||||||
.font(AppFonts.mono(9.5, weight: .bold))
|
|
||||||
.foregroundColor(isSel ? AppColors.accent : AppColors.text3(cs))
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 7)
|
|
||||||
.background(isSel ? AppColors.accentSoft : AppColors.surface2(cs))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
.overlay(
|
|
||||||
Capsule().stroke(
|
|
||||||
isSel ? AppColors.accent : AppColors.border(cs),
|
|
||||||
lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: selectedCategory != .reminder ? "tag.fill" : "tag")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(selectedCategory != .reminder ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
.frame(width: 42, height: 44)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// ── Color ──
|
Image(systemName: "arrow.right.square")
|
||||||
TaskFormBlock(label: "COLOR") {
|
.font(.system(size: 15))
|
||||||
HStack(spacing: 14) {
|
.foregroundColor(AppColors.text3(cs))
|
||||||
ForEach([TaskColor.orange, .green, .blue, .yellow], id: \.self) { c in
|
.frame(width: 42, height: 44)
|
||||||
let isSel = selectedColor == c
|
|
||||||
Button {
|
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
||||||
selectedColor = c
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(c.color())
|
|
||||||
.frame(width: 28, height: 28)
|
|
||||||
if isSel {
|
|
||||||
Circle()
|
|
||||||
.stroke(c.color(), lineWidth: 2.5)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.font(.system(size: 11, weight: .bold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recurring ──
|
Image(systemName: "ellipsis")
|
||||||
HStack {
|
.font(.system(size: 15))
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundColor(AppColors.text3(cs))
|
||||||
Text("RECURRING")
|
.frame(width: 42, height: 44)
|
||||||
.font(AppFonts.mono(9, weight: .bold))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
Spacer()
|
||||||
Text("Repeats every year")
|
|
||||||
.font(AppFonts.sans(12))
|
// Submit / mic
|
||||||
.foregroundColor(AppColors.text2(cs))
|
ZStack {
|
||||||
|
if canAdd {
|
||||||
|
Button { submit() } label: {
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.background(AppColors.accent)
|
||||||
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
Spacer()
|
.buttonStyle(PressButtonStyle(scale: 0.90))
|
||||||
Toggle("", isOn: $isRecurring)
|
.transition(.scale(scale: 0.5).combined(with: .opacity))
|
||||||
.labelsHidden()
|
} else {
|
||||||
.tint(AppColors.accent)
|
Image(systemName: "mic")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
.frame(width: 42, height: 44)
|
||||||
|
.transition(.scale(scale: 0.5).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.animation(KisaniSpring.micro, value: canAdd)
|
||||||
.padding(.top, 18)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add Button ──
|
|
||||||
Divider().background(AppColors.border(cs))
|
|
||||||
|
|
||||||
Button {
|
|
||||||
guard canAdd else { return }
|
|
||||||
withAnimation {
|
|
||||||
taskVM.addTask(
|
|
||||||
title: title.trimmingCharacters(in: .whitespaces),
|
|
||||||
dueDate: hasDueDate ? dueDate : nil,
|
|
||||||
quadrant: selectedQuadrant,
|
|
||||||
category: selectedCategory,
|
|
||||||
taskColor: selectedColor,
|
|
||||||
isRecurring: isRecurring
|
|
||||||
)
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
Text("Add Task")
|
|
||||||
.font(AppFonts.sans(14, weight: .bold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(14)
|
|
||||||
.background(canAdd ? AppColors.accent : AppColors.borderHi(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
||||||
.animation(KisaniSpring.micro, value: canAdd)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!canAdd)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
.background(AppColors.background(cs).ignoresSafeArea())
|
|
||||||
.onAppear {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
titleFocused = true
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
.background(AppColors.surface(cs).ignoresSafeArea())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
private func submit() {
|
||||||
private func quadrantColor(_ q: Quadrant) -> Color {
|
guard canAdd else { return }
|
||||||
switch q {
|
taskVM.addTask(title: cleanTitle, dueDate: parsed.date,
|
||||||
case .urgent: return AppColors.accent
|
quadrant: selectedQuadrant, category: selectedCategory)
|
||||||
case .schedule: return AppColors.yellow
|
dismiss()
|
||||||
case .delegate_: return AppColors.blue
|
|
||||||
case .eliminate: return AppColors.green
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private func quadrantRoman(_ q: Quadrant) -> String {
|
|
||||||
switch q { case .urgent: return "I"; case .schedule: return "II";
|
|
||||||
case .delegate_: return "III"; case .eliminate: return "IV" }
|
|
||||||
}
|
|
||||||
private func quadrantShort(_ q: Quadrant) -> String {
|
|
||||||
switch q { case .urgent: return "URGENT"; case .schedule: return "SCHED";
|
|
||||||
case .delegate_: return "DELEG"; case .eliminate: return "ELIM" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Form Block helper
|
|
||||||
private struct TaskFormBlock<Content: View>: View {
|
|
||||||
@Environment(\.colorScheme) private var cs
|
|
||||||
let label: String
|
|
||||||
@ViewBuilder let content: Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
|
||||||
Text(label)
|
|
||||||
.font(AppFonts.mono(9, weight: .bold))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user