diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 3af0ec7..d0daeb6 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -186,7 +186,7 @@ struct CalendarView: View { .sheet(isPresented: $showAddTask) { AddTaskSheet(prefilledDate: selectedDate) .environmentObject(taskVM) - .presentationDetents([.large]) + .presentationDetents([.height(500), .large]) .presentationDragIndicator(.visible) } .sheet(isPresented: $showCalendarConnect) { diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index 97997d3..ac5bf73 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -67,7 +67,7 @@ struct MatrixView: View { .sheet(isPresented: $showAddTask) { AddTaskSheet() .environmentObject(taskVM) - .presentationDetents([.large]) + .presentationDetents([.height(500), .large]) .presentationDragIndicator(.visible) } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 408d77e..d2e3e4f 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -187,7 +187,7 @@ struct TodayView: View { .sheet(isPresented: $showAddTask) { AddTaskSheet() .environmentObject(taskVM) - .presentationDetents([.large]) + .presentationDetents([.height(500), .large]) .presentationDragIndicator(.visible) } .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 + struct AddTaskSheet: View { @EnvironmentObject var taskVM: TaskViewModel @Environment(\.colorScheme) var cs @Environment(\.dismiss) var dismiss - @State private var title = "" - @State private var hasDueDate: Bool - @State private var dueDate: Date + @State private var rawText = "" + @State private var parsed = NLParsed() @State private var selectedQuadrant: Quadrant @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 prefilledQuadrant: Quadrant @@ -762,266 +960,134 @@ struct AddTaskSheet: View { init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) { self.prefilledDate = prefilledDate self.prefilledQuadrant = prefilledQuadrant - _hasDueDate = State(initialValue: prefilledDate != nil) - _dueDate = State(initialValue: prefilledDate ?? Date()) _selectedQuadrant = State(initialValue: prefilledQuadrant) + if let d = prefilledDate { + var p = NLParsed(); p.date = d; _parsed = State(initialValue: p) + } } - private var canAdd: Bool { - !title.trimmingCharacters(in: .whitespaces).isEmpty - } + private var cleanTitle: String { NLTaskParser.cleanTitle(rawText, ranges: parsed.tokenRanges) } + private var canAdd: Bool { !cleanTitle.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { VStack(spacing: 0) { - // ── Header ── - HStack { - Text("New Task") - .font(AppFonts.sans(17, weight: .bold)) - .foregroundColor(AppColors.text(cs)) - Spacer() - Button("Cancel") { dismiss() } - .font(AppFonts.sans(13)) + // ── Text input ────────────────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + NLTaskField( + text: $rawText, + placeholder: "What would you like to do?", + highlightRanges: parsed.tokenRanges + ) + .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)) - .buttonStyle(.plain) } - .padding(.horizontal, 20) + .padding(.horizontal, 16) .padding(.top, 20) .padding(.bottom, 14) + // ── Toolbar ───────────────────────────────────────────────────── Divider().background(AppColors.border(cs)) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 20) { - - // ── Task name ── - TaskFormBlock(label: "TASK") { - TextField("What needs to be done?", text: $title) - .font(AppFonts.sans(14)) - .foregroundColor(AppColors.text(cs)) - .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) - ) + HStack(spacing: 0) { + // Date chip + Button { } label: { + HStack(spacing: 5) { + Image(systemName: "calendar") + .font(.system(size: 13, weight: .semibold)) + Text(parsed.hasDate ? parsed.dateLabel : "No Date") + .font(AppFonts.sans(13, weight: .semibold)) } + .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 ── - 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) - } + Spacer(minLength: 4) - // ── Priority ── - TaskFormBlock(label: "PRIORITY") { - HStack(spacing: 7) { - ForEach(Quadrant.allCases) { q in - let isSel = selectedQuadrant == q - 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) - } + // Priority + Menu { + ForEach(Quadrant.allCases) { q in + Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: { + Label(q.rawValue, systemImage: selectedQuadrant == q ? "checkmark" : "") } } + } 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 ── - TaskFormBlock(label: "CATEGORY") { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], - 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) - } - } + // Category + Menu { + ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in + Button { withAnimation(KisaniSpring.micro) { selectedCategory = cat } } label: { + Label(cat.rawValue, systemImage: selectedCategory == cat ? "checkmark" : "") } } + } 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 ── - TaskFormBlock(label: "COLOR") { - HStack(spacing: 14) { - ForEach([TaskColor.orange, .green, .blue, .yellow], id: \.self) { c in - 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() - } - } + Image(systemName: "arrow.right.square") + .font(.system(size: 15)) + .foregroundColor(AppColors.text3(cs)) + .frame(width: 42, height: 44) - // ── Recurring ── - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("RECURRING") - .font(AppFonts.mono(9, weight: .bold)) - .foregroundColor(AppColors.text3(cs)) - Text("Repeats every year") - .font(AppFonts.sans(12)) - .foregroundColor(AppColors.text2(cs)) + Image(systemName: "ellipsis") + .font(.system(size: 15)) + .foregroundColor(AppColors.text3(cs)) + .frame(width: 42, height: 44) + + Spacer() + + // Submit / mic + 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() - Toggle("", isOn: $isRecurring) - .labelsHidden() - .tint(AppColors.accent) + .buttonStyle(PressButtonStyle(scale: 0.90)) + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } else { + 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) - .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 + .animation(KisaniSpring.micro, value: canAdd) } + .padding(.horizontal, 12) + .padding(.vertical, 4) } + .background(AppColors.surface(cs).ignoresSafeArea()) } - // MARK: - Helpers - private func quadrantColor(_ q: Quadrant) -> Color { - switch q { - case .urgent: return AppColors.accent - case .schedule: return AppColors.yellow - 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: 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 - } + private func submit() { + guard canAdd else { return } + taskVM.addTask(title: cleanTitle, dueDate: parsed.date, + quadrant: selectedQuadrant, category: selectedCategory) + dismiss() } }