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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-23 14:17:56 -06:00
parent 136b327093
commit be0d298d9f
8 changed files with 882 additions and 6 deletions

545
Docs/FEATURE_ROADMAP.md Normal file
View File

@@ -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<UUID>`, 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**

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -44,7 +44,7 @@ struct MainTabView: View {
}
.tag(Tab.settings)
}
.tint(.green)
.tint(DesignSystem.Colors.accent)
}
}

View File

@@ -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 {