Fix residence auto-update, widget theming, and document patterns

- Fix residence detail not updating after edit:
  - DataManager.updateResidence() now updates both _residences and _myResidences
  - ResidenceViewModel auto-updates selectedResidence when data changes
  - No pull-to-refresh needed after editing

- Add widget theme support:
  - Widgets now use user's selected theme via App Group UserDefaults
  - ThemeManager has simplified version for widget extension context
  - Added WIDGET_EXTENSION compiler flag to CaseraExtension target

- Redesign widget views with organic aesthetic:
  - Updated FreeWidgetView, SmallWidgetView, MediumWidgetView, LargeWidgetView
  - Created OrganicTaskRowView, OrganicStatsView, OrganicStatPillWidget

- Document patterns in CLAUDE.md:
  - Added Mutation & Auto-Update Pattern section
  - Added iOS Shared Components documentation
  - Documented reusable buttons, forms, empty states, cards, modifiers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-17 22:58:55 -06:00
parent 7d76393e40
commit b39d37a6e8
8 changed files with 719 additions and 254 deletions

View File

@@ -253,228 +253,350 @@ struct CaseraEntryView : View {
// MARK: - Free Tier Widget View (Non-Interactive)
struct FreeWidgetView: View {
let entry: SimpleEntry
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(spacing: 12) {
VStack(spacing: OrganicSpacing.cozy) {
Spacer()
// Task count display
Text("\(entry.taskCount)")
.font(.system(size: 56, weight: .bold))
.foregroundStyle(.blue)
// Organic task count with glow
ZStack {
// Soft glow behind number
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.2),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 50
)
)
.frame(width: 100, height: 100)
Text(entry.taskCount == 1 ? "task waiting on you" : "tasks waiting on you")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Text("\(entry.taskCount)")
.font(.system(size: 52, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
Text(entry.taskCount == 1 ? "task waiting" : "tasks waiting")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
.multilineTextAlignment(.center)
Spacer()
// Subtle upgrade hint
// Subtle upgrade hint with organic styling
Text("Upgrade for interactive widgets")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.font(.system(size: 10, weight: .medium, design: .rounded))
.foregroundStyle(Color.appTextSecondary.opacity(0.6))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.15 : 0.08))
)
}
.padding(16)
.padding(OrganicSpacing.cozy)
}
}
// MARK: - Small Widget View
struct SmallWidgetView: View {
let entry: SimpleEntry
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Task Count
VStack(alignment: .leading, spacing: 4) {
Text("\(entry.taskCount)")
.font(.system(size: 36, weight: .bold))
.foregroundStyle(.blue)
// Task Count with organic glow
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text("\(entry.taskCount)")
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
if entry.taskCount > 0 {
Text(entry.taskCount == 1 ? "upcoming task" : "upcoming tasks")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
if entry.taskCount > 0 {
Text(entry.taskCount == 1 ? "task" : "tasks")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
}
}
Spacer()
// Small decorative accent
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.15), Color.clear],
center: .center,
startRadius: 0,
endRadius: 20
)
)
.frame(width: 40, height: 40)
.offset(x: 10, y: -10)
}
// Next Task with Complete Button
Spacer(minLength: 8)
// Next Task Card
if let nextTask = entry.nextTask {
VStack(alignment: .leading, spacing: 6) {
Text("NEXT UP")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.tracking(0.5)
Text(nextTask.title)
.font(.system(size: 12, weight: .semibold))
.font(.system(size: 12, weight: .semibold, design: .rounded))
.lineLimit(1)
.foregroundStyle(.primary)
.foregroundStyle(Color.appTextPrimary)
HStack(spacing: 8) {
HStack(spacing: 6) {
if let dueDate = nextTask.dueDate {
HStack(spacing: 4) {
HStack(spacing: 3) {
Image(systemName: "calendar")
.font(.system(size: 9))
.font(.system(size: 8, weight: .medium))
Text(formatWidgetDate(dueDate))
.font(.system(size: 10, weight: .medium))
.font(.system(size: 9, weight: .semibold, design: .rounded))
}
.foregroundStyle(nextTask.isOverdue ? .red : .orange)
.foregroundStyle(nextTask.isOverdue ? Color.appError : Color.appAccent)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Capsule()
.fill((nextTask.isOverdue ? Color.appError : Color.appAccent).opacity(colorScheme == .dark ? 0.2 : 0.12))
)
}
Spacer()
// Complete button
// Organic complete button
Button(intent: CompleteTaskIntent(taskId: nextTask.id, taskTitle: nextTask.title)) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 20))
.foregroundStyle(.green)
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.15))
.frame(width: 28, height: 28)
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Color.appPrimary)
}
}
.buttonStyle(.plain)
}
}
.padding(.top, 8)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(OrganicSpacing.compact)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.12 : 0.08))
)
} else {
HStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.green)
// Empty state
HStack(spacing: 8) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.15))
.frame(width: 24, height: 24)
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Color.appPrimary)
}
Text("All caught up!")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.green.opacity(0.1))
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.1 : 0.06))
)
}
}
.padding(16)
.padding(14)
}
}
// MARK: - Medium Widget View
struct MediumWidgetView: View {
let entry: SimpleEntry
@Environment(\.colorScheme) var colorScheme
var body: some View {
HStack(spacing: 16) {
// Left side - Task count
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 0) {
// Left side - Task count with organic styling
VStack(alignment: .center, spacing: 4) {
Spacer()
Text("\(entry.taskCount)")
.font(.system(size: 42, weight: .bold))
.foregroundStyle(.blue)
ZStack {
// Soft glow
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.15), Color.clear],
center: .center,
startRadius: 0,
endRadius: 35
)
)
.frame(width: 70, height: 70)
VStack(alignment: .leading) {
Text("upcoming")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(entry.taskCount == 1 ? "task" : "tasks")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(2)
Text("\(entry.taskCount)")
.font(.system(size: 38, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
Text(entry.taskCount == 1 ? "task" : "tasks")
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
Spacer()
}
.frame(maxWidth: 75)
.frame(width: 85)
Divider()
// Organic divider
Rectangle()
.fill(
LinearGradient(
colors: [Color.appTextSecondary.opacity(0), Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 1)
.padding(.vertical, 12)
// Right side - Next tasks with interactive buttons
// Right side - Task list
VStack(alignment: .leading, spacing: 6) {
if entry.nextTask != nil {
Text("NEXT UP")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.tracking(0.5)
ForEach(Array(entry.upcomingTasks.prefix(3).enumerated()), id: \.element.id) { index, task in
InteractiveTaskRowView(task: task)
OrganicTaskRowView(task: task, compact: true)
}
Spacer()
Spacer(minLength: 0)
} else {
Spacer()
// Empty state
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.green)
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.15))
.frame(width: 36, height: 36)
Image(systemName: "checkmark")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(Color.appPrimary)
}
Text("All caught up!")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
Spacer()
}
}
.frame(maxWidth: .infinity)
.padding(.leading, 12)
}
.padding(16)
.padding(14)
}
}
// MARK: - Interactive Task Row (for Medium widget)
struct InteractiveTaskRowView: View {
// MARK: - Organic Task Row View
struct OrganicTaskRowView: View {
let task: CacheManager.CustomTask
var compact: Bool = false
var showResidence: Bool = false
@Environment(\.colorScheme) var colorScheme
var body: some View {
HStack(spacing: 8) {
// Checkbox to complete task (color indicates priority)
HStack(spacing: compact ? 8 : 10) {
// Organic checkbox button
Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) {
Image(systemName: "circle")
.font(.system(size: 18))
.foregroundStyle(priorityColor)
ZStack {
Circle()
.stroke(
LinearGradient(
colors: [priorityColor, priorityColor.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 2
)
.frame(width: compact ? 18 : 22, height: compact ? 18 : 22)
Circle()
.fill(priorityColor.opacity(colorScheme == .dark ? 0.15 : 0.1))
.frame(width: compact ? 14 : 18, height: compact ? 14 : 18)
}
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: compact ? 2 : 3) {
Text(task.title)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
.foregroundStyle(.primary)
.font(.system(size: compact ? 11 : 12, weight: .semibold, design: .rounded))
.lineLimit(compact ? 1 : 2)
.foregroundStyle(Color.appTextPrimary)
if let dueDate = task.dueDate {
HStack(spacing: 3) {
Image(systemName: "calendar")
.font(.system(size: 8))
Text(formatWidgetDate(dueDate))
.font(.system(size: 9, weight: .medium))
HStack(spacing: 8) {
if showResidence, let residenceName = task.residenceName, !residenceName.isEmpty {
HStack(spacing: 2) {
Image("icon")
.resizable()
.frame(width: 7, height: 7)
Text(residenceName)
.font(.system(size: 9, weight: .medium, design: .rounded))
}
.foregroundStyle(Color.appTextSecondary)
}
if let dueDate = task.dueDate {
HStack(spacing: 2) {
Image(systemName: "calendar")
.font(.system(size: 7, weight: .medium))
Text(formatWidgetDate(dueDate))
.font(.system(size: 9, weight: .semibold, design: .rounded))
}
.foregroundStyle(task.isOverdue ? Color.appError : Color.appAccent)
}
.foregroundStyle(task.isOverdue ? .red : .secondary)
}
}
Spacer()
Spacer(minLength: 0)
}
.padding(.vertical, 3)
.padding(.vertical, compact ? 4 : 6)
.padding(.horizontal, compact ? 6 : 8)
.background(
RoundedRectangle(cornerRadius: compact ? 10 : 12, style: .continuous)
.fill(priorityColor.opacity(colorScheme == .dark ? 0.12 : 0.06))
)
}
private var priorityColor: Color {
// Overdue tasks are always red
if task.isOverdue {
return .red
return Color.appError
}
switch task.priority?.lowercased() {
case "urgent": return .red
case "high": return .orange
case "medium": return .yellow
default: return .green
case "urgent": return Color.appError
case "high": return Color.appAccent
case "medium": return Color(red: 0.92, green: 0.70, blue: 0.03) // Yellow
default: return Color.appPrimary
}
}
}
@@ -482,185 +604,137 @@ struct InteractiveTaskRowView: View {
// MARK: - Large Widget View
struct LargeWidgetView: View {
let entry: SimpleEntry
@Environment(\.colorScheme) var colorScheme
private var maxTasksToShow: Int { 5 }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if entry.upcomingTasks.isEmpty {
// Empty state - centered
// Empty state - centered with organic styling
Spacer()
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
VStack(spacing: 14) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.2), Color.appPrimary.opacity(0.05), Color.clear],
center: .center,
startRadius: 0,
endRadius: 40
)
)
.frame(width: 80, height: 80)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44, weight: .medium))
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
Text("All caught up!")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.secondary)
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
Spacer()
// Stats even when empty
LargeWidgetStatsView(entry: entry)
OrganicStatsView(entry: entry)
} else {
// Tasks section - always at top
// Tasks section with organic rows
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in
LargeInteractiveTaskRowView(task: task)
OrganicTaskRowView(task: task, showResidence: true)
}
if entry.upcomingTasks.count > maxTasksToShow {
Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 2)
.padding(.top, 4)
}
}
Spacer(minLength: 12)
Spacer(minLength: 10)
// Stats section at bottom
LargeWidgetStatsView(entry: entry)
OrganicStatsView(entry: entry)
}
}
.padding(14)
}
}
// MARK: - Large Widget Stats View
struct LargeWidgetStatsView: View {
// MARK: - Organic Stats View
struct OrganicStatsView: View {
let entry: SimpleEntry
var body: some View {
HStack(spacing: 0) {
// Overdue
StatItem(
value: entry.overdueCount,
label: "Overdue",
color: entry.overdueCount > 0 ? .red : .secondary
)
Divider()
.frame(height: 30)
// Next 7 Days (exclusive of overdue)
StatItem(
value: entry.dueNext7DaysCount,
label: "7 Days",
color: .orange
)
Divider()
.frame(height: 30)
// Next 30 Days (days 8-30)
StatItem(
value: entry.dueNext30DaysCount,
label: "30 Days",
color: .green
)
}
.padding(.vertical, 10)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.primary.opacity(0.05))
)
}
}
// MARK: - Stat Item View
struct StatItem: View {
let value: Int
let label: String
let color: Color
var body: some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(color)
Text(label)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Large Interactive Task Row
struct LargeInteractiveTaskRowView: View {
let task: CacheManager.CustomTask
@Environment(\.colorScheme) var colorScheme
var body: some View {
HStack(spacing: 8) {
// Checkbox to complete task (color indicates priority)
Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) {
Image(systemName: "circle")
.font(.system(size: 20))
.foregroundStyle(priorityColor)
}
.buttonStyle(.plain)
// Overdue
OrganicStatPillWidget(
value: entry.overdueCount,
label: "Overdue",
color: entry.overdueCount > 0 ? Color.appError : Color.appTextSecondary
)
VStack(alignment: .leading, spacing: 2) {
Text(task.title)
.font(.system(size: 12, weight: .medium))
.lineLimit(2)
.foregroundStyle(.primary)
// Next 7 Days
OrganicStatPillWidget(
value: entry.dueNext7DaysCount,
label: "7 Days",
color: Color.appAccent
)
HStack(spacing: 10) {
if let residenceName = task.residenceName, !residenceName.isEmpty {
HStack(spacing: 2) {
Image("icon")
.resizable()
.frame(width: 7, height: 7)
.font(.system(size: 7))
Text(residenceName)
.font(.system(size: 9))
}
.foregroundStyle(.secondary)
}
if let dueDate = task.dueDate {
HStack(spacing: 2) {
Image(systemName: "calendar")
.font(.system(size: 7))
Text(formatWidgetDate(dueDate))
.font(.system(size: 9, weight: task.isOverdue ? .semibold : .regular))
}
.foregroundStyle(task.isOverdue ? .red : .secondary)
}
}
}
Spacer()
// Next 30 Days
OrganicStatPillWidget(
value: entry.dueNext30DaysCount,
label: "30 Days",
color: Color.appPrimary
)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.primary.opacity(0.05))
)
}
}
private var priorityColor: Color {
// Overdue tasks are always red
if task.isOverdue {
return .red
}
switch task.priority?.lowercased() {
case "urgent": return .red
case "high": return .orange
case "medium": return .yellow
default: return .green
// MARK: - Organic Stat Pill for Widget
struct OrganicStatPillWidget: View {
let value: Int
let label: String
let color: Color
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(spacing: 3) {
Text("\(value)")
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [color, color.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text(label)
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(Color.appTextSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(color.opacity(colorScheme == .dark ? 0.15 : 0.08))
)
}
}
@@ -670,7 +744,22 @@ struct Casera: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
CaseraEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
.containerBackground(for: .widget) {
// Organic warm gradient background
ZStack {
Color.appBackgroundPrimary
// Subtle accent gradient
LinearGradient(
colors: [
Color.appPrimary.opacity(0.06),
Color.clear
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
.configurationDisplayName("Casera Tasks")
.description("View and complete your upcoming tasks.")