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:
@@ -13,6 +13,18 @@ enum WidgetTheme {
|
||||
static let textFaint = Color.white.opacity(0.4)
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func widgetTitleCased() -> String {
|
||||
split(separator: " ").map { rawWord in
|
||||
let word = String(rawWord)
|
||||
guard let first = word.first else { return word }
|
||||
if word == word.uppercased(), word.count <= 5 { return word }
|
||||
return first.uppercased() + word.dropFirst().lowercased()
|
||||
}
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vertical-bar progress (comb / equalizer style)
|
||||
|
||||
struct BarGrid: View {
|
||||
@@ -38,99 +50,79 @@ struct BarGrid: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dot-grid progress (day / week / month / year)
|
||||
|
||||
enum TimePeriod { case day, week, month, year }
|
||||
// MARK: - Dot-grid progress
|
||||
|
||||
struct ProgressDotView: View {
|
||||
let period: TimePeriod
|
||||
let entry: ProgressDotEntry
|
||||
let accent: Color
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var label: String {
|
||||
switch period {
|
||||
case .day: let f = DateFormatter(); f.dateFormat = "EEEE d"; return f.string(from: Date())
|
||||
case .week: return "This week"
|
||||
case .month: let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: Date())
|
||||
case .year: let f = DateFormatter(); f.dateFormat = "yyyy"; return f.string(from: Date())
|
||||
private var headerFont: Font {
|
||||
switch family {
|
||||
case .systemLarge: return .system(.title3, design: .monospaced)
|
||||
case .accessoryRectangular: return .system(.caption, design: .monospaced)
|
||||
default: return .system(.body, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
let cal = Calendar.current; let now = Date()
|
||||
switch period {
|
||||
case .day:
|
||||
let sod = cal.startOfDay(for: now)
|
||||
return (now.timeIntervalSince(sod)) / 86400
|
||||
case .week:
|
||||
let comps = cal.dateComponents([.weekday], from: now)
|
||||
let wd = (comps.weekday ?? 1) - 1
|
||||
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
|
||||
return (Double(wd) * 86400 + secIntoDay) / (7 * 86400)
|
||||
case .month:
|
||||
let range = cal.range(of: .day, in: .month, for: now)!
|
||||
let day = cal.component(.day, from: now) - 1
|
||||
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
|
||||
return (Double(day) * 86400 + secIntoDay) / (Double(range.count) * 86400)
|
||||
case .year:
|
||||
let start = cal.date(from: cal.dateComponents([.year], from: now))!
|
||||
let end = cal.date(byAdding: .year, value: 1, to: start)!
|
||||
return now.timeIntervalSince(start) / end.timeIntervalSince(start)
|
||||
}
|
||||
private var spacing: CGFloat {
|
||||
family == .accessoryRectangular ? 4 : 10
|
||||
}
|
||||
|
||||
private var subLabel: String {
|
||||
let pct = Int(progress * 100)
|
||||
return "\(pct)%"
|
||||
private var horizontalPadding: CGFloat {
|
||||
switch family {
|
||||
case .systemLarge: return 8
|
||||
case .accessoryRectangular: return 0
|
||||
default: return 4
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let isMedium = family == .systemMedium
|
||||
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
|
||||
let percent = Int((entry.progress * 100).rounded(.down))
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(label)
|
||||
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
|
||||
Text(entry.title)
|
||||
.font(headerFont)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Spacer()
|
||||
Text(subLabel)
|
||||
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
|
||||
Text("\(percent)%")
|
||||
.font(headerFont)
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
DotGrid(total: 100, filled: Int(progress * 100), color: accent)
|
||||
DotGridView(progress: entry.progress, totalDots: entry.totalDots, color: accent)
|
||||
}
|
||||
.padding(.horizontal, isMedium ? 4 : 0)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.containerBackground(WidgetTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular dot grid (event countdown motif)
|
||||
// MARK: - Reusable circular dot grid
|
||||
|
||||
struct DotGrid: View {
|
||||
let total: Int
|
||||
let filled: Int
|
||||
struct DotGridView: View {
|
||||
let progress: Double
|
||||
var totalDots: Int = 100
|
||||
let color: Color
|
||||
var gap: CGFloat = 3
|
||||
|
||||
private var filledDots: Int {
|
||||
Int((min(1, max(0, progress)) * Double(totalDots)).rounded(.down))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let l = layout(for: total, in: geo.size)
|
||||
VStack(spacing: gap) {
|
||||
ForEach(0..<l.rows, id: \.self) { row in
|
||||
HStack(spacing: gap) {
|
||||
ForEach(0..<l.cols, id: \.self) { col in
|
||||
let idx = row * l.cols + col
|
||||
if idx < total {
|
||||
dot(filled: idx < filled, size: l.size)
|
||||
} else {
|
||||
Color.clear.frame(width: l.size, height: l.size)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
let layout = layout(for: totalDots, in: geo.size)
|
||||
let columns = Array(repeating: GridItem(.fixed(layout.size), spacing: gap, alignment: .center),
|
||||
count: layout.cols)
|
||||
LazyVGrid(columns: columns, alignment: .center, spacing: gap) {
|
||||
ForEach(0..<totalDots, id: \.self) { index in
|
||||
dot(filled: index < filledDots, size: layout.size)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(width: layout.width, height: layout.height, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +137,19 @@ struct DotGrid: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the column count that yields the largest uniform circle that still fits
|
||||
/// every dot inside the available area — an even grid that fills the widget.
|
||||
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat) {
|
||||
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6) }
|
||||
var best = (cols: 1, rows: count, size: CGFloat(0))
|
||||
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat, width: CGFloat, height: CGFloat) {
|
||||
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6, 6, 6) }
|
||||
var best = (cols: 1, rows: count, size: CGFloat(0), width: CGFloat(0), height: CGFloat(0))
|
||||
for cols in 1...count {
|
||||
let rows = Int(ceil(Double(count) / Double(cols)))
|
||||
let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols)
|
||||
let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows)
|
||||
let s = min(w, h)
|
||||
if s > best.size { best = (cols, rows, s) }
|
||||
let gridWidth = CGFloat(cols) * s + gap * CGFloat(cols - 1)
|
||||
let gridHeight = CGFloat(rows) * s + gap * CGFloat(rows - 1)
|
||||
if s > best.size && gridWidth <= size.width && gridHeight <= size.height {
|
||||
best = (cols, rows, s, gridWidth, gridHeight)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -169,17 +163,22 @@ struct EventBarsView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(LocalizedStringKey(entry.eventName))
|
||||
.font(.system(size: 22, weight: .heavy))
|
||||
Text(entry.eventName.widgetTitleCased())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.layoutPriority(1)
|
||||
Button(intent: CycleCountdownUnitIntent()) {
|
||||
Text(entry.fineTimeLeft)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.textDim)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.9)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
@@ -200,10 +199,10 @@ struct LockScreenRectView: View {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(task.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
Text(task.timeLeftLabel)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
@@ -235,10 +234,10 @@ struct LockScreenCircularView: View {
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if let days = task.daysLeft {
|
||||
Text("\(days)").font(.system(size: 14, weight: .bold))
|
||||
Text("days").font(.system(size: 7))
|
||||
Text("\(days)").font(.system(.caption, design: .monospaced))
|
||||
Text("days").font(.system(.caption2, design: .monospaced))
|
||||
} else {
|
||||
Image(systemName: "checkmark").font(.system(size: 14, weight: .bold))
|
||||
Image(systemName: "checkmark").font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user