From be0d298d9fa710cf4e2aeeed13e4065c7114b5f8 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 23 Jan 2026 14:17:56 -0600 Subject: [PATCH] Add dark mode support and design system foundation - Create DesignSystem with ColorTokens, AppearanceManager, spacing, and typography - Add appearance picker in Settings (System/Light/Dark modes) - Replace hardcoded colors with design tokens in MainTabView and Enums+UI - Inject AppearanceManager via environment in PlantGuideApp - Add FEATURE_ROADMAP.md documenting 8 planned features Co-Authored-By: Claude Opus 4.5 --- Docs/FEATURE_ROADMAP.md | 545 ++++++++++++++++++ .../Core/DesignSystem/AppearanceManager.swift | 71 +++ .../Core/DesignSystem/ColorTokens.swift | 121 ++++ .../Core/DesignSystem/DesignSystem.swift | 104 ++++ PlantGuide/PlantGuideApp.swift | 6 + .../Presentation/Extensions/Enums+UI.swift | 10 +- .../Presentation/Navigation/MainTabView.swift | 2 +- .../Scenes/Settings/SettingsView.swift | 29 + 8 files changed, 882 insertions(+), 6 deletions(-) create mode 100644 Docs/FEATURE_ROADMAP.md create mode 100644 PlantGuide/Core/DesignSystem/AppearanceManager.swift create mode 100644 PlantGuide/Core/DesignSystem/ColorTokens.swift create mode 100644 PlantGuide/Core/DesignSystem/DesignSystem.swift diff --git a/Docs/FEATURE_ROADMAP.md b/Docs/FEATURE_ROADMAP.md new file mode 100644 index 0000000..5b73dc1 --- /dev/null +++ b/Docs/FEATURE_ROADMAP.md @@ -0,0 +1,545 @@ +# PlantGuide Feature Roadmap + +Implementation plan for 8 new features: Dark Mode, CloudKit Sync, Plant Rooms, Animations, Flexible Snoozing, Today View, Batch Actions, and Progress Photos. + +--- + +## Requirements Summary + +| Feature | Specification | +|---------|---------------| +| **Dark Mode** | System-following + manual toggle, new color token system | +| **CloudKit** | `NSPersistentCloudKitContainer`, fresh installs only | +| **Rooms** | User-creatable `Room` entity, defaults: Kitchen, Living Room, Bedroom, Bathroom, Office, Patio/Balcony, Other | +| **Snoozing** | 3 days, 1 week, 2 weeks, 1 month options | +| **Today View** | Replaces Care tab, shows overdue + today's tasks | +| **Batch Actions** | "Water all" for room or selection, logs per-plant | +| **Progress Photos** | Scheduled reminders, time-lapse with user-controllable speed slider | +| **Animations** | Subtle, defining—specified per context below | + +**Tab structure after implementation:** +Camera | Browse | Collection | Today | Settings + +--- + +## Implementation Order + +| Phase | Feature | Effort | Notes | +|-------|---------|--------|-------| +| **1** | Dark Mode + Color Tokens | S | Foundation for all UI | +| **2** | CloudKit Sync | M | Must happen before new entities to avoid migration pain | +| **3** | Plant Rooms/Zones | M | New `Room` entity (synced), user-creatable with defaults | +| **4** | Subtle Animations | M | Design system completion | +| **5** | Flexible Snoozing | S | Extend existing CareTask system | +| **6** | Today View | M | Replace Care tab | +| **7** | Batch Actions | S | Builds on rooms | +| **8** | Progress Photos | L | New entity, reminders, time-lapse playback | + +--- + +## Dependency Graph + +``` +Dark Mode (color tokens) ─────────────────┐ + ├──► All UI features depend on this +Subtle Animations ────────────────────────┘ + +Plant Rooms/Zones ──► Batch Actions ("Water all in Kitchen") + ──► Today View (group by room) + +Progress Photos ──► Independent (but benefits from animations) + +Flexible Snoozing ──► Independent (extends existing system) + +Today View ──► Requires rooms to be meaningful + ──► Benefits from animations +``` + +--- + +## Phase 1: Dark Mode + Color Tokens + +### Technical Approach +Create a centralized design system with semantic color tokens that respond to color scheme. + +### Files to Create +| File | Purpose | +|------|---------| +| `Core/DesignSystem/ColorTokens.swift` | Semantic colors (background, surface, textPrimary, textSecondary, accent, destructive, etc.) | +| `Core/DesignSystem/AppearanceManager.swift` | `@Observable` class managing appearance preference (system/light/dark) | +| `Core/DesignSystem/DesignSystem.swift` | Namespace for tokens + spacing + typography | + +### Files to Modify +| File | Change | +|------|--------| +| `PlantGuideApp.swift` | Inject `AppearanceManager`, apply `.preferredColorScheme()` | +| `MainTabView.swift` | Replace `.tint(.green)` with `DesignSystem.Colors.accent` | +| `SettingsView.swift` | Add appearance picker (System/Light/Dark) | +| All Views | Replace hardcoded colors with semantic tokens | + +### Color Token Structure +```swift +enum Colors { + static let background = Color("Background") // Primary background + static let surface = Color("Surface") // Cards, sheets + static let textPrimary = Color("TextPrimary") + static let textSecondary = Color("TextSecondary") + static let accent = Color("Accent") // Green + static let destructive = Color("Destructive") + static let taskWatering = Color("TaskWatering") + static let taskFertilizing = Color("TaskFertilizing") + // ... etc +} +``` + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Inconsistent adoption | Grep for hardcoded `Color(` and `.foregroundColor` after implementation | +| Asset catalog bloat | Use programmatic colors with `UIColor { traitCollection in }` | + +### Tests +- Unit: `AppearanceManager` persists preference correctly +- UI: Screenshot tests for light/dark variants of key screens + +--- + +## Phase 2: CloudKit Sync + +### Technical Approach +Replace `NSPersistentContainer` with `NSPersistentCloudKitContainer`. Enable automatic sync. + +### Files to Modify +| File | Change | +|------|--------| +| `CoreDataStack.swift` | Switch to `NSPersistentCloudKitContainer`, configure CloudKit container | +| `PlantGuideModel.xcdatamodeld` | Enable "Used with CloudKit" on configuration | +| `PlantGuide.entitlements` | Add CloudKit entitlement + iCloud container | +| `Info.plist` | Add `NSUbiquitousContainerIdentifier` if needed | + +### Xcode Configuration +1. Enable CloudKit capability in Signing & Capabilities +2. Create CloudKit container: `iCloud.com.yourteam.PlantGuide` +3. Enable "CloudKit" checkbox on Core Data model configuration + +### Schema Considerations +- All entities must have no unique constraints (CloudKit limitation) +- All relationships must have inverses +- No `Undefined` attribute types +- Optional attributes for any field that might be nil during sync + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Sync conflicts | CloudKit uses last-writer-wins; acceptable for this app | +| Large photos slow sync | Store photos in `CKAsset` (handled automatically by Core Data) | +| Offline-first gaps | `NSPersistentCloudKitContainer` handles this automatically | +| Rate limiting | Monitor CloudKit dashboard; unlikely for personal use | + +### Tests +- Integration: Create plant on device A, verify appears on device B +- Unit: Mock `NSPersistentCloudKitContainer` for offline behavior + +--- + +## Phase 3: Plant Rooms/Zones + +### Technical Approach +New `Room` entity with one-to-many relationship to `Plant`. Replace `PlantLocation` enum usage. + +### Files to Create +| File | Purpose | +|------|---------| +| `Domain/Entities/Room.swift` | `Room` struct (id, name, icon, sortOrder, isDefault) | +| `Domain/UseCases/Room/CreateDefaultRoomsUseCase.swift` | Creates 7 default rooms on first launch | +| `Domain/UseCases/Room/ManageRoomsUseCase.swift` | CRUD for rooms | +| `Domain/RepositoryInterfaces/RoomRepositoryProtocol.swift` | Repository protocol | +| `Data/Repositories/CoreDataRoomRepository.swift` | Core Data implementation | +| `Presentation/Scenes/Rooms/RoomsListView.swift` | Settings screen to manage rooms | +| `Presentation/Scenes/Rooms/RoomEditorView.swift` | Create/edit room | +| `Presentation/Scenes/Rooms/RoomsViewModel.swift` | ViewModel | + +### Files to Modify +| File | Change | +|------|--------| +| `PlantGuideModel.xcdatamodeld` | Add `RoomMO` entity, relationship to `PlantMO` | +| `Plant.swift` | Change `location: PlantLocation?` to `roomID: UUID?` | +| `PlantMO` | Add `room` relationship | +| `CollectionView.swift` | Add room filter/grouping option | +| `PlantDetailView.swift` | Room picker instead of location enum | +| `SettingsView.swift` | Add "Manage Rooms" row | +| `DIContainer.swift` | Register room repository and use cases | + +### Default Rooms +```swift +let defaults = [ + Room(name: "Kitchen", icon: "refrigerator", sortOrder: 0, isDefault: true), + Room(name: "Living Room", icon: "sofa", sortOrder: 1, isDefault: true), + Room(name: "Bedroom", icon: "bed.double", sortOrder: 2, isDefault: true), + Room(name: "Bathroom", icon: "shower", sortOrder: 3, isDefault: true), + Room(name: "Office", icon: "desktopcomputer", sortOrder: 4, isDefault: true), + Room(name: "Patio/Balcony", icon: "sun.max", sortOrder: 5, isDefault: true), + Room(name: "Other", icon: "square.grid.2x2", sortOrder: 6, isDefault: true), +] +``` + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Orphaned plants when room deleted | Cascade to "Other" room or prompt user | +| Migration from `PlantLocation` enum | Map existing enum values to default rooms in migration | + +### Tests +- Unit: `CreateDefaultRoomsUseCase` creates exactly 7 rooms +- Unit: Room deletion cascades plants correctly +- UI: Room picker appears in plant detail + +--- + +## Phase 4: Subtle Animations + +### Technical Approach +Create reusable animation modifiers and view extensions. Apply consistently. + +### Files to Create +| File | Purpose | +|------|---------| +| `Core/DesignSystem/Animations.swift` | Standard timing curves, durations | +| `Presentation/Common/Modifiers/FadeInModifier.swift` | Staggered fade-in for lists | +| `Presentation/Common/Modifiers/TaskCompleteModifier.swift` | Checkmark + confetti burst | +| `Presentation/Common/Modifiers/WateringFeedbackModifier.swift` | Water droplet animation | +| `Presentation/Common/Modifiers/PlantGrowthModifier.swift` | Subtle scale pulse | +| `Presentation/Common/Components/SuccessCheckmark.swift` | Animated checkmark component | + +### Animation Specifications + +| Context | Animation | +|---------|-----------| +| **Task complete** | Checkmark draws in (0.3s), row scales down + fades (0.2s), optional confetti particles | +| **Watering feedback** | 3 water droplets fall and fade (0.5s staggered) | +| **List appearance** | Staggered fade-in, 0.05s delay per item, max 10 items animated | +| **Tab switch** | Cross-fade (0.2s) | +| **Card press** | Scale to 0.97 on press, spring back | +| **Pull to refresh** | Plant icon rotates while loading | +| **Empty state** | Gentle float animation on illustration | + +### Timing Standards +```swift +enum Animations { + static let quick = Animation.easeOut(duration: 0.15) + static let standard = Animation.easeInOut(duration: 0.25) + static let emphasis = Animation.spring(response: 0.4, dampingFraction: 0.7) + static let stagger = 0.05 // seconds between items +} +``` + +### Files to Modify +| File | Change | +|------|--------| +| `CareTaskRow.swift` | Add completion animation | +| `CollectionView.swift` | Staggered list animation | +| `MainTabView.swift` | Tab transition | +| All cards/buttons | Press feedback | + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Animation jank on older devices | Use `Animation.interactiveSpring` which degrades gracefully | +| Overdone animations | Keep durations under 0.5s, use subtle scales (0.95-1.05 range) | +| Accessibility | Respect `UIAccessibility.isReduceMotionEnabled` | + +### Tests +- Unit: Animations respect reduce motion setting +- Manual: Test on oldest supported device (iPhone XR or similar) + +--- + +## Phase 5: Flexible Snoozing + +### Technical Approach +Extend existing snooze system with new intervals. Add snooze picker UI. + +### Files to Create +| File | Purpose | +|------|---------| +| `Presentation/Common/Components/SnoozePickerView.swift` | Bottom sheet with snooze options | + +### Files to Modify +| File | Change | +|------|--------| +| `CareTask.swift` | Add `snoozedUntil: Date?` property, `snoozeCount: Int` | +| `CareTaskRow.swift` | Replace swipe actions with snooze picker trigger | +| `CareScheduleViewModel.swift` | Update `snoozeTask()` to accept `SnoozeInterval` enum | +| `NotificationService.swift` | Reschedule notification on snooze | +| `CareTaskMO` | Add `snoozedUntil` and `snoozeCount` attributes | + +### Snooze Options +```swift +enum SnoozeInterval: CaseIterable { + case threeDays + case oneWeek + case twoWeeks + case oneMonth + + var days: Int { + switch self { + case .threeDays: return 3 + case .oneWeek: return 7 + case .twoWeeks: return 14 + case .oneMonth: return 30 + } + } + + var label: String { + switch self { + case .threeDays: return "3 days" + case .oneWeek: return "1 week" + case .twoWeeks: return "2 weeks" + case .oneMonth: return "1 month" + } + } +} +``` + +### UI Behavior +- Swipe left on task → shows snooze picker (bottom sheet) +- Picker shows 4 options + "Custom date" option +- Snoozed tasks show "Snoozed until [date]" badge +- Snoozed tasks appear in "Snoozed" filter section + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Users abuse snooze | Track `snoozeCount`, optionally show "frequently snoozed" indicator | +| Notification sync | Cancel old notification, schedule new one in same transaction | + +### Tests +- Unit: `snoozeTask(interval:)` calculates correct future date +- Unit: Snooze count increments +- UI: Snoozed task appears in correct section + +--- + +## Phase 6: Today View + +### Technical Approach +New view replacing Care tab. Dashboard with overdue + today sections, grouped by room. + +### Files to Create +| File | Purpose | +|------|---------| +| `Presentation/Scenes/TodayView/TodayView.swift` | Main dashboard | +| `Presentation/Scenes/TodayView/TodayViewModel.swift` | ViewModel | +| `Presentation/Scenes/TodayView/Components/TaskSection.swift` | Overdue/Today section | +| `Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift` | Tasks grouped by room | +| `Presentation/Scenes/TodayView/Components/QuickStatsBar.swift` | Today's progress (3/7 tasks done) | + +### Files to Modify +| File | Change | +|------|--------| +| `MainTabView.swift` | Replace Care tab with Today tab | +| `DIContainer.swift` | Add `makeTodayViewModel()` | + +### Today View Layout +``` +┌─────────────────────────────────┐ +│ Good morning! │ +│ 3 of 7 tasks completed │ +├─────────────────────────────────┤ +│ OVERDUE (2) │ +│ ┌─────────────────────────────┐ │ +│ │ Monstera - Water (2d ago) │ │ +│ │ Fern - Fertilize (1d ago) │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────┤ +│ TODAY (5) │ +│ │ +│ Kitchen (2) │ +│ ┌─────────────────────────────┐ │ +│ │ Pothos - Water │ │ +│ │ Basil - Water │ │ +│ └─────────────────────────────┘ │ +│ │ +│ Living Room (3) │ +│ ┌─────────────────────────────┐ │ +│ │ Snake Plant - Water │ │ +│ │ Spider Plant - Fertilize │ │ +│ │ Cactus - Water │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────┘ +``` + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Empty state confusion | Show encouraging empty state: "All caught up!" | +| Performance with many tasks | Limit visible tasks, add "Show all" expansion | + +### Tests +- Unit: Tasks correctly grouped by overdue/today/room +- UI: Empty state displays correctly +- UI: Task completion updates stats bar + +--- + +## Phase 7: Batch Actions + +### Technical Approach +Multi-select mode + batch action bar. Actions apply to selection or entire room. + +### Files to Create +| File | Purpose | +|------|---------| +| `Presentation/Common/Components/BatchActionBar.swift` | Floating action bar | +| `Domain/UseCases/PlantCare/BatchCompleteTasksUseCase.swift` | Complete multiple tasks | + +### Files to Modify +| File | Change | +|------|--------| +| `TodayView.swift` | Add edit mode, selection state | +| `TodayViewModel.swift` | `selectedTaskIDs: Set`, batch action methods | +| `RoomTaskGroup.swift` | "Water all in [Room]" button | +| `CareScheduleRepositoryProtocol.swift` | Add `batchUpdate(taskIDs:completion:)` | + +### UI Behavior +1. **Room-level batch**: Each room group has "Water all" button (only for watering tasks) +2. **Selection mode**: Long-press or Edit button enables multi-select +3. **Batch bar**: Appears at bottom when items selected: "Complete (5)" button +4. **Logging**: Each plant gets individual `CareTask` completion entry + +### Batch Action Bar +``` +┌─────────────────────────────────┐ +│ 5 selected [Complete] [Cancel]│ +└─────────────────────────────────┘ +``` + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Accidental batch complete | Require confirmation for >3 tasks | +| Mixed task types in selection | Only show "Complete" (generic), not task-specific action | + +### Tests +- Unit: `BatchCompleteTasksUseCase` creates individual completion records +- UI: Selection count updates correctly +- UI: Confirmation appears for large batches + +--- + +## Phase 8: Progress Photos + +### Technical Approach +New `ProgressPhoto` entity linked to `Plant`. Scheduled reminders for capture. Time-lapse viewer with speed control. + +### Files to Create +| File | Purpose | +|------|---------| +| `Domain/Entities/ProgressPhoto.swift` | Photo entity (id, plantID, imageData, dateTaken, notes) | +| `Domain/RepositoryInterfaces/ProgressPhotoRepositoryProtocol.swift` | Repository protocol | +| `Data/Repositories/CoreDataProgressPhotoRepository.swift` | Core Data implementation | +| `Domain/UseCases/Photos/CaptureProgressPhotoUseCase.swift` | Save photo with metadata | +| `Domain/UseCases/Photos/SchedulePhotoReminderUseCase.swift` | Weekly/monthly reminder | +| `Presentation/Scenes/ProgressPhotos/ProgressPhotoGalleryView.swift` | Grid of photos for plant | +| `Presentation/Scenes/ProgressPhotos/ProgressPhotoCaptureView.swift` | Camera capture screen | +| `Presentation/Scenes/ProgressPhotos/TimeLapsePlayerView.swift` | Time-lapse viewer | +| `Presentation/Scenes/ProgressPhotos/ProgressPhotosViewModel.swift` | ViewModel | + +### Files to Modify +| File | Change | +|------|--------| +| `PlantGuideModel.xcdatamodeld` | Add `ProgressPhotoMO` entity | +| `PlantDetailView.swift` | Add "Progress Photos" section | +| `NotificationService.swift` | Add photo reminder notification type | +| `DIContainer.swift` | Register photo repositories and use cases | + +### Core Data Entity +``` +ProgressPhotoMO +├── id: UUID +├── plantID: UUID +├── imageData: Binary Data (External Storage) +├── thumbnailData: Binary Data +├── dateTaken: Date +├── notes: String? +└── plant: PlantMO (relationship) +``` + +### Time-Lapse Player +``` +┌─────────────────────────────────┐ +│ [Photo Display] │ +│ │ +│ ◀ ▶ Jan 15, 2026 │ +│ │ +│ Speed: [━━━━●━━━━━] 0.3s/frame │ +│ │ +│ [ Play ] │ +└─────────────────────────────────┘ +``` + +### Photo Reminder Options +- Weekly (same day each week) +- Bi-weekly +- Monthly (same date each month) +- Off + +### Storage Strategy +- Full resolution stored as `Binary Data` with "Allows External Storage" enabled +- Thumbnail (200x200) stored inline for fast gallery loading +- CloudKit syncs via `CKAsset` automatically + +### Risks & Mitigations +| Risk | Mitigation | +|------|------------| +| Storage bloat | Compress to HEIC, limit resolution to 2048px max dimension | +| CloudKit photo sync slow | Show sync indicator, allow offline viewing of local photos | +| Gallery performance | Load thumbnails only, full image on tap | + +### Tests +- Unit: Photo saved with correct metadata +- Unit: Thumbnail generated at correct size +- UI: Time-lapse plays at correct speed +- Integration: Photo syncs to CloudKit + +--- + +## Red Team Check + +### Potential Issues Across All Phases + +| Issue | Phase | Severity | Mitigation | +|-------|-------|----------|------------| +| CloudKit container misconfigured | 2 | Critical | Test on real device early, not just simulator | +| Color tokens not adopted everywhere | 1 | Medium | Add SwiftLint rule or grep check | +| Room deletion orphans data | 3 | High | Cascade to "Other" room, never delete "Other" | +| Animation performance on old devices | 4 | Medium | Test on iPhone XR/XS, use `interactiveSpring` | +| Snooze notifications don't update | 5 | High | Wrap in transaction: cancel + reschedule | +| Today view empty state confusing | 6 | Low | Design clear empty states early | +| Batch action logging wrong | 7 | Medium | Unit test verifies individual records | +| Photo storage exceeds iCloud quota | 8 | Medium | Warn user when approaching limit | + +### Rollback Strategy + +Each phase is independently deployable. If issues arise: + +1. **Feature flags**: Wrap new features in `@AppStorage("feature_X_enabled")` flags +2. **Database migrations**: All Core Data changes are additive (new entities/attributes), never destructive +3. **CloudKit schema**: Once deployed to production, schema can only be extended, not modified + +--- + +## Totals + +| Phase | Feature | New Files | Modified Files | Effort | +|-------|---------|-----------|----------------|--------| +| 1 | Dark Mode | 3 | ~15 | S | +| 2 | CloudKit | 0 | 4 | M | +| 3 | Rooms | 8 | 7 | M | +| 4 | Animations | 6 | 5 | M | +| 5 | Snoozing | 1 | 5 | S | +| 6 | Today View | 5 | 2 | M | +| 7 | Batch Actions | 2 | 4 | S | +| 8 | Progress Photos | 9 | 4 | L | + +**Total: ~34 new files, ~30 modified files** diff --git a/PlantGuide/Core/DesignSystem/AppearanceManager.swift b/PlantGuide/Core/DesignSystem/AppearanceManager.swift new file mode 100644 index 0000000..4357ace --- /dev/null +++ b/PlantGuide/Core/DesignSystem/AppearanceManager.swift @@ -0,0 +1,71 @@ +import SwiftUI + +/// User's preferred appearance mode +enum AppearanceMode: String, CaseIterable, Identifiable { + case system = "System" + case light = "Light" + case dark = "Dark" + + var id: String { rawValue } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + var icon: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max.fill" + case .dark: return "moon.fill" + } + } +} + +/// Manages the app's appearance preferences +@Observable +@MainActor +final class AppearanceManager { + private static let appearanceModeKey = "appearanceMode" + + /// Current appearance mode + var mode: AppearanceMode { + didSet { + UserDefaults.standard.set(mode.rawValue, forKey: Self.appearanceModeKey) + } + } + + /// The color scheme to apply, or nil for system default + var colorScheme: ColorScheme? { + mode.colorScheme + } + + init() { + if let savedMode = UserDefaults.standard.string(forKey: Self.appearanceModeKey), + let mode = AppearanceMode(rawValue: savedMode) { + self.mode = mode + } else { + self.mode = .system + } + } + + /// Cycles through appearance modes: System → Light → Dark → System + func cycleMode() { + switch mode { + case .system: mode = .light + case .light: mode = .dark + case .dark: mode = .system + } + } +} + +// MARK: - View Modifier for applying appearance +extension View { + /// Applies the appearance manager's color scheme preference + func applyAppearance(_ manager: AppearanceManager) -> some View { + self.preferredColorScheme(manager.colorScheme) + } +} diff --git a/PlantGuide/Core/DesignSystem/ColorTokens.swift b/PlantGuide/Core/DesignSystem/ColorTokens.swift new file mode 100644 index 0000000..fdc84d3 --- /dev/null +++ b/PlantGuide/Core/DesignSystem/ColorTokens.swift @@ -0,0 +1,121 @@ +import SwiftUI +import UIKit + +/// Semantic color tokens for the PlantGuide design system. +/// All colors automatically adapt to light/dark mode. +enum ColorTokens { + // MARK: - Backgrounds + static let background = Color("Background") + static let backgroundSecondary = Color("BackgroundSecondary") + static let surface = Color("Surface") + static let surfaceElevated = Color("SurfaceElevated") + + // MARK: - Text + static let textPrimary = Color("TextPrimary") + static let textSecondary = Color("TextSecondary") + static let textTertiary = Color("TextTertiary") + static let textInverse = Color("TextInverse") + + // MARK: - Brand + static let accent = Color("Accent") + static let accentSecondary = Color("AccentSecondary") + + // MARK: - Semantic + static let destructive = Color("Destructive") + static let success = Color("Success") + static let warning = Color("Warning") + + // MARK: - Task Colors + static let taskWatering = Color("TaskWatering") + static let taskFertilizing = Color("TaskFertilizing") + static let taskRepotting = Color("TaskRepotting") + static let taskPruning = Color("TaskPruning") + static let taskPestControl = Color("TaskPestControl") + + // MARK: - Borders & Separators + static let border = Color("Border") + static let separator = Color("Separator") +} + +// MARK: - Programmatic Color Definitions +// These define the actual color values for light and dark modes +extension ColorTokens { + /// Creates an adaptive color that responds to light/dark mode + static func adaptive(light: UIColor, dark: UIColor) -> Color { + Color(UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark ? dark : light + }) + } +} + +// MARK: - Color Palette (Raw Values) +extension ColorTokens { + enum Palette { + // Greens (Brand) + static let green50 = Color(hex: "E8F5E9") + static let green100 = Color(hex: "C8E6C9") + static let green500 = Color(hex: "4CAF50") + static let green600 = Color(hex: "43A047") + static let green700 = Color(hex: "388E3C") + static let green900 = Color(hex: "1B5E20") + + // Blues (Watering) + static let blue500 = Color(hex: "2196F3") + static let blue600 = Color(hex: "1E88E5") + + // Browns (Repotting) + static let brown500 = Color(hex: "795548") + static let brown600 = Color(hex: "6D4C41") + + // Purples (Pruning) + static let purple500 = Color(hex: "9C27B0") + static let purple600 = Color(hex: "8E24AA") + + // Reds (Pest Control / Destructive) + static let red500 = Color(hex: "F44336") + static let red600 = Color(hex: "E53935") + + // Oranges (Warning) + static let orange500 = Color(hex: "FF9800") + static let orange600 = Color(hex: "FB8C00") + + // Neutrals + static let gray50 = Color(hex: "FAFAFA") + static let gray100 = Color(hex: "F5F5F5") + static let gray200 = Color(hex: "EEEEEE") + static let gray300 = Color(hex: "E0E0E0") + static let gray400 = Color(hex: "BDBDBD") + static let gray500 = Color(hex: "9E9E9E") + static let gray600 = Color(hex: "757575") + static let gray700 = Color(hex: "616161") + static let gray800 = Color(hex: "424242") + static let gray900 = Color(hex: "212121") + } +} + +// MARK: - Color Hex Extension +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/PlantGuide/Core/DesignSystem/DesignSystem.swift b/PlantGuide/Core/DesignSystem/DesignSystem.swift new file mode 100644 index 0000000..ce29eab --- /dev/null +++ b/PlantGuide/Core/DesignSystem/DesignSystem.swift @@ -0,0 +1,104 @@ +import SwiftUI + +/// PlantGuide Design System +/// Central namespace for all design tokens and styling constants. +enum DesignSystem { + // MARK: - Colors + /// Semantic color tokens - use these instead of hardcoded colors + typealias Colors = ColorTokens + + // MARK: - Spacing + enum Spacing { + /// 4pt + static let xxs: CGFloat = 4 + /// 8pt + static let xs: CGFloat = 8 + /// 12pt + static let sm: CGFloat = 12 + /// 16pt + static let md: CGFloat = 16 + /// 20pt + static let lg: CGFloat = 20 + /// 24pt + static let xl: CGFloat = 24 + /// 32pt + static let xxl: CGFloat = 32 + /// 48pt + static let xxxl: CGFloat = 48 + } + + // MARK: - Corner Radius + enum CornerRadius { + /// 4pt - subtle rounding + static let xs: CGFloat = 4 + /// 8pt - small components + static let sm: CGFloat = 8 + /// 12pt - cards, buttons + static let md: CGFloat = 12 + /// 16pt - larger cards + static let lg: CGFloat = 16 + /// 20pt - modals, sheets + static let xl: CGFloat = 20 + } + + // MARK: - Typography + enum Typography { + /// Large title - 34pt bold + static let largeTitle = Font.largeTitle.bold() + /// Title 1 - 28pt bold + static let title1 = Font.title.bold() + /// Title 2 - 22pt semibold + static let title2 = Font.title2.weight(.semibold) + /// Title 3 - 20pt semibold + static let title3 = Font.title3.weight(.semibold) + /// Headline - 17pt semibold + static let headline = Font.headline + /// Body - 17pt regular + static let body = Font.body + /// Callout - 16pt regular + static let callout = Font.callout + /// Subheadline - 15pt regular + static let subheadline = Font.subheadline + /// Footnote - 13pt regular + static let footnote = Font.footnote + /// Caption 1 - 12pt regular + static let caption1 = Font.caption + /// Caption 2 - 11pt regular + static let caption2 = Font.caption2 + } + + // MARK: - Shadows + enum Shadow { + static let sm = ShadowStyle(color: .black.opacity(0.08), radius: 4, x: 0, y: 2) + static let md = ShadowStyle(color: .black.opacity(0.12), radius: 8, x: 0, y: 4) + static let lg = ShadowStyle(color: .black.opacity(0.16), radius: 16, x: 0, y: 8) + } + + // MARK: - Icon Sizes + enum IconSize { + /// 16pt + static let sm: CGFloat = 16 + /// 20pt + static let md: CGFloat = 20 + /// 24pt + static let lg: CGFloat = 24 + /// 32pt + static let xl: CGFloat = 32 + /// 48pt + static let xxl: CGFloat = 48 + } +} + +// MARK: - Shadow Style +struct ShadowStyle { + let color: Color + let radius: CGFloat + let x: CGFloat + let y: CGFloat +} + +extension View { + func shadow(_ style: ShadowStyle) -> some View { + self.shadow(color: style.color, radius: style.radius, x: style.x, y: style.y) + } +} diff --git a/PlantGuide/PlantGuideApp.swift b/PlantGuide/PlantGuideApp.swift index 224e1b2..183244a 100644 --- a/PlantGuide/PlantGuideApp.swift +++ b/PlantGuide/PlantGuideApp.swift @@ -10,6 +10,10 @@ import SwiftUI @main struct PlantGuideApp: App { + // MARK: - Properties + + @State private var appearanceManager = AppearanceManager() + // MARK: - Initialization init() { @@ -27,6 +31,8 @@ struct PlantGuideApp: App { var body: some Scene { WindowGroup { MainTabView() + .environment(appearanceManager) + .preferredColorScheme(appearanceManager.colorScheme) } } } diff --git a/PlantGuide/Presentation/Extensions/Enums+UI.swift b/PlantGuide/Presentation/Extensions/Enums+UI.swift index 82b7f7c..0af79d8 100644 --- a/PlantGuide/Presentation/Extensions/Enums+UI.swift +++ b/PlantGuide/Presentation/Extensions/Enums+UI.swift @@ -25,11 +25,11 @@ extension CareTaskType { /// The color associated with this care task type var iconColor: Color { switch self { - case .watering: return .blue - case .fertilizing: return .green - case .repotting: return .brown - case .pruning: return .purple - case .pestControl: return .red + case .watering: return DesignSystem.Colors.taskWatering + case .fertilizing: return DesignSystem.Colors.taskFertilizing + case .repotting: return DesignSystem.Colors.taskRepotting + case .pruning: return DesignSystem.Colors.taskPruning + case .pestControl: return DesignSystem.Colors.taskPestControl } } diff --git a/PlantGuide/Presentation/Navigation/MainTabView.swift b/PlantGuide/Presentation/Navigation/MainTabView.swift index 93c5712..4202d3d 100644 --- a/PlantGuide/Presentation/Navigation/MainTabView.swift +++ b/PlantGuide/Presentation/Navigation/MainTabView.swift @@ -44,7 +44,7 @@ struct MainTabView: View { } .tag(Tab.settings) } - .tint(.green) + .tint(DesignSystem.Colors.accent) } } diff --git a/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift b/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift index 52bd006..5756668 100644 --- a/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift +++ b/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift @@ -19,6 +19,7 @@ struct SettingsView: View { // MARK: - Properties @State private var viewModel = SettingsViewModel() + @Environment(AppearanceManager.self) private var appearanceManager /// Whether to show the delete all data confirmation dialog @State private var showDeleteConfirmation = false @@ -31,6 +32,7 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { + appearanceSection identificationSection notificationsSection storageSection @@ -163,6 +165,33 @@ struct SettingsView: View { } } + // MARK: - Appearance Section + + private var appearanceSection: some View { + Section { + Picker(selection: Bindable(appearanceManager).mode) { + ForEach(AppearanceMode.allCases) { mode in + Label(mode.rawValue, systemImage: mode.icon) + .tag(mode) + } + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Appearance") + Text("Choose light, dark, or system theme") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: appearanceManager.mode.icon) + } + } + .pickerStyle(.navigationLink) + } header: { + Text("Appearance") + } + } + // MARK: - Notifications Section private var notificationsSection: some View {