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:
322
CLAUDE.md
322
CLAUDE.md
@@ -146,6 +146,328 @@ Task {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Mutation & Auto-Update Pattern
|
||||||
|
|
||||||
|
**CRITICAL: When implementing CRUD operations, follow this pattern to ensure UI updates automatically without requiring pull-to-refresh.**
|
||||||
|
|
||||||
|
#### Kotlin DataManager Update Methods
|
||||||
|
|
||||||
|
When updating a single item, ensure ALL related caches are updated:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ✅ CORRECT: Update all caches that contain the item
|
||||||
|
fun updateResidence(residence: Residence) {
|
||||||
|
// Update primary list
|
||||||
|
_residences.value = _residences.value.map {
|
||||||
|
if (it.id == residence.id) residence else it
|
||||||
|
}
|
||||||
|
// Also update related caches (myResidences is checked by getResidence)
|
||||||
|
_myResidences.value?.let { myRes ->
|
||||||
|
val updatedResidences = myRes.residences.map {
|
||||||
|
if (it.id == residence.id) residence else it
|
||||||
|
}
|
||||||
|
_myResidences.value = myRes.copy(residences = updatedResidences)
|
||||||
|
}
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG: Only updating one cache causes stale data
|
||||||
|
fun updateResidence(residence: Residence) {
|
||||||
|
_residences.value = _residences.value.map {
|
||||||
|
if (it.id == residence.id) residence else it
|
||||||
|
}
|
||||||
|
// Missing: _myResidences update - getResidence may return stale data!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### iOS ViewModel: Auto-Update Selected Items
|
||||||
|
|
||||||
|
When a detail view displays a `selectedItem`, the ViewModel must auto-update it when DataManager data changes:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ✅ CORRECT: Auto-update selectedResidence when data changes
|
||||||
|
init() {
|
||||||
|
// Observe residences list
|
||||||
|
DataManagerObservable.shared.$residences
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] residences in
|
||||||
|
self?.residences = residences
|
||||||
|
// Auto-update selectedResidence if it exists in the updated list
|
||||||
|
if let currentSelected = self?.selectedResidence,
|
||||||
|
let updatedResidence = residences.first(where: { $0.id == currentSelected.id }) {
|
||||||
|
self?.selectedResidence = updatedResidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Also observe myResidences (another source of residence data)
|
||||||
|
DataManagerObservable.shared.$myResidences
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] myResidences in
|
||||||
|
self?.myResidences = myResidences
|
||||||
|
// Auto-update selectedResidence here too
|
||||||
|
if let currentSelected = self?.selectedResidence,
|
||||||
|
let updatedResidence = myResidences?.residences.first(where: { $0.id == currentSelected.id }) {
|
||||||
|
self?.selectedResidence = updatedResidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG: Only storing list data, selectedResidence becomes stale
|
||||||
|
init() {
|
||||||
|
DataManagerObservable.shared.$residences
|
||||||
|
.sink { [weak self] residences in
|
||||||
|
self?.residences = residences
|
||||||
|
// Missing: selectedResidence auto-update!
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Complete Data Flow for Mutations
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User edits item in FormView (has its own ViewModel instance)
|
||||||
|
2. FormView.viewModel calls APILayer.updateItem()
|
||||||
|
3. APILayer calls API, on success:
|
||||||
|
- DataManager.updateItem() updates ALL relevant caches
|
||||||
|
- Returns updated item
|
||||||
|
4. DataManager StateFlows emit new values
|
||||||
|
5. DataManagerObservable picks up changes, publishes to @Published
|
||||||
|
6. ALL ViewModels observing that data receive updates via Combine
|
||||||
|
7. Each ViewModel's sink checks if selectedItem matches and updates it
|
||||||
|
8. SwiftUI re-renders automatically - NO pull-to-refresh needed
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Checklist for New CRUD Features
|
||||||
|
|
||||||
|
- [ ] Kotlin `DataManager.updateX()` updates ALL caches containing that data type
|
||||||
|
- [ ] iOS ViewModel observes ALL relevant `DataManagerObservable` publishers
|
||||||
|
- [ ] iOS ViewModel auto-updates `selectedX` in each Combine sink
|
||||||
|
- [ ] No manual refresh calls needed after mutations (architecture handles it)
|
||||||
|
|
||||||
|
### iOS Shared Components (`iosApp/iosApp/Shared/`)
|
||||||
|
|
||||||
|
**CRITICAL: Always check the Shared folder for reusable components before creating new ones.** This folder contains standardized UI components, view modifiers, and utilities that ensure consistency across the app.
|
||||||
|
|
||||||
|
#### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
iosApp/iosApp/Shared/
|
||||||
|
├── Components/
|
||||||
|
│ ├── FormComponents.swift # Form headers, sections, text fields
|
||||||
|
│ ├── ButtonStyles.swift # Primary, secondary, destructive buttons
|
||||||
|
│ └── SharedEmptyStateView.swift # Empty state views
|
||||||
|
├── Modifiers/
|
||||||
|
│ └── CardModifiers.swift # Card styling modifiers
|
||||||
|
├── Extensions/
|
||||||
|
│ ├── ViewExtensions.swift # Form styling, loading overlays
|
||||||
|
│ ├── StringExtensions.swift # String utilities
|
||||||
|
│ ├── DoubleExtensions.swift # Number formatting
|
||||||
|
│ └── DateExtensions.swift # Date formatting
|
||||||
|
└── Utilities/
|
||||||
|
├── ValidationHelpers.swift # Form validation
|
||||||
|
└── SharedErrorMessageParser.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Reusable Button Components
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Primary filled button (main actions)
|
||||||
|
PrimaryButton(title: "Save", icon: "checkmark", isLoading: isLoading) {
|
||||||
|
saveAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary outlined button
|
||||||
|
SecondaryButton(title: "Cancel", icon: "xmark") {
|
||||||
|
cancelAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructive button (delete, remove)
|
||||||
|
DestructiveButton(title: "Delete", icon: "trash") {
|
||||||
|
deleteAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text-only button
|
||||||
|
TextButton(title: "Learn More", icon: "arrow.right") {
|
||||||
|
navigateAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact button for cards/rows
|
||||||
|
CompactButton(title: "Edit", icon: "pencil", color: .appPrimary, isFilled: false) {
|
||||||
|
editAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organic button with gradient (premium feel)
|
||||||
|
OrganicPrimaryButton(title: "Continue", isLoading: isLoading) {
|
||||||
|
continueAction()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Reusable Form Components
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Form header with icon
|
||||||
|
FormHeader(
|
||||||
|
icon: "house.fill",
|
||||||
|
title: "Add Property",
|
||||||
|
subtitle: "Enter your property details"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Organic form header (radial gradient style)
|
||||||
|
OrganicFormHeader(
|
||||||
|
icon: "person.fill",
|
||||||
|
title: "Create Account",
|
||||||
|
subtitle: "Join Casera today"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Form section with icon header
|
||||||
|
IconFormSection(icon: "info.circle", title: "Details", footer: "Optional info") {
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error display section
|
||||||
|
if let error = errorMessage {
|
||||||
|
ErrorSection(message: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success message section
|
||||||
|
SuccessSection(message: "Changes saved successfully")
|
||||||
|
|
||||||
|
// Form action button (submit)
|
||||||
|
FormActionButton(title: "Submit", isLoading: isSubmitting) {
|
||||||
|
submitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text field with icon
|
||||||
|
IconTextField(
|
||||||
|
icon: "envelope",
|
||||||
|
placeholder: "Email",
|
||||||
|
text: $email,
|
||||||
|
keyboardType: .emailAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secure text field with visibility toggle
|
||||||
|
SecureIconTextField(
|
||||||
|
icon: "lock",
|
||||||
|
placeholder: "Password",
|
||||||
|
text: $password,
|
||||||
|
isVisible: $showPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field label with optional required indicator
|
||||||
|
FieldLabel(text: "Username", isRequired: true)
|
||||||
|
|
||||||
|
// Field error message
|
||||||
|
FieldError(message: "This field is required")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Reusable Empty State Views
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Standard empty state
|
||||||
|
StandardEmptyStateView(
|
||||||
|
icon: "tray",
|
||||||
|
title: "No Items",
|
||||||
|
subtitle: "Get started by adding your first item",
|
||||||
|
actionLabel: "Add Item",
|
||||||
|
action: { showAddSheet = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Organic empty state (matches app design)
|
||||||
|
OrganicEmptyState(
|
||||||
|
icon: "house",
|
||||||
|
title: "No Properties",
|
||||||
|
subtitle: "Add your first property to get started",
|
||||||
|
actionLabel: "Add Property",
|
||||||
|
action: { showAddSheet = true },
|
||||||
|
blobVariation: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simple list empty state
|
||||||
|
ListEmptyState(icon: "doc.text", message: "No documents found")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Reusable Card Modifiers
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Standard card styling
|
||||||
|
VStack { content }
|
||||||
|
.standardCard() // Default padding, background, shadow
|
||||||
|
|
||||||
|
// Compact card (smaller padding)
|
||||||
|
HStack { content }
|
||||||
|
.compactCard()
|
||||||
|
|
||||||
|
// Organic card (matches design system)
|
||||||
|
VStack { content }
|
||||||
|
.organicCardStyle(showBlob: true, blobVariation: 0)
|
||||||
|
|
||||||
|
// List row card
|
||||||
|
ForEach(items) { item in
|
||||||
|
ItemRow(item: item)
|
||||||
|
.listRowCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata pill (tags, badges)
|
||||||
|
Text("Active")
|
||||||
|
.metadataPill()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Reusable View Extensions
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Standard form styling (ALWAYS use on Form views)
|
||||||
|
Form {
|
||||||
|
// content
|
||||||
|
}
|
||||||
|
.standardFormStyle() // Applies .listStyle(.plain), .scrollContentBackground(.hidden)
|
||||||
|
|
||||||
|
// Section backgrounds
|
||||||
|
Section { content }
|
||||||
|
.sectionBackground() // Uses Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
Section { headerContent }
|
||||||
|
.headerSectionBackground() // Clear background for headers
|
||||||
|
|
||||||
|
// Loading overlay
|
||||||
|
content
|
||||||
|
.loadingOverlay(isLoading: isLoading, message: "Saving...")
|
||||||
|
|
||||||
|
// Conditional modifiers
|
||||||
|
content
|
||||||
|
.if(condition) { view in
|
||||||
|
view.opacity(0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss keyboard on tap
|
||||||
|
ScrollView { content }
|
||||||
|
.dismissKeyboardOnTap()
|
||||||
|
|
||||||
|
// Standard loading view
|
||||||
|
StandardLoadingView(message: "Loading...")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### When to Create New vs Reuse
|
||||||
|
|
||||||
|
**Reuse existing components when:**
|
||||||
|
- Building forms (use `FormComponents.swift`)
|
||||||
|
- Adding buttons (use `ButtonStyles.swift`)
|
||||||
|
- Showing empty states (use `SharedEmptyStateView.swift`)
|
||||||
|
- Styling cards (use `CardModifiers.swift`)
|
||||||
|
- Styling forms (use `ViewExtensions.swift`)
|
||||||
|
|
||||||
|
**Create new components when:**
|
||||||
|
- The pattern will be used 3+ times
|
||||||
|
- It doesn't fit existing components
|
||||||
|
- It's domain-specific (put in feature folder, not Shared)
|
||||||
|
|
||||||
|
**Add to Shared folder when:**
|
||||||
|
- Component is generic and reusable across features
|
||||||
|
- It follows existing design patterns
|
||||||
|
- It doesn't depend on specific business logic
|
||||||
|
|
||||||
### iOS Design System
|
### iOS Design System
|
||||||
|
|
||||||
**CRITICAL**: Always use the custom design system colors defined in `iosApp/iosApp/Design/DesignSystem.swift` and Xcode Asset Catalog. Never use system colors directly.
|
**CRITICAL**: Always use the custom design system colors defined in `iosApp/iosApp/Design/DesignSystem.swift` and Xcode Asset Catalog. Never use system colors directly.
|
||||||
|
|||||||
@@ -370,6 +370,13 @@ object DataManager {
|
|||||||
_residences.value = _residences.value.map {
|
_residences.value = _residences.value.map {
|
||||||
if (it.id == residence.id) residence else it
|
if (it.id == residence.id) residence else it
|
||||||
}
|
}
|
||||||
|
// Also update myResidences if present (used by getResidence cache lookup)
|
||||||
|
_myResidences.value?.let { myRes ->
|
||||||
|
val updatedResidences = myRes.residences.map {
|
||||||
|
if (it.id == residence.id) residence else it
|
||||||
|
}
|
||||||
|
_myResidences.value = myRes.copy(residences = updatedResidences)
|
||||||
|
}
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ package com.example.casera.network
|
|||||||
*/
|
*/
|
||||||
object ApiConfig {
|
object ApiConfig {
|
||||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||||
val CURRENT_ENV = Environment.LOCAL
|
val CURRENT_ENV = Environment.DEV
|
||||||
|
|
||||||
enum class Environment {
|
enum class Environment {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
|
|||||||
@@ -253,228 +253,350 @@ struct CaseraEntryView : View {
|
|||||||
// MARK: - Free Tier Widget View (Non-Interactive)
|
// MARK: - Free Tier Widget View (Non-Interactive)
|
||||||
struct FreeWidgetView: View {
|
struct FreeWidgetView: View {
|
||||||
let entry: SimpleEntry
|
let entry: SimpleEntry
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: OrganicSpacing.cozy) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Task count display
|
// Organic task count with glow
|
||||||
Text("\(entry.taskCount)")
|
ZStack {
|
||||||
.font(.system(size: 56, weight: .bold))
|
// Soft glow behind number
|
||||||
.foregroundStyle(.blue)
|
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")
|
Text("\(entry.taskCount)")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 52, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Subtle upgrade hint
|
// Subtle upgrade hint with organic styling
|
||||||
Text("Upgrade for interactive widgets")
|
Text("Upgrade for interactive widgets")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10, weight: .medium, design: .rounded))
|
||||||
.foregroundStyle(.tertiary)
|
.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
|
// MARK: - Small Widget View
|
||||||
struct SmallWidgetView: View {
|
struct SmallWidgetView: View {
|
||||||
let entry: SimpleEntry
|
let entry: SimpleEntry
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Task Count
|
// Task Count with organic glow
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("\(entry.taskCount)")
|
Text("\(entry.taskCount)")
|
||||||
.font(.system(size: 36, weight: .bold))
|
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if entry.taskCount > 0 {
|
if entry.taskCount > 0 {
|
||||||
Text(entry.taskCount == 1 ? "upcoming task" : "upcoming tasks")
|
Text(entry.taskCount == 1 ? "task" : "tasks")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next Task with Complete Button
|
|
||||||
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))
|
|
||||||
.lineLimit(1)
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if let dueDate = nextTask.dueDate {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "calendar")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
Text(formatWidgetDate(dueDate))
|
|
||||||
.font(.system(size: 10, weight: .medium))
|
|
||||||
}
|
|
||||||
.foregroundStyle(nextTask.isOverdue ? .red : .orange)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Complete button
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
// Next Task Card
|
||||||
|
if let nextTask = entry.nextTask {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(nextTask.title)
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(Color.appTextPrimary)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let dueDate = nextTask.dueDate {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 8, weight: .medium))
|
||||||
|
Text(formatWidgetDate(dueDate))
|
||||||
|
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||||
|
}
|
||||||
|
.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()
|
||||||
|
|
||||||
|
// Organic complete button
|
||||||
Button(intent: CompleteTaskIntent(taskId: nextTask.id, taskTitle: nextTask.title)) {
|
Button(intent: CompleteTaskIntent(taskId: nextTask.id, taskTitle: nextTask.title)) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
ZStack {
|
||||||
.font(.system(size: 20))
|
Circle()
|
||||||
.foregroundStyle(.green)
|
.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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(OrganicSpacing.compact)
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.fill(Color.blue.opacity(0.1))
|
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.12 : 0.08))
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HStack {
|
// Empty state
|
||||||
Image(systemName: "checkmark.circle.fill")
|
HStack(spacing: 8) {
|
||||||
.font(.system(size: 16))
|
ZStack {
|
||||||
.foregroundStyle(.green)
|
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!")
|
Text("All caught up!")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.fill(Color.green.opacity(0.1))
|
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.1 : 0.06))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(14)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Medium Widget View
|
// MARK: - Medium Widget View
|
||||||
struct MediumWidgetView: View {
|
struct MediumWidgetView: View {
|
||||||
let entry: SimpleEntry
|
let entry: SimpleEntry
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 0) {
|
||||||
// Left side - Task count
|
// Left side - Task count with organic styling
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .center, spacing: 4) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
Text("\(entry.taskCount)")
|
Text("\(entry.taskCount)")
|
||||||
.font(.system(size: 42, weight: .bold))
|
.font(.system(size: 38, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
VStack(alignment: .leading) {
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||||
Text("upcoming")
|
startPoint: .topLeading,
|
||||||
.font(.system(size: 11, weight: .medium))
|
endPoint: .bottomTrailing
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
.lineLimit(2)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(entry.taskCount == 1 ? "task" : "tasks")
|
Text(entry.taskCount == 1 ? "task" : "tasks")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.appTextSecondary)
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
if entry.nextTask != nil {
|
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
|
ForEach(Array(entry.upcomingTasks.prefix(3).enumerated()), id: \.element.id) { index, task in
|
||||||
InteractiveTaskRowView(task: task)
|
OrganicTaskRowView(task: task, compact: true)
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
Spacer()
|
|
||||||
} else {
|
} else {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
// Empty state
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
ZStack {
|
||||||
.font(.system(size: 24))
|
Circle()
|
||||||
.foregroundStyle(.green)
|
.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!")
|
Text("All caught up!")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.leading, 12)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(14)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interactive Task Row (for Medium widget)
|
// MARK: - Organic Task Row View
|
||||||
struct InteractiveTaskRowView: View {
|
struct OrganicTaskRowView: View {
|
||||||
let task: CacheManager.CustomTask
|
let task: CacheManager.CustomTask
|
||||||
|
var compact: Bool = false
|
||||||
|
var showResidence: Bool = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: compact ? 8 : 10) {
|
||||||
// Checkbox to complete task (color indicates priority)
|
// Organic checkbox button
|
||||||
Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) {
|
Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) {
|
||||||
Image(systemName: "circle")
|
ZStack {
|
||||||
.font(.system(size: 18))
|
Circle()
|
||||||
.foregroundStyle(priorityColor)
|
.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)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: compact ? 2 : 3) {
|
||||||
Text(task.title)
|
Text(task.title)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.system(size: compact ? 11 : 12, weight: .semibold, design: .rounded))
|
||||||
.lineLimit(1)
|
.lineLimit(compact ? 1 : 2)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(Color.appTextPrimary)
|
||||||
|
|
||||||
|
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 {
|
if let dueDate = task.dueDate {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 2) {
|
||||||
Image(systemName: "calendar")
|
Image(systemName: "calendar")
|
||||||
.font(.system(size: 8))
|
.font(.system(size: 7, weight: .medium))
|
||||||
Text(formatWidgetDate(dueDate))
|
Text(formatWidgetDate(dueDate))
|
||||||
.font(.system(size: 9, weight: .medium))
|
.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 {
|
private var priorityColor: Color {
|
||||||
// Overdue tasks are always red
|
|
||||||
if task.isOverdue {
|
if task.isOverdue {
|
||||||
return .red
|
return Color.appError
|
||||||
}
|
}
|
||||||
switch task.priority?.lowercased() {
|
switch task.priority?.lowercased() {
|
||||||
case "urgent": return .red
|
case "urgent": return Color.appError
|
||||||
case "high": return .orange
|
case "high": return Color.appAccent
|
||||||
case "medium": return .yellow
|
case "medium": return Color(red: 0.92, green: 0.70, blue: 0.03) // Yellow
|
||||||
default: return .green
|
default: return Color.appPrimary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,185 +604,137 @@ struct InteractiveTaskRowView: View {
|
|||||||
// MARK: - Large Widget View
|
// MARK: - Large Widget View
|
||||||
struct LargeWidgetView: View {
|
struct LargeWidgetView: View {
|
||||||
let entry: SimpleEntry
|
let entry: SimpleEntry
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var maxTasksToShow: Int { 5 }
|
private var maxTasksToShow: Int { 5 }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if entry.upcomingTasks.isEmpty {
|
if entry.upcomingTasks.isEmpty {
|
||||||
// Empty state - centered
|
// Empty state - centered with organic styling
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
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")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 44, weight: .medium))
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text("All caught up!")
|
Text("All caught up!")
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Stats even when empty
|
// Stats even when empty
|
||||||
LargeWidgetStatsView(entry: entry)
|
OrganicStatsView(entry: entry)
|
||||||
} else {
|
} else {
|
||||||
// Tasks section - always at top
|
// Tasks section with organic rows
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in
|
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 {
|
if entry.upcomingTasks.count > maxTasksToShow {
|
||||||
Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more")
|
Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more")
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.appTextSecondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.top, 2)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 10)
|
||||||
|
|
||||||
// Stats section at bottom
|
// Stats section at bottom
|
||||||
LargeWidgetStatsView(entry: entry)
|
OrganicStatsView(entry: entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Large Widget Stats View
|
// MARK: - Organic Stats View
|
||||||
struct LargeWidgetStatsView: View {
|
struct OrganicStatsView: View {
|
||||||
let entry: SimpleEntry
|
let entry: SimpleEntry
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
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
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
// Checkbox to complete task (color indicates priority)
|
// Overdue
|
||||||
Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) {
|
OrganicStatPillWidget(
|
||||||
Image(systemName: "circle")
|
value: entry.overdueCount,
|
||||||
.font(.system(size: 20))
|
label: "Overdue",
|
||||||
.foregroundStyle(priorityColor)
|
color: entry.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
||||||
}
|
)
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
// Next 7 Days
|
||||||
Text(task.title)
|
OrganicStatPillWidget(
|
||||||
.font(.system(size: 12, weight: .medium))
|
value: entry.dueNext7DaysCount,
|
||||||
.lineLimit(2)
|
label: "7 Days",
|
||||||
.foregroundStyle(.primary)
|
color: Color.appAccent
|
||||||
|
)
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
// Next 30 Days
|
||||||
if let residenceName = task.residenceName, !residenceName.isEmpty {
|
OrganicStatPillWidget(
|
||||||
HStack(spacing: 2) {
|
value: entry.dueNext30DaysCount,
|
||||||
Image("icon")
|
label: "30 Days",
|
||||||
.resizable()
|
color: Color.appPrimary
|
||||||
.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()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.primary.opacity(0.05))
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var priorityColor: Color {
|
// MARK: - Organic Stat Pill for Widget
|
||||||
// Overdue tasks are always red
|
struct OrganicStatPillWidget: View {
|
||||||
if task.isOverdue {
|
let value: Int
|
||||||
return .red
|
let label: String
|
||||||
}
|
let color: Color
|
||||||
switch task.priority?.lowercased() {
|
@Environment(\.colorScheme) var colorScheme
|
||||||
case "urgent": return .red
|
|
||||||
case "high": return .orange
|
var body: some View {
|
||||||
case "medium": return .yellow
|
VStack(spacing: 3) {
|
||||||
default: return .green
|
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 {
|
var body: some WidgetConfiguration {
|
||||||
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
||||||
CaseraEntryView(entry: entry)
|
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")
|
.configurationDisplayName("Casera Tasks")
|
||||||
.description("View and complete your upcoming tasks.")
|
.description("View and complete your upcoming tasks.")
|
||||||
|
|||||||
@@ -135,6 +135,9 @@
|
|||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Assets.xcassets,
|
Assets.xcassets,
|
||||||
|
Design/DesignSystem.swift,
|
||||||
|
Design/OrganicDesign.swift,
|
||||||
|
Helpers/ThemeManager.swift,
|
||||||
Shared/TaskStatsCalculator.swift,
|
Shared/TaskStatsCalculator.swift,
|
||||||
);
|
);
|
||||||
target = 1C07893C2EBC218B00392B46 /* CaseraExtension */;
|
target = 1C07893C2EBC218B00392B46 /* CaseraExtension */;
|
||||||
@@ -713,6 +716,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.casera.CaseraDev.CaseraDev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.casera.CaseraDev.CaseraDev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -747,6 +751,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.casera.CaseraDev.CaseraDev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.casera.CaseraDev.CaseraDev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import SwiftUI
|
|||||||
extension Color {
|
extension Color {
|
||||||
// MARK: - Dynamic Theme Resolution
|
// MARK: - Dynamic Theme Resolution
|
||||||
private static func themed(_ name: String) -> Color {
|
private static func themed(_ name: String) -> Color {
|
||||||
|
// Both main app and widgets use the theme from ThemeManager
|
||||||
|
// Theme is shared via App Group UserDefaults
|
||||||
let theme = ThemeManager.shared.currentTheme.rawValue
|
let theme = ThemeManager.shared.currentTheme.rawValue
|
||||||
return Color("\(theme)/\(name)", bundle: nil)
|
return Color("\(theme)/\(name)", bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Semantic Colors (Use These in UI)
|
// MARK: - Semantic Colors (Use These in UI)
|
||||||
// These dynamically resolve based on ThemeManager.shared.currentTheme
|
// These dynamically resolve based on ThemeManager.shared.currentTheme
|
||||||
|
// Theme is shared between main app and widgets via App Group
|
||||||
static var appPrimary: Color { themed("Primary") }
|
static var appPrimary: Color { themed("Primary") }
|
||||||
static var appSecondary: Color { themed("Secondary") }
|
static var appSecondary: Color { themed("Secondary") }
|
||||||
static var appAccent: Color { themed("Accent") }
|
static var appAccent: Color { themed("Accent") }
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if !WIDGET_EXTENSION
|
||||||
|
import Combine
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Theme ID Enum
|
// MARK: - Theme ID Enum
|
||||||
enum ThemeID: String, CaseIterable, Codable {
|
enum ThemeID: String, CaseIterable, Codable {
|
||||||
@@ -56,7 +59,31 @@ enum ThemeID: String, CaseIterable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared App Group UserDefaults
|
||||||
|
private let appGroupID = "group.com.tt.casera.CaseraDev"
|
||||||
|
private let sharedDefaults = UserDefaults(suiteName: appGroupID) ?? UserDefaults.standard
|
||||||
|
|
||||||
// MARK: - Theme Manager
|
// MARK: - Theme Manager
|
||||||
|
#if WIDGET_EXTENSION
|
||||||
|
// Simplified ThemeManager for widget extensions (no ObservableObject needed)
|
||||||
|
class ThemeManager {
|
||||||
|
static let shared = ThemeManager()
|
||||||
|
|
||||||
|
var currentTheme: ThemeID {
|
||||||
|
// Load saved theme from shared App Group defaults
|
||||||
|
if let savedThemeRawValue = sharedDefaults.string(forKey: themeKey),
|
||||||
|
let savedTheme = ThemeID(rawValue: savedThemeRawValue) {
|
||||||
|
return savedTheme
|
||||||
|
}
|
||||||
|
return .bright
|
||||||
|
}
|
||||||
|
|
||||||
|
private let themeKey = "selectedTheme"
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// Full ThemeManager for main app with ObservableObject support
|
||||||
class ThemeManager: ObservableObject {
|
class ThemeManager: ObservableObject {
|
||||||
static let shared = ThemeManager()
|
static let shared = ThemeManager()
|
||||||
|
|
||||||
@@ -69,8 +96,8 @@ class ThemeManager: ObservableObject {
|
|||||||
private let themeKey = "selectedTheme"
|
private let themeKey = "selectedTheme"
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Load saved theme or default to .bright
|
// Load saved theme from shared App Group defaults
|
||||||
if let savedThemeRawValue = UserDefaults.standard.string(forKey: themeKey),
|
if let savedThemeRawValue = sharedDefaults.string(forKey: themeKey),
|
||||||
let savedTheme = ThemeID(rawValue: savedThemeRawValue) {
|
let savedTheme = ThemeID(rawValue: savedThemeRawValue) {
|
||||||
self.currentTheme = savedTheme
|
self.currentTheme = savedTheme
|
||||||
} else {
|
} else {
|
||||||
@@ -79,7 +106,8 @@ class ThemeManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveTheme() {
|
private func saveTheme() {
|
||||||
UserDefaults.standard.set(currentTheme.rawValue, forKey: themeKey)
|
// Save to shared App Group defaults so widgets can access it
|
||||||
|
sharedDefaults.set(currentTheme.rawValue, forKey: themeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setTheme(_ theme: ThemeID) {
|
func setTheme(_ theme: ThemeID) {
|
||||||
@@ -88,3 +116,4 @@ class ThemeManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
if myResidences != nil {
|
if myResidences != nil {
|
||||||
self?.isLoading = false
|
self?.isLoading = false
|
||||||
}
|
}
|
||||||
|
// Auto-update selectedResidence if it exists in the updated list
|
||||||
|
if let currentSelected = self?.selectedResidence,
|
||||||
|
let updatedResidence = myResidences?.residences.first(where: { $0.id == currentSelected.id }) {
|
||||||
|
self?.selectedResidence = updatedResidence
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
@@ -40,6 +45,11 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] residences in
|
.sink { [weak self] residences in
|
||||||
self?.residences = residences
|
self?.residences = residences
|
||||||
|
// Auto-update selectedResidence if it exists in the updated list
|
||||||
|
if let currentSelected = self?.selectedResidence,
|
||||||
|
let updatedResidence = residences.first(where: { $0.id == currentSelected.id }) {
|
||||||
|
self?.selectedResidence = updatedResidence
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user