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:
kutesir
2026-05-27 14:58:47 +03:00
parent d7ec351dd4
commit 1b29fb996f
3 changed files with 306 additions and 240 deletions

View File

@@ -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) {

View File

@@ -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)
} }
} }

View File

@@ -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
}
} }
} }