2026-06-03 12:27:45 +03:00
|
|
|
import SwiftUI
|
|
|
|
|
import WidgetKit
|
|
|
|
|
|
2026-06-10 17:18:52 +03:00
|
|
|
// MARK: - Shared widget theme (slate background + brand orange)
|
|
|
|
|
|
|
|
|
|
enum WidgetTheme {
|
2026-06-11 19:33:01 +03:00
|
|
|
// Exactly the brand palette: app accent RGB(232,98,42) on the logo's
|
|
|
|
|
// dark RGB(26,29,34) — widgets read as siblings of the app icon.
|
|
|
|
|
static let background = Color(red: 26/255, green: 29/255, blue: 34/255)
|
|
|
|
|
static let accent = Color(red: 232/255, green: 98/255, blue: 42/255)
|
2026-06-10 17:18:52 +03:00
|
|
|
static let text = Color.white
|
|
|
|
|
static let textDim = Color.white.opacity(0.55)
|
|
|
|
|
static let textFaint = Color.white.opacity(0.4)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
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: " ")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 17:18:52 +03:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
// MARK: - Dot-grid progress
|
2026-06-03 12:27:45 +03:00
|
|
|
|
|
|
|
|
struct ProgressDotView: View {
|
2026-06-15 12:40:39 +03:00
|
|
|
let entry: ProgressDotEntry
|
2026-06-03 12:27:45 +03:00
|
|
|
let accent: Color
|
|
|
|
|
@Environment(\.widgetFamily) private var family
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
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)
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
private var spacing: CGFloat {
|
|
|
|
|
family == .accessoryRectangular ? 4 : 10
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
private var horizontalPadding: CGFloat {
|
|
|
|
|
switch family {
|
|
|
|
|
case .systemLarge: return 8
|
|
|
|
|
case .accessoryRectangular: return 0
|
|
|
|
|
default: return 4
|
|
|
|
|
}
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-06-15 12:40:39 +03:00
|
|
|
let percent = Int((entry.progress * 100).rounded(.down))
|
|
|
|
|
VStack(alignment: .leading, spacing: spacing) {
|
2026-06-03 12:27:45 +03:00
|
|
|
HStack(alignment: .firstTextBaseline) {
|
2026-06-15 12:40:39 +03:00
|
|
|
Text(entry.title)
|
|
|
|
|
.font(headerFont)
|
2026-06-03 12:27:45 +03:00
|
|
|
.foregroundColor(.white)
|
|
|
|
|
.lineLimit(1)
|
2026-06-15 12:40:39 +03:00
|
|
|
.minimumScaleFactor(0.72)
|
2026-06-03 12:27:45 +03:00
|
|
|
Spacer()
|
2026-06-15 12:40:39 +03:00
|
|
|
Text("\(percent)%")
|
|
|
|
|
.font(headerFont)
|
2026-06-03 12:27:45 +03:00
|
|
|
.foregroundColor(Color(white: 0.5))
|
2026-06-15 12:40:39 +03:00
|
|
|
.lineLimit(1)
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
2026-06-15 12:40:39 +03:00
|
|
|
DotGridView(progress: entry.progress, totalDots: entry.totalDots, color: accent)
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
2026-06-15 12:40:39 +03:00
|
|
|
.padding(.horizontal, horizontalPadding)
|
2026-06-10 17:31:55 +03:00
|
|
|
.containerBackground(WidgetTheme.background, for: .widget)
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
// MARK: - Reusable circular dot grid
|
2026-06-03 12:27:45 +03:00
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
struct DotGridView: View {
|
|
|
|
|
let progress: Double
|
|
|
|
|
var totalDots: Int = 100
|
2026-06-03 12:27:45 +03:00
|
|
|
let color: Color
|
|
|
|
|
var gap: CGFloat = 3
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
private var filledDots: Int {
|
|
|
|
|
Int((min(1, max(0, progress)) * Double(totalDots)).rounded(.down))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 12:27:45 +03:00
|
|
|
var body: some View {
|
|
|
|
|
GeometryReader { geo in
|
2026-06-15 12:40:39 +03:00
|
|
|
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)
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 12:40:39 +03:00
|
|
|
.frame(width: layout.width, height: layout.height, alignment: .topLeading)
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
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))
|
2026-06-03 12:27:45 +03:00
|
|
|
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)
|
2026-06-15 12:40:39 +03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 17:31:55 +03:00
|
|
|
// 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) {
|
2026-06-15 12:40:39 +03:00
|
|
|
Text(entry.eventName.widgetTitleCased())
|
|
|
|
|
.font(.system(.body, design: .monospaced))
|
2026-06-10 17:31:55 +03:00
|
|
|
.foregroundColor(WidgetTheme.accent)
|
|
|
|
|
.lineLimit(1)
|
2026-06-15 12:40:39 +03:00
|
|
|
.minimumScaleFactor(0.78)
|
|
|
|
|
.layoutPriority(1)
|
2026-06-10 17:31:55 +03:00
|
|
|
Button(intent: CycleCountdownUnitIntent()) {
|
|
|
|
|
Text(entry.fineTimeLeft)
|
2026-06-15 12:40:39 +03:00
|
|
|
.font(.system(.body, design: .monospaced))
|
2026-06-10 17:31:55 +03:00
|
|
|
.foregroundColor(WidgetTheme.textDim)
|
|
|
|
|
.lineLimit(1)
|
2026-06-15 12:40:39 +03:00
|
|
|
.minimumScaleFactor(0.9)
|
2026-06-10 17:31:55 +03:00
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
2026-06-15 12:40:39 +03:00
|
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
|
|
|
.layoutPriority(2)
|
2026-06-10 17:31:55 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 12:27:45 +03:00
|
|
|
// 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)
|
2026-06-15 12:40:39 +03:00
|
|
|
.font(.system(.caption, design: .monospaced))
|
2026-06-03 12:27:45 +03:00
|
|
|
.lineLimit(1)
|
|
|
|
|
Text(task.timeLeftLabel)
|
2026-06-15 12:40:39 +03:00
|
|
|
.font(.system(.caption2, design: .monospaced))
|
2026-06-03 12:27:45 +03:00
|
|
|
.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)
|
2026-06-11 19:33:01 +03:00
|
|
|
.tint(WidgetTheme.accent)
|
2026-06-03 12:27:45 +03:00
|
|
|
} 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)
|
2026-06-11 19:33:01 +03:00
|
|
|
.tint(WidgetTheme.accent)
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
if let days = task.daysLeft {
|
2026-06-15 12:40:39 +03:00
|
|
|
Text("\(days)").font(.system(.caption, design: .monospaced))
|
|
|
|
|
Text("days").font(.system(.caption2, design: .monospaced))
|
2026-06-03 12:27:45 +03:00
|
|
|
} else {
|
2026-06-15 12:40:39 +03:00
|
|
|
Image(systemName: "checkmark").font(.system(size: 14))
|
2026-06-03 12:27:45 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.containerBackground(.thinMaterial, for: .widget)
|
|
|
|
|
}
|
|
|
|
|
}
|