Matrix: TickTick-style Priority×deadline view + Progress Dots widget (KC-21)

Matrix (KC-21): the Eisenhower Matrix is now a computed view of tasks.
Importance = Priority (High = top row), urgency = deadline proximity, so
tasks auto-place into Q1–Q4. Added a persisted `matrixOverride` so a manual
drag pins the task and syncs its Priority to that row (top → High, moving
down demotes High → Medium) and never gets forced back; editing Priority,
due date, or the editor clears the override to re-place live. New tasks get
KisaniCal priority defaults (birthday/domain/annual/exam/subscription → High,
workout → Medium). AddTaskSheet drops the manual quadrant picker for a
Priority menu (pick Priority + Date only).

Also includes in-progress Progress Dots widget work (day/week/month/custom
event modes, App-Group anchor, recurrence-aware spans).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-15 12:40:39 +03:00
parent 2b48509a35
commit 3592d2a9d8
11 changed files with 568 additions and 197 deletions

View File

@@ -644,3 +644,41 @@ Files: `TaskContextMenu.swift`, `WidgetData.swift`, `EventCountdownWidget.swift`
3. A yearly birthday shows Mode B: bar spans last year's → this year's date.
4. Long-press the task again → "Stop Tracking Countdown" → widget falls back
to its configured mode.
---
## KC-21 — Eisenhower Matrix as a TickTick-style view (Priority × deadline)
### Problem
The Matrix treated importance as the task's *stored quadrant* — you placed a
task in a row and only a manual "Move" changed it. TickTick instead treats the
Matrix as a **view**: it auto-places every task from its **Priority** and **due
date**, lets you drag freely between all four quadrants, and never forces a
dragged task back.
### Fix (model — `TaskItem.swift`)
- Added `matrixOverride: Quadrant?` (persisted, optional → old saves decode).
- Importance is now `priority == .high` (top row), not the stored quadrant.
Urgency stays deadline-driven (due today/overdue/≤ `urgentWindowDays`).
Auto-placement: Q1 = High+urgent, Q2 = High+later, Q3 = not-High+urgent,
Q4 = the rest. `displayQuadrant` returns `matrixOverride` if set, else auto.
- `moveToQuadrant` (drag/menu) now pins `matrixOverride` **and** syncs Priority
to the target row (top → High; moving down demotes High → Medium). The move
sticks — nothing forces it back.
- Editing Priority (`setPriority`), due date (`setDate`), or saving the editor
(`updateTask`) clears `matrixOverride` so the task re-places live.
- `addTask` applies KisaniCal defaults when no priority is picked: birthday /
domain / annual (+ title keywords exam, subscription, renew, expir) → High;
workout / gym / exercise → Medium.
### Fix (create UI — `TodayView.swift`)
- `AddTaskSheet` no longer has a manual **quadrant** selector. It now exposes a
**Priority** menu (flag icon). User picks Priority + Date only; placement is
computed. Adding from a Matrix quadrant seeds High for the top row.
### How to test
1. New task, no priority, far date → lands in Q4; raise to High → jumps to Q2.
2. Drag it to Q1 → stays in Q1 (priority becomes High, override set).
3. Drag a Q1 task to Q4 → High demotes to Medium, sticks in Q4.
4. Open the task, change its due date sooner → override clears, re-places by rule.
5. Add a "Mum's birthday" with no priority → auto-High.

View File

@@ -23,6 +23,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
// Recurrence: optional so existing saved tasks decode safely (missing key nil).
var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended)
var completedOccurrences: [String]? = nil // "yyyy-MM-dd" of completed occurrences
// Matrix: a manual drag pins the task to a quadrant (overrides auto-placement).
// nil = auto-place from Priority × deadline. Optional so old saves decode safely.
var matrixOverride: Quadrant? = nil
}
enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -332,18 +335,21 @@ class TaskViewModel: ObservableObject {
return nil
}
// MARK: - Eisenhower Matrix (classic: you set importance, time sets urgency)
// MARK: - Eisenhower Matrix (TickTick-style: Priority × deadline, with manual override)
//
// Importance is intrinsic taken from the task's assigned quadrant's row
// (Q1/Q2 = important, Q3/Q4 = unimportant) and only changes when you move it.
// Urgency is derived from the deadline: a task is urgent when its (next active)
// due date is within `urgentWindowDays`. The displayed quadrant = importance × urgency,
// so important tasks slide Q2Q1 as they near, unimportant slide Q4Q3, and
// recurring tasks roll forward (a completed occurrence is replaced by the next).
// The Matrix is a VIEW of tasks, not a separate system. A task is auto-placed:
// Importance (top row) = Priority is High; Medium/Low/None = bottom row.
// Urgency (left column) = its (next active) due date is today/overdue/within
// `urgentWindowDays`; undated or far-out = right column.
// So Q1 = High + urgent, Q2 = High + not urgent, Q3 = not-High + urgent, Q4 = the rest.
// A manual drag (`moveToQuadrant`) pins the task via `matrixOverride` and syncs its
// Priority to the target row the move sticks and is never forced back. Editing the
// Priority or due date in the task view clears the override so it re-places live.
// Recurring tasks roll forward (a completed occurrence is replaced by the next).
static let urgentWindowDays = 7
private func isImportantQuadrant(_ q: Quadrant) -> Bool { q == .urgent || q == .schedule }
private func isImportant(_ t: TaskItem) -> Bool { t.priority == .high }
/// The due date that drives urgency now: a recurring task's next active
/// occurrence, or a one-off task's own due date.
@@ -359,9 +365,9 @@ class TaskViewModel: ObservableObject {
return days <= Self.urgentWindowDays // due soon / overdue
}
/// Where a task should appear in the Matrix right now (importance × urgency).
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
switch (isImportantQuadrant(t.quadrant), isUrgent(t, on: today)) {
/// Auto-placement from Priority (importance) × deadline (urgency).
private func autoQuadrant(_ t: TaskItem, on today: Date) -> Quadrant {
switch (isImportant(t), isUrgent(t, on: today)) {
case (true, true): return .urgent
case (true, false): return .schedule
case (false, true): return .delegate_
@@ -369,6 +375,11 @@ class TaskViewModel: ObservableObject {
}
}
/// Where a task appears in the Matrix now: a manual override wins, else auto-placement.
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
t.matrixOverride ?? autoQuadrant(t, on: today)
}
/// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant.
/// Recurring tasks contribute their single next-active occurrence.
func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] {
@@ -409,6 +420,7 @@ class TaskViewModel: ObservableObject {
func setDate(_ task: TaskItem, date: Date?) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].dueDate = date
tasks[idx].matrixOverride = nil // deadline changed re-place from urgency
save()
}
@@ -421,14 +433,40 @@ class TaskViewModel: ObservableObject {
func setPriority(_ task: TaskItem, priority: Priority) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].priority = priority
tasks[idx].matrixOverride = nil // priority changed re-place from importance
save()
}
/// Manual drag/move in the Matrix. Pins the task to the target quadrant and syncs
/// its Priority to that row top row (Q1/Q2) becomes High; moving down out of the
/// top demotes HighMedium so it reads as unimportant. TickTick-style: it sticks.
func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
tasks[idx].quadrant = quadrant
tasks[idx].matrixOverride = quadrant
tasks[idx].quadrant = quadrant // keep stored quadrant in sync (widget accent)
let importantRow = (quadrant == .urgent || quadrant == .schedule)
if importantRow {
tasks[idx].priority = .high
} else if tasks[idx].priority == .high {
tasks[idx].priority = .medium
}
save()
}
/// KisaniCal priority defaults high-stakes events start High, workouts Medium.
/// Applied only when the user didn't pick a priority (== .none) at creation.
private func defaultPriority(category: TaskCategory, title: String) -> Priority {
switch category {
case .birthday, .domain, .annual: return .high
default: break
}
let t = title.lowercased()
if t.contains("birthday") || t.contains("exam") || t.contains("subscription")
|| t.contains("renew") || t.contains("domain") || t.contains("expir") { return .high }
if t.contains("workout") || t.contains("gym") || t.contains("exercise") { return .medium }
return .none
}
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false,
@@ -436,12 +474,14 @@ class TaskViewModel: ObservableObject {
endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
let resolvedPriority = priority == .none
? defaultPriority(category: category, title: title) : priority
var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring,
recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay,
priority: priority, reminderDate: reminderDate,
priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder)
item.recurrenceEnd = recurrenceEnd
tasks.append(item)
@@ -463,6 +503,7 @@ class TaskViewModel: ObservableObject {
tasks[idx].reminderDate = reminderDate
tasks[idx].constantReminder = constantReminder
tasks[idx].recurrenceEnd = recurrenceEnd
tasks[idx].matrixOverride = nil // editor saved a new deadline re-place
save()
}
@@ -488,4 +529,3 @@ class TaskViewModel: ObservableObject {
save()
}
}

View File

@@ -47,20 +47,23 @@ struct TaskMenuItems: View {
}
Menu {
Button { onSetDate(task, cal.startOfDay(for: Date())) } label: {
Label("Today", systemImage: "sun.max")
Button { onSetDate(task, preservingTime(on: Date())) } label: {
Label("Today", systemImage: "calendar")
}
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
Label("Tomorrow", systemImage: "sun.max")
}
Button { onSetDate(task, preservingTime(on: nextMonday())) } label: {
Label("Next Monday", systemImage: "calendar.badge.clock")
}
Button {
let d = cal.date(byAdding: .day, value: 1, to: Date())!
onSetDate(task, cal.startOfDay(for: d))
} label: { Label("Tomorrow", systemImage: "sunrise") }
Button {
let d = cal.date(byAdding: .weekOfYear, value: 1, to: Date())!
onSetDate(task, cal.startOfDay(for: d))
} label: { Label("Next Week", systemImage: "calendar") }
Divider()
if let onEdit {
Button { onEdit(task) } label: {
Label("Pick Date", systemImage: "calendar.badge.plus")
}
}
Button { onSetDate(task, nil) } label: {
Label("Remove Date", systemImage: "xmark.circle")
Label("Clear", systemImage: "xmark.square")
}
} label: { Label("Date", systemImage: "calendar.badge.clock") }
@@ -87,7 +90,7 @@ struct TaskMenuItems: View {
} label: { Label("Tags", systemImage: "tag") }
Button { onLiveActivity(task) } label: {
Label("Add to Live Activity", systemImage: "pin.circle")
Label(liveActivityTitle, systemImage: liveActivityIcon)
}
Button { CountdownTracker.toggle(task) } label: {
@@ -105,4 +108,36 @@ struct TaskMenuItems: View {
Label("Delete", systemImage: "trash")
}
}
private var liveActivityTitle: String {
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
return "Remove from Live Activity"
}
return "Add to Live Activity"
}
private var liveActivityIcon: String {
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
return "pin.slash"
}
return "pin.circle"
}
private func dayOffset(_ value: Int) -> Date {
cal.date(byAdding: .day, value: value, to: Date()) ?? Date()
}
private func nextMonday() -> Date {
var comps = DateComponents()
comps.weekday = 2
return cal.nextDate(after: Date(), matching: comps, matchingPolicy: .nextTimePreservingSmallerComponents)
?? dayOffset(7)
}
private func preservingTime(on day: Date) -> Date {
let base = cal.startOfDay(for: day)
guard task.hasTime, let due = task.dueDate else { return base }
let parts = cal.dateComponents([.hour, .minute], from: due)
return cal.date(bySettingHour: parts.hour ?? 0, minute: parts.minute ?? 0, second: 0, of: base) ?? base
}
}

View File

@@ -126,7 +126,11 @@ struct TodayView: View {
onPostponeTask: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
)
.padding(.horizontal, 18)
.padding(.top, 10)
@@ -139,7 +143,13 @@ struct TodayView: View {
tasks: taskVM.tomorrowTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onDelete: { taskVM.delete($0) }
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
)
}
@@ -150,7 +160,13 @@ struct TodayView: View {
tasks: taskVM.next7DaysTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onDelete: { taskVM.delete($0) }
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
)
}
@@ -161,7 +177,13 @@ struct TodayView: View {
tasks: taskVM.laterTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onDelete: { taskVM.delete($0) }
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
)
}
@@ -512,6 +534,10 @@ struct OverdueCard: View {
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
@State private var expanded = true
@@ -599,25 +625,24 @@ struct OverdueCard: View {
@ViewBuilder
private func taskMenu(_ task: TaskItem) -> some View {
Button { withAnimation { onToggle(task) } } label: {
Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
Label("Mark Complete", systemImage: "checkmark.circle")
}
Button { onPostponeTask(task) } label: {
Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
Button { onPin(task) } label: {
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
}
Button { onEdit(task) } label: {
Label("Edit", systemImage: "pencil")
}
ShareLink(item: task.title) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) { onDelete(task) } label: {
Label("Delete", systemImage: "trash")
}
TaskMenuItems(
task: task,
onPin: onPin,
onSetDate: onSetDate,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: onDelete
)
}
}
@@ -919,7 +944,13 @@ struct UpcomingSection: View {
let tasks: [TaskItem]
let onToggle: (TaskItem) -> Void
let onPostpone: (TaskItem) -> Void
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
let onDelete: (TaskItem) -> Void
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
@State private var collapsed = false
@State private var showAll = false
@@ -972,13 +1003,21 @@ struct UpcomingSection: View {
withAnimation(KisaniSpring.snappy) { onToggle(task) }
}
.contextMenu {
Button { withAnimation { onPostpone(task) } } label: {
Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
Divider()
Button(role: .destructive) { withAnimation { onDelete(task) } } label: {
Label("Delete", systemImage: "trash")
}
TaskMenuItems(
task: task,
onPin: onPin,
onSetDate: onSetDate,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
)
}
.padding(.horizontal, 18)
.padding(.bottom, 5)
@@ -1682,7 +1721,7 @@ struct AddTaskSheet: View {
@State private var rawText = ""
@State private var parsed = NLParsed()
@State private var selectedQuadrant: Quadrant
@State private var selectedPriority: Priority = .none
@State private var selectedCategory: TaskCategory = .reminder
@State private var reminderDate: Date? = nil
@State private var constantReminder = false
@@ -1694,7 +1733,9 @@ struct AddTaskSheet: View {
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
self.prefilledDate = prefilledDate
self.prefilledQuadrant = prefilledQuadrant
_selectedQuadrant = State(initialValue: prefilledQuadrant)
// Adding from a Matrix quadrant: seed the Priority that lands the task there.
let importantRow = (prefilledQuadrant == .urgent || prefilledQuadrant == .schedule)
_selectedPriority = State(initialValue: importantRow ? .high : .none)
var p = NLParsed()
p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date())
_parsed = State(initialValue: p)
@@ -1762,27 +1803,18 @@ struct AddTaskSheet: View {
Spacer(minLength: 4)
// Quadrant
// Priority drives Matrix placement (importance axis), TickTick-style
Menu {
ForEach(Quadrant.allCases) { q in
Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: {
Label {
Text(q.matrixLabel)
} icon: {
Image(uiImage: quadrantBadgeImage(q))
}
ForEach(Priority.allCases) { p in
Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: {
Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag")
}
}
} label: {
HStack(spacing: 3) {
Text(selectedQuadrant.roman)
.font(AppFonts.mono(8, weight: .heavy))
.foregroundColor(.white)
.frame(width: 14, height: 14)
.background(selectedQuadrant.color)
.clipShape(Circle())
}
.frame(width: 42, height: 44)
Image(systemName: selectedPriority == .none ? "flag" : "flag.fill")
.font(.system(size: 15))
.foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color)
.frame(width: 42, height: 44)
}
.buttonStyle(.plain)
@@ -1858,35 +1890,14 @@ struct AddTaskSheet: View {
private func submit() {
guard canAdd else { return }
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: selectedQuadrant, category: selectedCategory,
quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder,
recurrenceEnd: parsed.recurrenceEnd)
dismiss()
}
private func quadrantBadgeImage(_ q: Quadrant) -> UIImage {
let uiColor: UIColor
switch q {
case .urgent: uiColor = UIColor(AppColors.accent)
case .schedule: uiColor = UIColor(AppColors.yellow)
case .delegate_: uiColor = UIColor(AppColors.blue)
case .eliminate: uiColor = UIColor(AppColors.green)
}
let size = CGSize(width: 20, height: 20)
let renderer = UIGraphicsImageRenderer(size: size)
let img = renderer.image { ctx in
uiColor.setFill()
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
let label = q.roman
let font = UIFont.monospacedSystemFont(ofSize: 8, weight: .heavy)
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white]
let ts = label.size(withAttributes: attrs)
label.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: (size.height - ts.height) / 2), withAttributes: attrs)
}
return img.withRenderingMode(.alwaysOriginal)
}
}
// MARK: - Task Date Picker Sheet

View File

@@ -186,6 +186,7 @@ struct InViewTutorialCard: View {
@Binding var step: Int
let onAdvance: () -> Void
let onSkip: () -> Void
@Environment(\.colorScheme) private var cs
private var current: PageTip { tips[min(step, tips.count - 1)] }
private var isLast: Bool { step == tips.count - 1 }
@@ -203,17 +204,17 @@ struct InViewTutorialCard: View {
}
Text(current.title)
.font(AppFonts.sans(15, weight: .bold))
.foregroundColor(.white)
.foregroundColor(AppColors.text(cs))
Spacer()
Button("Skip") { onSkip() }
.font(AppFonts.sans(11))
.foregroundColor(.white.opacity(0.45))
.foregroundColor(AppColors.text3(cs))
.buttonStyle(.plain)
}
Text(current.body)
.font(AppFonts.sans(13))
.foregroundColor(.white.opacity(0.80))
.foregroundColor(AppColors.text2(cs))
.fixedSize(horizontal: false, vertical: true)
.lineSpacing(2)
@@ -221,7 +222,7 @@ struct InViewTutorialCard: View {
HStack(spacing: 5) {
ForEach(0..<tips.count, id: \.self) { i in
Capsule()
.fill(i == step ? AppColors.accent : Color.white.opacity(0.20))
.fill(i == step ? AppColors.accent : AppColors.text3(cs).opacity(0.22))
.frame(width: i == step ? 18 : 6, height: 6)
.animation(KisaniSpring.micro, value: step)
}
@@ -243,8 +244,9 @@ struct InViewTutorialCard: View {
}
}
.padding(16)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1))
.background(AppColors.surface(cs), in: RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(AppColors.border(cs), lineWidth: 1))
.shadow(color: Color.black.opacity(cs == .dark ? 0.28 : 0.08), radius: 18, x: 0, y: 10)
.padding(.horizontal, 20)
.id(step)
.transition(.asymmetric(