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>
258 lines
9.1 KiB
Swift
258 lines
9.1 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Preference Key
|
|
|
|
struct TutorialFrameKey: PreferenceKey {
|
|
static var defaultValue: [Int: CGRect] = [:]
|
|
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
|
|
value.merge(nextValue()) { _, new in new }
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func tutorialAnchor(_ index: Int) -> some View {
|
|
background(GeometryReader { geo in
|
|
Color.clear.preference(
|
|
key: TutorialFrameKey.self,
|
|
value: [index: geo.frame(in: .global)]
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// MARK: - Overlay
|
|
|
|
struct TutorialOverlay: View {
|
|
@ObservedObject private var tm = TutorialManager.shared
|
|
let frames: [Int: CGRect]
|
|
@Environment(\.colorScheme) private var cs
|
|
|
|
private var spotlight: CGRect? {
|
|
guard let f = frames[tm.current.anchorIndex], f.width > 4 else { return nil }
|
|
return f.insetBy(dx: -12, dy: -8)
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack {
|
|
dimLayer(geo: geo)
|
|
spotlightBorder
|
|
skipButton
|
|
callout(geo: geo)
|
|
.id(tm.step)
|
|
.transition(.asymmetric(
|
|
insertion: .opacity.combined(with: .scale(scale: 0.96)),
|
|
removal: .opacity
|
|
))
|
|
}
|
|
.ignoresSafeArea()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { }
|
|
.animation(KisaniSpring.snappy, value: tm.step)
|
|
}
|
|
}
|
|
|
|
// MARK: Dim
|
|
|
|
@ViewBuilder
|
|
private func dimLayer(geo: GeometryProxy) -> some View {
|
|
if let sp = spotlight {
|
|
Path { p in
|
|
p.addRect(CGRect(origin: .zero, size: geo.size))
|
|
p.addRoundedRect(in: sp, cornerSize: .init(width: 16, height: 16))
|
|
}
|
|
.fill(style: FillStyle(eoFill: true))
|
|
.foregroundColor(.black.opacity(0.72))
|
|
.animation(KisaniSpring.snappy, value: sp.origin)
|
|
} else {
|
|
Color.black.opacity(0.72)
|
|
}
|
|
}
|
|
|
|
// MARK: Spotlight border
|
|
|
|
@ViewBuilder
|
|
private var spotlightBorder: some View {
|
|
if let sp = spotlight {
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(AppColors.accent, lineWidth: 2)
|
|
.frame(width: sp.width, height: sp.height)
|
|
.position(x: sp.midX, y: sp.midY)
|
|
.animation(KisaniSpring.snappy, value: sp.origin)
|
|
}
|
|
}
|
|
|
|
// MARK: Skip
|
|
|
|
private var skipButton: some View {
|
|
VStack {
|
|
HStack {
|
|
Spacer()
|
|
Button("Skip") { tm.skip() }
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(.white.opacity(0.50))
|
|
.padding(.top, 58)
|
|
.padding(.trailing, 24)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
// MARK: Callout
|
|
|
|
private func callout(geo: GeometryProxy) -> some View {
|
|
let step = tm.current
|
|
let sp = spotlight
|
|
let maxW = min(geo.size.width - 48, 320.0)
|
|
let estH: CGFloat = 160
|
|
|
|
let midY = sp?.midY ?? geo.size.height / 2
|
|
let above = midY > geo.size.height * 0.5
|
|
|
|
let cy: CGFloat = above
|
|
? max(estH / 2 + 56, (sp?.minY ?? midY) - 24 - estH / 2)
|
|
: min(geo.size.height - estH / 2 - 20, (sp?.maxY ?? midY) + 24 + estH / 2)
|
|
|
|
let midX = sp?.midX ?? geo.size.width / 2
|
|
let cx = min(max(maxW / 2 + 24, midX), geo.size.width - maxW / 2 - 24)
|
|
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
|
|
// Header
|
|
HStack(spacing: 10) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.fill(AppColors.accent.opacity(0.18))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: step.icon)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(AppColors.accent)
|
|
}
|
|
Text(step.title)
|
|
.font(AppFonts.sans(15, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
Text("\(tm.step + 1) / \(tm.steps.count)")
|
|
.font(AppFonts.mono(9.5, weight: .bold))
|
|
.foregroundColor(.white.opacity(0.35))
|
|
}
|
|
|
|
// Body
|
|
Text(step.body)
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(.white.opacity(0.80))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.lineSpacing(2)
|
|
|
|
// Progress + action
|
|
HStack(spacing: 0) {
|
|
HStack(spacing: 5) {
|
|
ForEach(0..<tm.steps.count, id: \.self) { i in
|
|
Capsule()
|
|
.fill(i == tm.step ? AppColors.accent : Color.white.opacity(0.20))
|
|
.frame(width: i == tm.step ? 18 : 6, height: 6)
|
|
.animation(KisaniSpring.micro, value: tm.step)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button { tm.advance() } label: {
|
|
HStack(spacing: 5) {
|
|
Text(tm.isLast ? "Get started" : "Next")
|
|
.font(AppFonts.sans(14, weight: .semibold))
|
|
Image(systemName: tm.isLast ? "checkmark" : "arrow.right")
|
|
.font(.system(size: 11, weight: .bold))
|
|
}
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(AppColors.accent)
|
|
.clipShape(Capsule())
|
|
}
|
|
.buttonStyle(PressButtonStyle(scale: 0.94))
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: maxW)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
|
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1))
|
|
.position(x: cx, y: cy)
|
|
}
|
|
}
|
|
|
|
// MARK: - In-View Tutorial Card (Today / Workout page tutorials)
|
|
|
|
struct InViewTutorialCard: View {
|
|
let tips: [PageTip]
|
|
@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 }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 10) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.fill(AppColors.accent.opacity(0.18))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: current.icon)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(AppColors.accent)
|
|
}
|
|
Text(current.title)
|
|
.font(AppFonts.sans(15, weight: .bold))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Button("Skip") { onSkip() }
|
|
.font(AppFonts.sans(11))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
Text(current.body)
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(AppColors.text2(cs))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.lineSpacing(2)
|
|
|
|
HStack {
|
|
HStack(spacing: 5) {
|
|
ForEach(0..<tips.count, id: \.self) { i in
|
|
Capsule()
|
|
.fill(i == step ? AppColors.accent : AppColors.text3(cs).opacity(0.22))
|
|
.frame(width: i == step ? 18 : 6, height: 6)
|
|
.animation(KisaniSpring.micro, value: step)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button(action: onAdvance) {
|
|
HStack(spacing: 5) {
|
|
Text(isLast ? "Got it" : "Next")
|
|
.font(AppFonts.sans(14, weight: .semibold))
|
|
Image(systemName: isLast ? "checkmark" : "arrow.right")
|
|
.font(.system(size: 11, weight: .bold))
|
|
}
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
.background(AppColors.accent)
|
|
.clipShape(Capsule())
|
|
}
|
|
.buttonStyle(PressButtonStyle(scale: 0.94))
|
|
}
|
|
}
|
|
.padding(16)
|
|
.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(
|
|
insertion: .opacity.combined(with: .scale(scale: 0.97)),
|
|
removal: .opacity
|
|
))
|
|
}
|
|
}
|