Files
KisaniCal/KisaniCalWidgets/WidgetViews.swift

255 lines
9.1 KiB
Swift
Raw Permalink Normal View History

import SwiftUI
import WidgetKit
// MARK: - Shared widget theme (slate background + brand orange)
enum WidgetTheme {
// 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)
static let text = Color.white
static let textDim = Color.white.opacity(0.55)
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 {
let progress: Double // 01
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
struct ProgressDotView: View {
let entry: ProgressDotEntry
let accent: Color
@Environment(\.widgetFamily) private var family
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 spacing: CGFloat {
family == .accessoryRectangular ? 4 : 10
}
private var horizontalPadding: CGFloat {
switch family {
case .systemLarge: return 8
case .accessoryRectangular: return 0
default: return 4
}
}
private var isAccessory: Bool { family == .accessoryRectangular }
// 100 tiny dots are illegible in a lock-screen pill show a coarse grid there.
private var dotCount: Int { isAccessory ? 28 : entry.totalDots }
var body: some View {
let percent = Int((entry.progress * 100).rounded(.down))
VStack(alignment: .leading, spacing: spacing) {
HStack(alignment: .firstTextBaseline) {
Text(entry.title)
.font(headerFont)
.foregroundColor(isAccessory ? .primary : .white)
.lineLimit(1)
.minimumScaleFactor(0.72)
Spacer()
Text("\(percent)%")
.font(headerFont)
.foregroundColor(isAccessory ? .primary.opacity(0.7) : Color(white: 0.5))
.lineLimit(1)
}
DotGridView(progress: entry.progress, totalDots: dotCount,
color: isAccessory ? .primary : accent)
}
.padding(.horizontal, horizontalPadding)
.containerBackground(for: .widget) {
if isAccessory { AccessoryWidgetBackground() } else { WidgetTheme.background }
}
}
}
// MARK: - Reusable circular dot grid
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 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)
}
}
.frame(width: layout.width, height: layout.height, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
@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)
}
}
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)
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
}
}
// 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(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(.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)
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(.caption, design: .monospaced))
.lineLimit(1)
Text(task.timeLeftLabel)
.font(.system(.caption2, design: .monospaced))
.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(WidgetTheme.accent)
} 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(WidgetTheme.accent)
}
VStack(spacing: 0) {
if let days = task.daysLeft {
Text("\(days)").font(.system(.caption, design: .monospaced))
Text("days").font(.system(.caption2, design: .monospaced))
} else {
Image(systemName: "checkmark").font(.system(size: 14))
}
}
}
.containerBackground(.thinMaterial, for: .widget)
}
}