# Phase 4 — Habit Tray: Implementation Plan ## Architectural Decision: New `HabitTray` Module Create `Modules/HabitTray` (do not expand `DayView`). The reasoning mirrors the Phase 3 decision to split Timeline: 1. **Responsibility**: The tray owns substantial independent state — expanded/collapsed toggle, `@Query` over all `Habit` objects, sort order by frequency, add-habit form presentation. Placing this in `DayView` would make that file responsible for layout orchestration, timeline paging, date navigation, and habit management all at once. 2. **Phase 5 drag coordination**: When drag & drop arrives, `DayView` will thread a `hoveredSlot: Binding` between `HabitTrayView` and `DayTimelineView`. This binding already exists on `DayTimelineView` (defaulting to `.constant(nil)`). The boundary is clean: `DayView` owns the shared drag state; `HabitTray` and `Timeline` are consumers. This is the same pattern already planned in Phase 3. 3. **Mac reuse (Phase 9)**: The menu bar popover uses the same `DayView` root. If the tray is a separate module, Phase 9 can tune `HabitTrayView` for the compact 320pt popover width without touching `DayView` or `Timeline`. 4. **Testability**: Frequency-sort logic and hex-to-Color parsing can be unit-tested in `HabitTrayTests` in isolation. **Dependency chain after Phase 4:** ``` ProjectDawn (app) → DayView → HabitTray → Data → Timeline → Data ``` --- ## Naming Notes - The module is `HabitTray`; the public entry point view is `HabitTrayView`. No collision risk with SwiftUI system types. - The add-habit form is `HabitFormView` (internal to `HabitTray`). Phase 7 may promote it if edit needs to be triggered from elsewhere. - `HabitPillView` is a shared internal component used by both the collapsed and expanded states. - The color helper is `Color+Hex.swift` — a `Color` extension, internal to the module. > **Implementation note:** `HabitLibrarySheet` was removed during implementation. The collapsed strip and expanded grid were unified into a single always-on sheet in `HabitTrayView` using `presentationDetents(selection:)`. `DayView` presents it via `.sheet(isPresented: .constant(true))`. `presentationBackgroundInteraction(.enabled(upThrough: collapsedDetent))` allows timeline interaction while the tray is collapsed. --- ## Geometry / Layout Constants ```swift // HabitTrayLayout.swift (internal enum) enum HabitTrayLayout { static let collapsedHeight: CGFloat = 80 // tray strip height (matches Phase 3 placeholder) static let pillHeight: CGFloat = 44 // capsule height in tray row static let pillHPadding: CGFloat = 12 // horizontal internal padding in pill static let pillHSpacing: CGFloat = 8 // spacing between pills in scroll row static let sheetFraction: CGFloat = 0.7 // presentationDetents(.fraction(0.7)) static let gridColumns: Int = 3 // columns in library grid static let gridSpacing: CGFloat = 12 } ``` --- ## Step-by-Step Plan ### Step 1 — Scaffold `HabitTray` module manifest **Commit:** `chore(tuist): scaffold HabitTray module manifest` - Create `Modules/HabitTray/Project.swift`: ```swift import ProjectDescription import ProjectDescriptionHelpers let project = Project.module( name: "HabitTray", dependencies: [ .project(target: "Data", path: "../Data"), ] ) ``` - Create the required empty directories: `Modules/HabitTray/Sources/`, `Modules/HabitTray/Resources/`, `Modules/HabitTray/Tests/` (Tuist globs these; they must exist). --- ### Step 2 — Wire into workspace and `DayView` **Commit:** `chore(tuist): wire HabitTray into workspace and DayView` - `Workspace.swift` — add `"Modules/HabitTray"` to the `projects` array. - `Modules/DayView/Project.swift` — add `.project(target: "HabitTray", path: "../HabitTray")` to the `dependencies` array alongside the existing `Timeline` entry. - Run `tuist generate` to verify the graph resolves cleanly. --- ### Step 3 — `Color+Hex.swift` (internal helper) **Commit:** (bundled with Step 6) Every `Habit` stores its color as a hex string (`colorHex: String`). The tray needs `hex → Color` for rendering and `Color → hex` when saving a custom-picked color. ```swift // Modules/HabitTray/Sources/Color+Hex.swift import SwiftUI extension Color { /// Parses a 6-digit hex string (with or without leading `#`) into a Color. /// Returns `.accentColor` as a safe fallback for malformed input. init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 guard hex.count == 6, Scanner(string: hex).scanHexInt64(&int) else { self = .accentColor return } let r = Double((int >> 16) & 0xFF) / 255 let g = Double((int >> 8) & 0xFF) / 255 let b = Double(int & 0xFF) / 255 self = Color(red: r, green: g, blue: b) } /// Converts this Color to a 6-character uppercase hex string. /// Returns nil if the color cannot be resolved to RGB components. func toHex() -> String? { #if canImport(UIKit) guard let components = UIColor(self).cgColor.components, components.count >= 3 else { return nil } #elseif canImport(AppKit) guard let components = NSColor(self).cgColor.components, components.count >= 3 else { return nil } #else return nil #endif let r = Int(components[0] * 255) let g = Int(components[1] * 255) let b = Int(components[2] * 255) return String(format: "%02X%02X%02X", r, g, b) } } ``` The `#if canImport` guards keep `toHex()` working for both iOS and macOS (Phase 9 menu bar). --- ### Step 4 — `HabitTrayLayout.swift` + `HabitColor.swift` + `HabitPillView.swift` (internal) **Commit:** `feat(HabitTray): HabitPillView — emoji + name capsule` ```swift // Modules/HabitTray/Sources/HabitColor.swift import Foundation struct HabitColor { let name: String let hex: String static let presets: [HabitColor] = [ HabitColor(name: "Coral", hex: "FF6B6B"), HabitColor(name: "Peach", hex: "FF9F43"), HabitColor(name: "Sun", hex: "FECA57"), HabitColor(name: "Mint", hex: "48DBAB"), HabitColor(name: "Sky", hex: "54A0FF"), HabitColor(name: "Lavender", hex: "A29BFE"), HabitColor(name: "Rose", hex: "FD79A8"), HabitColor(name: "Stone", hex: "B2BEC3"), ] } ``` ```swift // Modules/HabitTray/Sources/HabitPillView.swift import SwiftUI import Data struct HabitPillView: View { let habit: Habit private var backgroundColor: Color { Color(hex: habit.colorHex) } // Luminance-based contrast: white on dark colors, near-black on light. private var foregroundColor: Color { let hex = habit.colorHex.trimmingCharacters(in: .init(charactersIn: "#")) var int: UInt64 = 0 Scanner(string: hex).scanHexInt64(&int) let r = Double((int >> 16) & 0xFF) / 255 let g = Double((int >> 8) & 0xFF) / 255 let b = Double(int & 0xFF) / 255 let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b return luminance > 0.55 ? Color(white: 0.1) : .white } var body: some View { HStack(spacing: 4) { Text(habit.emoji) Text(habit.name) .lineLimit(1) } .font(.subheadline.weight(.medium)) .foregroundStyle(foregroundColor) .padding(.horizontal, HabitTrayLayout.pillHPadding) .frame(height: HabitTrayLayout.pillHeight) .background(backgroundColor, in: Capsule()) } } ``` The luminance threshold of `0.55` correctly renders dark text on Sun (`#FECA57`) and Stone (`#B2BEC3`), and white text on Coral, Mint, Sky, Lavender, and Rose. It also handles arbitrary `ColorPicker` output. --- ### Step 5 — `ColorSwatchRowView.swift` (internal) **Commit:** (bundled with Step 6) ```swift // Modules/HabitTray/Sources/ColorSwatchRowView.swift import SwiftUI struct ColorSwatchRowView: View { @Binding var selectedHex: String @Binding var customColor: Color @Binding var useCustomColor: Bool var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(HabitColor.presets, id: \.hex) { preset in Circle() .fill(Color(hex: preset.hex)) .frame(width: 32, height: 32) .overlay( Circle().strokeBorder( (!useCustomColor && selectedHex == preset.hex) ? Color.primary : Color.clear, lineWidth: 2.5 ) .padding(-3) ) .onTapGesture { selectedHex = preset.hex useCustomColor = false } } // System ColorPicker rendered as a circle swatch ColorPicker("Custom", selection: $customColor, supportsOpacity: false) .labelsHidden() .frame(width: 32, height: 32) .clipShape(Circle()) .overlay( Circle().strokeBorder( useCustomColor ? Color.primary : Color.clear, lineWidth: 2.5 ) .padding(-3) ) .onChange(of: customColor) { _, _ in useCustomColor = true } } .padding(.vertical, 4) } } } ``` The `ColorPicker` is clipped to a circle so it matches the preset swatch row visually. Tapping it opens the system full-spectrum picker. `onChange` automatically switches `useCustomColor = true`. --- ### Step 6 — `HabitFormView.swift` (internal) **Commit:** `feat(HabitTray): HabitColor + ColorSwatchRowView + HabitFormView` ```swift // Modules/HabitTray/Sources/HabitFormView.swift import SwiftUI import SwiftData import Data struct HabitFormView: View { @Environment(\.modelContext) private var context @Binding var isPresented: Bool @State private var name: String = "" @State private var emoji: String = "⭐️" @State private var selectedColorHex: String = HabitColor.presets[0].hex @State private var customColor: Color = .accentColor @State private var useCustomColor: Bool = false @State private var defaultDuration: Int = 30 private var isSaveDisabled: Bool { name.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { NavigationStack { Form { Section("Name") { TextField("e.g. Morning Run", text: $name) } Section("Emoji") { TextField("Emoji", text: $emoji) .onChange(of: emoji) { _, new in // Clamp to first grapheme cluster if let first = new.unicodeScalars.first { emoji = String(first.value > 0xFF ? String(new.prefix(2)) : String(new.prefix(1))) } } } Section("Color") { ColorSwatchRowView( selectedHex: $selectedColorHex, customColor: $customColor, useCustomColor: $useCustomColor ) } Section("Default Duration") { Picker("Duration", selection: $defaultDuration) { ForEach([15, 30, 45, 60, 90, 120], id: \.self) { mins in Text("\(mins) min").tag(mins) } } .pickerStyle(.wheel) .frame(height: 120) } } .navigationTitle("New Habit") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { isPresented = false } } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } .disabled(isSaveDisabled) } } } } private func save() { let resolvedHex = useCustomColor ? (customColor.toHex() ?? selectedColorHex) : selectedColorHex let habit = Habit( name: name.trimmingCharacters(in: .whitespaces), emoji: emoji, colorHex: resolvedHex, defaultDuration: defaultDuration ) context.insert(habit) isPresented = false } } ``` **Emoji field note**: `TextField` allows multi-character input. The `onChange` handler trims to the first grapheme cluster. Most emoji are single scalars > U+00FF; skin-tone variants use sequences, so `prefix(2)` handles those. A dedicated emoji picker grid (Phase 7) will replace this. --- ### Step 7 — `HabitLibrarySheet.swift` (internal) **Commit:** `feat(HabitTray): HabitLibrarySheet — expanded grid of all habits` ```swift // Modules/HabitTray/Sources/HabitLibrarySheet.swift import SwiftUI import SwiftData import Data struct HabitLibrarySheet: View { @Query private var habits: [Habit] @Binding var isPresented: Bool let onAddHabit: () -> Void private var sortedHabits: [Habit] { habits.sorted { $0.instances.count > $1.instances.count } } private let columns = Array( repeating: GridItem(.flexible(), spacing: HabitTrayLayout.gridSpacing), count: HabitTrayLayout.gridColumns ) var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: columns, spacing: HabitTrayLayout.gridSpacing) { ForEach(sortedHabits) { habit in HabitPillView(habit: habit) .frame(maxWidth: .infinity) } Button(action: onAddHabit) { Label("Add Habit", systemImage: "plus") .font(.subheadline.weight(.medium)) .frame(maxWidth: .infinity) .frame(height: HabitTrayLayout.pillHeight) .background(.secondary.opacity(0.15), in: Capsule()) } .buttonStyle(.plain) } .padding() } .navigationTitle("Habit Library") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { isPresented = false } } } } .presentationDetents([.fraction(HabitTrayLayout.sheetFraction), .large]) .presentationDragIndicator(.visible) } } ``` Notes: - `.presentationDetents([.fraction(0.7), .large])` lets the user pull the sheet fully open if they have many habits. - `.presentationDragIndicator(.visible)` provides a system drag handle — no need to draw a custom one. - Frequency sort (`instances.count`) is computed in-memory since `@Query` cannot sort on relationship aggregates. Hundreds of habits is the realistic scale — in-memory is negligible. - The `onAddHabit` closure bubbles up from `HabitTrayView` so form presentation is controlled from one call site. --- ### Step 8 — `HabitTrayView.swift` (public entry point) **Commit:** `feat(HabitTray): HabitTrayView — collapsed tray, drag-up expansion` ```swift // Modules/HabitTray/Sources/HabitTrayView.swift import SwiftUI import SwiftData import Data public struct HabitTrayView: View { @Query private var habits: [Habit] @State private var showLibrary: Bool = false @State private var showAddHabit: Bool = false public init() {} // Most recently added first — a reasonable proxy until Phase 5 adds usage tracking. private var trayHabits: [Habit] { habits.sorted { $0.createdAt > $1.createdAt } } public var body: some View { VStack(spacing: 0) { // Drag handle Capsule() .fill(Color.secondary.opacity(0.35)) .frame(width: 36, height: 4) .padding(.top, 8) .onTapGesture { showLibrary = true } // Horizontal pill scroll row ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: HabitTrayLayout.pillHSpacing) { ForEach(trayHabits) { habit in HabitPillView(habit: habit) } Button { showAddHabit = true } label: { Label("Add Habit", systemImage: "plus") .font(.subheadline.weight(.medium)) .padding(.horizontal, HabitTrayLayout.pillHPadding) .frame(height: HabitTrayLayout.pillHeight) .background(.secondary.opacity(0.15), in: Capsule()) } .buttonStyle(.plain) } .padding(.horizontal) .padding(.vertical, 10) } } .frame(height: HabitTrayLayout.collapsedHeight) .background(.bar) .gesture( DragGesture(minimumDistance: 10) .onEnded { value in if value.translation.height < -20 { withAnimation(.spring(duration: 0.4)) { showLibrary = true } } } ) .sheet(isPresented: $showLibrary) { HabitLibrarySheet(isPresented: $showLibrary) { showLibrary = false showAddHabit = true } } .sheet(isPresented: $showAddHabit) { HabitFormView(isPresented: $showAddHabit) } } } ``` Key design choices: - **Drag gesture**: `DragGesture(minimumDistance: 10)` with a negative-Y threshold (`< -20`) triggers the sheet. The horizontal `ScrollView` consumes horizontal gesture vectors first, so the tray's drag gesture only fires on predominantly upward swipes — no conflict. - **Spring animation**: `.spring(duration: 0.4)` on `showLibrary = true` matches PRD §6.4. - **Two independent sheets** (`showLibrary`, `showAddHabit`): When the user taps "+ Add Habit" inside the library, `showLibrary = false` runs first, then `showAddHabit = true`. SwiftUI processes these sequentially, avoiding the "sheet presenting over sheet" runtime warning on iOS 17. - **Separate `@Query` in tray and library**: Both query the same table. SwiftData deduplicates at the persistent store layer. Avoids prop-drilling a potentially large array. --- ### Step 9 — Wire `HabitTrayView` into `DayView` **Commit:** `feat(DayView): replace tray placeholder with HabitTrayView` In `Modules/DayView/Sources/DayView.swift`, replace the placeholder: ```swift // Before: // Phase 4: HabitTray Color.secondary.opacity(0.1) .frame(height: 80) // After: HabitTrayView() ``` Add `import HabitTray` at the top. In `Modules/DayView/Project.swift`, add the new dependency: ```swift let project = Project.module( name: "DayView", dependencies: [ .project(target: "Data", path: "../Data"), .project(target: "Timeline", path: "../Timeline"), .project(target: "HabitTray", path: "../HabitTray"), ] ) ``` The `@modelContainer` is injected at the app entry point (`ProjectDawnApp.swift`), so `HabitTrayView`'s `@Query` and `HabitFormView`'s `@Environment(\.modelContext)` resolve automatically — no changes to `ProjectDawnApp.swift`. --- ## File Summary | Commit | Files | |---|---| | `chore(tuist): scaffold HabitTray module manifest` | `Modules/HabitTray/Project.swift` (new) | | `chore(tuist): wire HabitTray into workspace and DayView` | `Workspace.swift`, `Modules/DayView/Project.swift` | | `feat(HabitTray): HabitPillView — emoji + name capsule with luminance contrast` | `Sources/HabitPillView.swift` (new), `Sources/HabitTrayLayout.swift` (new) | | `feat(HabitTray): HabitColor presets + Color+Hex + ColorSwatchRowView + HabitFormView` | `Sources/HabitColor.swift` (new), `Sources/Color+Hex.swift` (new), `Sources/ColorSwatchRowView.swift` (new), `Sources/HabitFormView.swift` (new) | | `feat(HabitTray): HabitTrayView — public entry point` | `Sources/HabitTrayView.swift` (new) | | `feat(DayView): replace tray placeholder with HabitTrayView` | `Modules/DayView/Sources/DayView.swift`, `Modules/DayView/Project.swift` | | `refactor(HabitTray): merge library sheet into HabitTrayView as a single always-on sheet` | `Sources/HabitTrayView.swift`, `Sources/HabitLibrarySheet.swift` (deleted), `Modules/DayView/Sources/DayView.swift` | --- ## Key Trade-offs | Decision | Choice | Reason | |---|---|---| | Module split | New `HabitTray` module | Single responsibility; isolated unit tests for sort/color logic; Phase 9 Mac reuse | | Tray sort order | `createdAt` descending | Simple default; Phase 5 replaces with usage-frequency when logging is implemented | | Frequency sort in library | In-memory sort on `instances.count` | `@Query` cannot sort on relationship aggregates; 100s of habits is the realistic scale — in-memory is negligible | | Emoji picker | `TextField` clamped to first grapheme | Zero dependencies, good enough for v1; Phase 7 replaces with grid picker during edit-habit work | | Sheet sequencing (library → add) | Dismiss library first, then present add | Avoids iOS "sheet over sheet" runtime warning; clean UX transition | | Drag gesture vs handle tap for expansion | Both (drag on strip + tappable handle) | Handle tap is more discoverable; drag is more natural once users know the tray | | `ColorPicker` integration | System `ColorPicker` as rainbow swatch | Zero custom code for full-spectrum picker; renders as a circle swatch matching the preset row | | `toHex()` implementation | `UIColor`/`NSColor` bridge with `#if canImport` guard | Required for Mac compatibility; `Color` has no public RGB accessors in SwiftUI | | Two `@Query` instances (tray + library) | Kept separate | Avoids prop-drilling; SwiftData deduplicates at the persistent store layer |