- 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
415 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|