Files
KisaniCal/KisaniCalWidget/KisaniCalWidget.swift
kutesir 5e232f2d81 feat: add widget extension — small, medium, large
- KisaniCalWidget: 3 sizes sharing the app's design language
  Small: date badge + kisaniCAL. wordmark + task count
  Medium: badge + task list (3 items) + workout row
  Large: badge + full task list (6 items) + workout row

- WidgetDataStore (Shared/): lightweight Codable types for App Group
  UserDefaults cross-process data exchange

- WidgetBridge: TaskViewModel/WorkoutViewModel extensions that sync
  data into App Group and call WidgetCenter.reloadAllTimelines()

- ContentView: calls syncWidget() on appear and scene becoming active

- project.yml + entitlements updated; project regenerated via xcodegen
2026-05-28 19:48:01 +03:00

415 lines
15 KiB
Swift

import WidgetKit
import SwiftUI
// MARK: - Colors (no UIKit dependency)
private enum WC {
static let accent = Color(red: 232/255, green: 98/255, blue: 42/255)
static let green = Color(red: 62/255, green: 207/255, blue: 142/255)
static let blue = Color(red: 77/255, green: 157/255, blue: 224/255)
static func bg(_ s: ColorScheme) -> Color {
s == .dark ? Color(red: 9/255, green: 9/255, blue: 9/255)
: Color(red: 245/255, green: 243/255, blue: 240/255)
}
static func surface(_ s: ColorScheme) -> Color {
s == .dark ? Color(red: 19/255, green: 19/255, blue: 19/255)
: Color(red: 255/255, green: 253/255, blue: 250/255)
}
static func text(_ s: ColorScheme) -> Color {
s == .dark ? Color(red: 240/255, green: 237/255, blue: 232/255)
: Color(red: 26/255, green: 23/255, blue: 20/255)
}
static func text2(_ s: ColorScheme) -> Color { text(s).opacity(0.48) }
static func text3(_ s: ColorScheme) -> Color { text(s).opacity(0.30) }
static func border(_ s: ColorScheme) -> Color {
s == .dark ? Color.white.opacity(0.07) : Color.black.opacity(0.09)
}
}
// MARK: - Timeline Entry
struct KisaniEntry: TimelineEntry {
let date: Date
let todayTasks: [WidgetDataStore.WidgetTask]
let overdueTasks: [WidgetDataStore.WidgetTask]
let workout: WidgetDataStore.WidgetWorkout?
}
// MARK: - Provider
struct KisaniProvider: TimelineProvider {
func placeholder(in context: Context) -> KisaniEntry {
KisaniEntry(
date: Date(),
todayTasks: previewTasks,
overdueTasks: [],
workout: WidgetDataStore.WidgetWorkout(
name: "Shoulders & Arms", sectionsCount: 4, hasWorkoutToday: true)
)
}
func getSnapshot(in context: Context, completion: @escaping (KisaniEntry) -> Void) {
completion(entry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<KisaniEntry>) -> Void) {
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
completion(Timeline(entries: [entry()], policy: .after(next)))
}
private func entry() -> KisaniEntry {
let all = WidgetDataStore.loadTasks()
let today = all.filter { $0.isToday }
let overdue = all.filter { $0.isOverdue }
let workout = WidgetDataStore.loadWorkout()
return KisaniEntry(date: Date(), todayTasks: today, overdueTasks: overdue, workout: workout)
}
private var previewTasks: [WidgetDataStore.WidgetTask] {
[
.init(id: UUID(), title: "Azure exam prep", dueDate: Date(), isToday: true, isOverdue: false),
.init(id: UUID(), title: "Push AWS project", dueDate: Date(), isToday: true, isOverdue: false),
.init(id: UUID(), title: "Review PR #42", dueDate: Date(), isToday: false, isOverdue: true),
]
}
}
// MARK: - Widget Bundle
@main
struct KisaniCalWidgetBundle: WidgetBundle {
var body: some Widget {
KisaniCalWidget()
}
}
struct KisaniCalWidget: Widget {
let kind = "KisaniCalWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in
KisaniWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
}
.configurationDisplayName("Kisani Cal")
.description("Today's tasks and workout at a glance.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
// MARK: - Root Widget View
struct KisaniWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: KisaniEntry
var body: some View {
switch family {
case .systemSmall: SmallWidget(entry: entry)
case .systemMedium: MediumWidget(entry: entry)
default: LargeWidget(entry: entry)
}
}
}
// MARK: - Shared Sub-Views
private struct DateBadge: View {
@Environment(\.colorScheme) private var cs
let size: CGFloat
private static let monthFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM"; return f
}()
var body: some View {
let day = Calendar.current.component(.day, from: Date())
VStack(spacing: 0) {
Text(Self.monthFmt.string(from: Date()).uppercased())
.font(.system(size: size * 0.18, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.kerning(1)
.frame(maxWidth: .infinity)
.frame(height: size * 0.32)
.background(WC.accent)
Text("\(day)")
.font(.system(size: size * 0.48, weight: .bold, design: .monospaced))
.foregroundColor(WC.text(cs))
.frame(maxWidth: .infinity)
.frame(height: size * 0.68)
.background(WC.surface(cs))
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.22))
.overlay(
RoundedRectangle(cornerRadius: size * 0.22)
.stroke(WC.border(cs), lineWidth: 1)
)
}
}
private struct TaskRow: View {
@Environment(\.colorScheme) private var cs
let task: WidgetDataStore.WidgetTask
private static let fmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "d MMM"; return f
}()
var body: some View {
HStack(spacing: 5) {
Circle()
.stroke(task.isOverdue ? WC.accent : WC.text3(cs), lineWidth: 1.2)
.frame(width: 7, height: 7)
Text(task.title)
.font(.system(size: 11, weight: .regular, design: .default))
.foregroundColor(WC.text(cs))
.lineLimit(1)
Spacer(minLength: 0)
if let d = task.dueDate {
Text(task.isOverdue ? "Overdue" : Self.fmt.string(from: d))
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundColor(task.isOverdue ? WC.accent : WC.text3(cs))
}
}
}
}
private struct WorkoutRow: View {
@Environment(\.colorScheme) private var cs
let workout: WidgetDataStore.WidgetWorkout
var body: some View {
HStack(spacing: 6) {
WorkoutSegBar(count: workout.sectionsCount)
.frame(width: 22, height: 22)
Text(workout.name)
.font(.system(size: 11, weight: .semibold, design: .default))
.foregroundColor(WC.text(cs))
.lineLimit(1)
Spacer(minLength: 0)
Text("Workout")
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundColor(WC.blue)
}
}
}
private struct WorkoutSegBar: View {
@Environment(\.colorScheme) private var cs
let count: Int
var body: some View {
let n = max(1, min(6, count))
VStack(spacing: 1.5) {
ForEach(0..<n, id: \.self) { _ in
RoundedRectangle(cornerRadius: 1)
.fill(WC.blue)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding(4)
.background(WC.blue.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
// MARK: - Small Widget
private struct SmallWidget: View {
@Environment(\.colorScheme) private var cs
let entry: KisaniEntry
var body: some View {
ZStack {
WC.bg(cs).ignoresSafeArea()
VStack(spacing: 10) {
DateBadge(size: 72)
VStack(spacing: 2) {
HStack(spacing: 0) {
Text("kisani")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundColor(WC.text2(cs))
Text("CAL")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundColor(WC.text(cs))
Text(".")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundColor(WC.accent)
}
let todayCount = entry.todayTasks.count
let overdueCount = entry.overdueTasks.count
Group {
if overdueCount > 0 {
Text("\(overdueCount) overdue · \(todayCount) today")
.foregroundColor(WC.accent)
} else if todayCount > 0 {
Text("\(todayCount) task\(todayCount == 1 ? "" : "s") today")
.foregroundColor(WC.text2(cs))
} else {
Text("All clear")
.foregroundColor(WC.text3(cs))
}
}
.font(.system(size: 9, weight: .regular, design: .monospaced))
}
}
.padding(14)
}
}
}
// MARK: - Medium Widget
private struct MediumWidget: View {
@Environment(\.colorScheme) private var cs
let entry: KisaniEntry
private var displayTasks: [WidgetDataStore.WidgetTask] {
let all = entry.overdueTasks + entry.todayTasks
return Array(all.prefix(3))
}
var body: some View {
ZStack {
WC.bg(cs).ignoresSafeArea()
HStack(alignment: .top, spacing: 14) {
// Left: badge + brand
VStack(spacing: 8) {
DateBadge(size: 64)
HStack(spacing: 0) {
Text("kisani")
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundColor(WC.text2(cs))
Text("CAL.")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(WC.accent)
}
}
// Right: tasks + workout
VStack(alignment: .leading, spacing: 5) {
if displayTasks.isEmpty && entry.workout == nil {
Spacer()
Text("Nothing scheduled")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(WC.text3(cs))
Spacer()
} else {
ForEach(displayTasks) { task in
TaskRow(task: task)
}
if let workout = entry.workout, workout.hasWorkoutToday,
displayTasks.count < 3 {
if !displayTasks.isEmpty {
Divider().opacity(0.4)
}
WorkoutRow(workout: workout)
}
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(14)
}
}
}
// MARK: - Large Widget
private struct LargeWidget: View {
@Environment(\.colorScheme) private var cs
let entry: KisaniEntry
private static let dayFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
}()
private var allTasks: [WidgetDataStore.WidgetTask] {
Array((entry.overdueTasks + entry.todayTasks).prefix(6))
}
var body: some View {
ZStack {
WC.bg(cs).ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
// Header row
HStack(spacing: 12) {
DateBadge(size: 56)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 0) {
Text("kisani")
.font(.system(size: 14, weight: .regular, design: .monospaced))
.foregroundColor(WC.text2(cs))
Text("CAL")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(WC.text(cs))
Text(".")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(WC.accent)
}
Text(Self.dayFmt.string(from: Date()))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(WC.text2(cs))
}
Spacer()
let total = entry.todayTasks.count + entry.overdueTasks.count
VStack(alignment: .trailing, spacing: 2) {
Text("\(total)")
.font(.system(size: 22, weight: .bold, design: .monospaced))
.foregroundColor(entry.overdueTasks.isEmpty ? WC.text(cs) : WC.accent)
Text("tasks")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(WC.text3(cs))
}
}
.padding(.horizontal, 14)
.padding(.top, 14)
.padding(.bottom, 12)
Divider().opacity(0.4).padding(.horizontal, 14)
// Task list
VStack(alignment: .leading, spacing: 7) {
if allTasks.isEmpty {
HStack {
Spacer()
Text("Nothing due today")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(WC.text3(cs))
Spacer()
}
.padding(.vertical, 8)
} else {
ForEach(allTasks) { task in
TaskRow(task: task)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
Spacer(minLength: 0)
// Workout row
if let workout = entry.workout, workout.hasWorkoutToday {
Divider().opacity(0.4).padding(.horizontal, 14)
WorkoutRow(workout: workout)
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
}
}
}
}