- New EventBarsWidget ("Event Countdown (Bars)") using BarGrid + EventBarsView,
with a fine "X hr, Y min left" label; registered in the bundle.
- Re-themed every home-screen widget to the shared WidgetTheme (dark slate
background + brand orange): Day Progress, Tasks Done Today, Event Countdown
(dots), My Tasks. Lock-screen widgets keep .thinMaterial (system-tinted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
361 lines
13 KiB
Swift
361 lines
13 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
// MARK: - Shared widget theme (slate background + brand orange)
|
|
|
|
enum WidgetTheme {
|
|
static let background = Color(red: 0.224, green: 0.255, blue: 0.310) // dark slate
|
|
static let accent = Color(red: 0.93, green: 0.42, blue: 0.20) // brand orange
|
|
static let text = Color.white
|
|
static let textDim = Color.white.opacity(0.55)
|
|
static let textFaint = Color.white.opacity(0.4)
|
|
}
|
|
|
|
// MARK: - Vertical-bar progress (comb / equalizer style)
|
|
|
|
struct BarGrid: View {
|
|
let progress: Double // 0…1
|
|
let color: Color
|
|
var count: Int = 64
|
|
var gap: CGFloat = 3
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let totalGap = gap * CGFloat(count - 1)
|
|
let barW = max(1.5, (geo.size.width - totalGap) / CGFloat(count))
|
|
let filled = Int((progress * Double(count)).rounded())
|
|
HStack(spacing: gap) {
|
|
ForEach(0..<count, id: \.self) { i in
|
|
RoundedRectangle(cornerRadius: barW / 2)
|
|
.fill(i < filled ? color : color.opacity(0.22))
|
|
.frame(width: barW)
|
|
}
|
|
}
|
|
.frame(height: geo.size.height, alignment: .center)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Dot-grid progress (day / week / month / year)
|
|
|
|
enum TimePeriod { case day, week, month, year }
|
|
|
|
struct ProgressDotView: View {
|
|
let period: TimePeriod
|
|
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 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 subLabel: String {
|
|
let pct = Int(progress * 100)
|
|
return "\(pct)%"
|
|
}
|
|
|
|
var body: some View {
|
|
let isMedium = family == .systemMedium
|
|
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(label)
|
|
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Text(subLabel)
|
|
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
|
|
.foregroundColor(Color(white: 0.5))
|
|
}
|
|
DotGrid(total: 100, filled: Int(progress * 100), color: accent)
|
|
}
|
|
.padding(.horizontal, isMedium ? 4 : 0)
|
|
.containerBackground(WidgetTheme.background, for: .widget)
|
|
}
|
|
}
|
|
|
|
// MARK: - Tasks completed today
|
|
|
|
struct TasksCompleteView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
private let teal = WidgetTheme.accent
|
|
|
|
private var stats: (done: Int, total: Int) { todayTaskCompletion() }
|
|
private var percent: Int {
|
|
let s = stats
|
|
guard s.total > 0 else { return 0 }
|
|
return Int((Double(s.done) / Double(s.total) * 100).rounded())
|
|
}
|
|
|
|
var body: some View {
|
|
let s = stats
|
|
let isMedium = family == .systemMedium
|
|
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text("Today's Tasks")
|
|
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Text("\(percent)%")
|
|
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
|
|
.foregroundColor(Color(white: 0.5))
|
|
}
|
|
if s.total == 0 {
|
|
Spacer(minLength: 0)
|
|
Text("No tasks due today")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(Color(white: 0.5))
|
|
Spacer(minLength: 0)
|
|
} else {
|
|
Text("\(s.done) of \(s.total) done")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(teal)
|
|
Spacer(minLength: 2)
|
|
DotGrid(total: s.total, filled: s.done, color: teal)
|
|
}
|
|
}
|
|
.padding(.horizontal, isMedium ? 4 : 0)
|
|
.containerBackground(WidgetTheme.background, for: .widget)
|
|
}
|
|
}
|
|
|
|
// MARK: - Circular dot grid (event countdown motif)
|
|
|
|
struct DotGrid: View {
|
|
let total: Int
|
|
let filled: Int
|
|
let color: Color
|
|
var gap: CGFloat = 3
|
|
|
|
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)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func dot(filled: Bool, size: CGFloat) -> some View {
|
|
if filled {
|
|
Circle().fill(color).frame(width: size, height: size)
|
|
} else {
|
|
Circle()
|
|
.strokeBorder(color.opacity(0.55), lineWidth: max(0.8, size * 0.13))
|
|
.frame(width: size, height: size)
|
|
}
|
|
}
|
|
|
|
/// 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))
|
|
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) }
|
|
}
|
|
return best
|
|
}
|
|
}
|
|
|
|
// MARK: - Event Countdown Widget (small + medium)
|
|
|
|
struct EventCountdownView: View {
|
|
let entry: EventEntry
|
|
@Environment(\.widgetFamily) var family
|
|
|
|
private let teal = WidgetTheme.accent
|
|
|
|
private var percent: Int { Int((entry.progress * 100).rounded()) }
|
|
|
|
var body: some View {
|
|
Group {
|
|
if family == .systemSmall {
|
|
small
|
|
} else {
|
|
medium
|
|
}
|
|
}
|
|
.containerBackground(WidgetTheme.background, for: .widget)
|
|
}
|
|
|
|
private var filled: Int { Int(entry.progress * Double(entry.totalDays)) }
|
|
|
|
// Wide layout: event name + days-left countdown, then the dot-grid progress.
|
|
private var medium: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text(LocalizedStringKey(entry.eventName))
|
|
.font(.system(size: 20, weight: .bold))
|
|
.foregroundColor(teal)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 4)
|
|
VStack(alignment: .trailing, spacing: 1) {
|
|
Button(intent: CycleCountdownUnitIntent()) {
|
|
Text(entry.timeLeftLabel)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(Color(white: 0.62))
|
|
.lineLimit(1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
Text("\(percent)%")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundColor(Color(white: 0.4))
|
|
}
|
|
}
|
|
DotGrid(total: entry.totalDays, filled: filled, color: teal)
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
|
|
private var small: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(LocalizedStringKey(entry.eventName))
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundColor(teal)
|
|
.lineLimit(2)
|
|
Button(intent: CycleCountdownUnitIntent()) {
|
|
Text(entry.timeLeftLabel)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundColor(Color(white: 0.62))
|
|
.lineLimit(1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
Spacer(minLength: 2)
|
|
DotGrid(total: entry.totalDays, filled: filled, color: teal)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Event Countdown — Bars style (medium)
|
|
|
|
struct EventBarsView: View {
|
|
let entry: EventEntry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
Text(LocalizedStringKey(entry.eventName))
|
|
.font(.system(size: 22, weight: .heavy))
|
|
.foregroundColor(WidgetTheme.accent)
|
|
.lineLimit(1)
|
|
Button(intent: CycleCountdownUnitIntent()) {
|
|
Text(entry.fineTimeLeft)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(WidgetTheme.textDim)
|
|
.lineLimit(1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
Spacer(minLength: 0)
|
|
}
|
|
Spacer(minLength: 8)
|
|
BarGrid(progress: entry.progress, color: WidgetTheme.accent)
|
|
.frame(maxHeight: .infinity)
|
|
}
|
|
.padding(.horizontal, 4).padding(.vertical, 2)
|
|
.containerBackground(WidgetTheme.background, for: .widget)
|
|
}
|
|
}
|
|
|
|
// MARK: - Lock Screen Widget (accessoryRectangular)
|
|
|
|
struct LockScreenRectView: View {
|
|
let task: WidgetTask
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(task.title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.lineLimit(1)
|
|
Text(task.timeLeftLabel)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
if !task.isComplete, let days = task.daysLeft, days <= 365 {
|
|
ProgressView(value: Double(365 - days), total: 365)
|
|
.progressViewStyle(.circular)
|
|
.frame(width: 22, height: 22)
|
|
.tint(.white)
|
|
} else if task.isComplete {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
}
|
|
}
|
|
.containerBackground(.thinMaterial, for: .widget)
|
|
}
|
|
}
|
|
|
|
// MARK: - Lock Screen Circular
|
|
|
|
struct LockScreenCircularView: View {
|
|
let task: WidgetTask
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
if !task.isComplete, let days = task.daysLeft, days <= 365 {
|
|
ProgressView(value: Double(365 - days), total: 365)
|
|
.progressViewStyle(.circular)
|
|
.tint(.white)
|
|
}
|
|
VStack(spacing: 0) {
|
|
if let days = task.daysLeft {
|
|
Text("\(days)").font(.system(size: 14, weight: .bold))
|
|
Text("days").font(.system(size: 7))
|
|
} else {
|
|
Image(systemName: "checkmark").font(.system(size: 14, weight: .bold))
|
|
}
|
|
}
|
|
}
|
|
.containerBackground(.thinMaterial, for: .widget)
|
|
}
|
|
}
|