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:
@@ -36,15 +36,232 @@ struct KisaniProvider: TimelineProvider {
|
||||
|
||||
// MARK: - Day Progress Widget ("Day done %")
|
||||
|
||||
enum ProgressDotMode: String, AppEnum {
|
||||
case day
|
||||
case week
|
||||
case month
|
||||
case customEvent
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
||||
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
|
||||
.day: "This Day",
|
||||
.week: "This Week",
|
||||
.month: "This Month",
|
||||
.customEvent: "Custom Event",
|
||||
]
|
||||
}
|
||||
|
||||
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "Progress Dots"
|
||||
static var description = IntentDescription("Track day, week, month, or a KisaniCal event.")
|
||||
|
||||
@Parameter(title: "Mode", default: .month)
|
||||
var mode: ProgressDotMode
|
||||
|
||||
@Parameter(title: "Event")
|
||||
var event: EventEntity?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
When(\ProgressDotConfigIntent.$mode, .equalTo, ProgressDotMode.customEvent) {
|
||||
Summary("Show \(\.$mode) for \(\.$event)")
|
||||
} otherwise: {
|
||||
Summary("Show \(\.$mode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressDotEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let title: String
|
||||
let progress: Double
|
||||
let totalDots: Int
|
||||
}
|
||||
|
||||
struct ProgressDotProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> ProgressDotEntry {
|
||||
ProgressDotEntry(date: .now, title: Self.dayTitle(for: .now), progress: 0.06, totalDots: 100)
|
||||
}
|
||||
|
||||
func snapshot(for configuration: ProgressDotConfigIntent, in context: Context) async -> ProgressDotEntry {
|
||||
entry(for: configuration, at: .now)
|
||||
}
|
||||
|
||||
func timeline(for configuration: ProgressDotConfigIntent, in context: Context) async -> Timeline<ProgressDotEntry> {
|
||||
let interval: TimeInterval = 5 * 60
|
||||
let entries = (0..<72).map { step in
|
||||
entry(for: configuration, at: Date().addingTimeInterval(Double(step) * interval))
|
||||
}
|
||||
return Timeline(entries: entries, policy: .after(Date().addingTimeInterval(Double(entries.count) * interval)))
|
||||
}
|
||||
|
||||
private func entry(for configuration: ProgressDotConfigIntent, at date: Date) -> ProgressDotEntry {
|
||||
switch configuration.mode {
|
||||
case .day:
|
||||
let start = Calendar.current.startOfDay(for: date)
|
||||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.dayTitle(for: date),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .week:
|
||||
let start = Self.startOfISOWeek(containing: date)
|
||||
let end = Calendar(identifier: .iso8601).date(byAdding: .weekOfYear, value: 1, to: start) ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.weekTitle(for: start),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .month:
|
||||
let interval = Calendar.current.dateInterval(of: .month, for: date)
|
||||
let start = interval?.start ?? date
|
||||
let end = interval?.end ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .customEvent:
|
||||
guard let event = configuration.event,
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
|
||||
let due = task.dueDate
|
||||
else {
|
||||
return ProgressDotEntry(date: date, title: "Select Event", progress: 0, totalDots: 100)
|
||||
}
|
||||
|
||||
let start = Self.eventStartDate(for: task, due: due, now: date)
|
||||
let computedProgress = Self.progress(now: date, start: start, end: due)
|
||||
let storedProgress = task.progress.map(Self.clamp) ?? 0
|
||||
return ProgressDotEntry(date: date, title: task.title,
|
||||
progress: max(storedProgress, computedProgress),
|
||||
totalDots: 100)
|
||||
}
|
||||
}
|
||||
|
||||
private static func progress(now: Date, start: Date, end: Date) -> Double {
|
||||
let total = end.timeIntervalSince(start)
|
||||
guard total > 0 else { return end <= now ? 1 : 0 }
|
||||
return clamp(now.timeIntervalSince(start) / total)
|
||||
}
|
||||
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
min(1, max(0, value))
|
||||
}
|
||||
|
||||
private static func eventStartDate(for task: WidgetTask, due: Date, now: Date) -> Date {
|
||||
if let span = recurrenceSpan(for: task, due: due, now: now) {
|
||||
return span.prev
|
||||
}
|
||||
|
||||
let start = [task.createdAt, task.reminderDate]
|
||||
.compactMap { $0 }
|
||||
.filter { $0 < due }
|
||||
.min()
|
||||
|
||||
let stored = storedAnchor(key: task.id.uuidString, target: due, now: now)
|
||||
return bestStartDate(explicit: start, stored: stored, target: due, now: now)
|
||||
}
|
||||
|
||||
private static func storedAnchor(key: String, target: Date, now: Date) -> Date {
|
||||
let userDefaults = UserDefaults.kisani
|
||||
let key = "kisani.widget.progressDots.anchor.\(key)"
|
||||
if let saved = userDefaults.object(forKey: key) as? Date, saved < target {
|
||||
return saved
|
||||
}
|
||||
userDefaults.set(now, forKey: key)
|
||||
return now
|
||||
}
|
||||
|
||||
private static func bestStartDate(explicit: Date?, stored: Date, target: Date, now: Date) -> Date {
|
||||
let candidate = explicit ?? stored
|
||||
guard candidate < target else { return inferredLegacyStart(target: target, now: now) }
|
||||
|
||||
let remaining = target.timeIntervalSince(now)
|
||||
let elapsed = now.timeIntervalSince(candidate)
|
||||
let total = target.timeIntervalSince(candidate)
|
||||
let progress = total > 0 ? elapsed / total : 0
|
||||
let looksLikeUpgradeAnchor = remaining > 14 * 86400
|
||||
&& elapsed >= 0
|
||||
&& elapsed < 3 * 86400
|
||||
&& progress < (1.0 / 100.0)
|
||||
|
||||
return looksLikeUpgradeAnchor ? inferredLegacyStart(target: target, now: now) : candidate
|
||||
}
|
||||
|
||||
private static func inferredLegacyStart(target: Date, now: Date) -> Date {
|
||||
let remainingDays = max(1, Int(ceil(target.timeIntervalSince(now) / 86400)))
|
||||
let totalDays = min(365, max(30, remainingDays * 2))
|
||||
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
|
||||
}
|
||||
|
||||
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
||||
let category = task.category.lowercased()
|
||||
if category == "birthday" || category == "annual" {
|
||||
return recurrenceSpan(label: "Yearly", due: due, now: now)
|
||||
}
|
||||
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
|
||||
return recurrenceSpan(label: label, due: due, now: now)
|
||||
}
|
||||
|
||||
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
||||
let cal = Calendar.current
|
||||
let comp: Calendar.Component
|
||||
let step: Int
|
||||
switch label {
|
||||
case "Daily", "Every Weekday": comp = .day; step = 1
|
||||
case "Weekly": comp = .day; step = 7
|
||||
case "Monthly": comp = .month; step = 1
|
||||
case "Yearly": comp = .year; step = 1
|
||||
default: return nil
|
||||
}
|
||||
|
||||
var next = due
|
||||
var guardRail = 0
|
||||
if next > now {
|
||||
while let prev = cal.date(byAdding: comp, value: -step, to: next),
|
||||
prev > now, guardRail < 4000 {
|
||||
next = prev
|
||||
guardRail += 1
|
||||
}
|
||||
} else {
|
||||
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
|
||||
guardRail < 4000 {
|
||||
next = candidate
|
||||
guardRail += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
|
||||
return (prev, next)
|
||||
}
|
||||
|
||||
private static func startOfISOWeek(containing date: Date) -> Date {
|
||||
let cal = Calendar(identifier: .iso8601)
|
||||
return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
|
||||
}
|
||||
|
||||
private static func dayTitle(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func weekTitle(for start: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
return "Week of \(formatter.string(from: start))"
|
||||
}
|
||||
|
||||
private static func monthTitle(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct DayProgressWidget: Widget {
|
||||
let kind = "KisaniDayProgress"
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
|
||||
ProgressDotView(period: .day, accent: WidgetTheme.accent)
|
||||
AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
|
||||
ProgressDotView(entry: entry, accent: WidgetTheme.accent)
|
||||
}
|
||||
.configurationDisplayName("Day Progress")
|
||||
.description("How much of today has elapsed, as a dot grid.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
.configurationDisplayName("Progress Dots")
|
||||
.description("Track day, week, month, or a selected event as a dot grid.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,15 +95,15 @@ struct MyTasksView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Today")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
Text("\(entry.openCount)")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
Spacer()
|
||||
Button(intent: AddTaskFromWidgetIntent()) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -112,7 +112,7 @@ struct MyTasksView: View {
|
||||
if entry.tasks.isEmpty {
|
||||
Spacer()
|
||||
Text("All clear for today 🎉")
|
||||
.font(.system(size: 13))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer()
|
||||
@@ -127,7 +127,7 @@ struct MyTasksView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text(task.title)
|
||||
.font(.system(size: 14))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(task.isComplete ? Color(white: 0.45) : .white)
|
||||
.strikethrough(task.isComplete, color: Color(white: 0.45))
|
||||
.lineLimit(1)
|
||||
@@ -136,7 +136,7 @@ struct MyTasksView: View {
|
||||
}
|
||||
if entry.tasks.count > maxRows {
|
||||
Text("+\(entry.tasks.count - maxRows) more")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.4))
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
|
||||
@@ -13,27 +13,36 @@ struct TaskLiveActivity: Widget {
|
||||
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(context.state.isComplete ? .green : accent)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(context.attributes.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.strikethrough(context.state.isComplete)
|
||||
if let due = context.state.dueDate {
|
||||
Text(due, style: context.state.isComplete ? .date : .relative)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
.layoutPriority(1)
|
||||
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.frame(maxWidth: 70)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
.frame(width: 92, alignment: .trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.leading, 14)
|
||||
.padding(.trailing, 10)
|
||||
.padding(.vertical, 12)
|
||||
.activityBackgroundTint(WidgetTheme.background.opacity(0.9))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
@@ -47,14 +56,17 @@ struct TaskLiveActivity: Widget {
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.frame(maxWidth: 64)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(width: 86, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.attributes.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
}
|
||||
} compactLeading: {
|
||||
@@ -63,8 +75,11 @@ struct TaskLiveActivity: Widget {
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.frame(maxWidth: 44)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
} minimal: {
|
||||
Image(systemName: "checklist").foregroundColor(accent)
|
||||
|
||||
@@ -12,6 +12,9 @@ struct WidgetTask: Codable, Identifiable {
|
||||
let category: String
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
var createdAt: Date? = nil
|
||||
var reminderDate: Date? = nil
|
||||
var progress: Double? = nil
|
||||
|
||||
// Days remaining until due date
|
||||
var daysLeft: Int? {
|
||||
@@ -63,7 +66,9 @@ func loadWidgetTasks() -> [WidgetTask] {
|
||||
return raw.map {
|
||||
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
|
||||
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
|
||||
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel)
|
||||
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
|
||||
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
|
||||
progress: $0.countdownProgress ?? $0.progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +82,13 @@ private struct RawTask: Codable {
|
||||
var category: String = "reminder"
|
||||
var isRecurring: Bool? = nil
|
||||
var recurrenceLabel: String? = nil
|
||||
var createdAt: Date? = nil
|
||||
var reminderDate: Date? = nil
|
||||
var progress: Double? = nil
|
||||
var countdownProgress: Double? = nil
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
|
||||
case createdAt, reminderDate, progress, countdownProgress
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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