Last Updated: 2026-01-31
Current Status: See docs/PROJECT_STATUS.md for authoritative status
Parity: AGENTS.md and CLAUDE.md are redundant copies and must be kept in sync.
Cravey is a cannabis cessation support iOS app (iOS 18+) built with Clean Architecture + MVVM using modern SwiftUI and SwiftData. The app helps users track cravings, record motivational videos/audio, and access supportive content during vulnerable moments.
- Privacy-First: All data is local-only. No cloud sync, no analytics, no tracking. SwiftData with
.noneCloudKit configuration. - Clean Architecture: Pure Domain layer, isolated Data layer, framework-independent business logic following Robert C. Martin principles.
- iOS-Only (Initial Release): Focused on iOS 18+. macOS support planned for future.
- Motivational Interviewing: Self-compassion, progress tracking, non-judgmental language.
- Simplicity: Clear UI for users in crisis moments.
- Swift 6.2 with strict concurrency
- SwiftUI for declarative UI (iOS 18+)
- SwiftData for persistence (@Model macro)
- AVFoundation for audio/video recording (TODO)
- XcodeGen 2.44.1 for project file generation
- Xcode 26.0.1 (Build 17A400)
- xcbeautify 2.30.1 - Pretty xcodebuild output
- swiftlint 0.61.0 - Code style linting
- swiftformat 0.58.3 - Code formatting
- gh 2.81.0 - GitHub CLI
This project follows the latest Apple conventions for iOS 18+ development:
1. @Observable (NOT ObservableObject)
// ✅ Modern (2025)
@Observable
@MainActor
final class CravingLogViewModel {
var intensity: Double = 5.0
var notes: String = ""
// No @Published needed!
}
// ❌ Legacy (pre-iOS 17)
class CravingLogViewModel: ObservableObject {
@Published var intensity: Double = 5.0
@Published var notes: String = ""
}2. @State for Objects (NOT @StateObject)
// ✅ Modern (2025)
struct ContentView: View {
@State private var viewModel = CravingLogViewModel()
// ...
}
// ❌ Legacy
struct ContentView: View {
@StateObject private var viewModel = CravingLogViewModel()
}3. @Environment(Type.self) for DI (NOT @EnvironmentObject)
// ✅ Modern (2025)
struct ContentView: View {
@Environment(DependencyContainer.self) private var container
}
// ❌ Legacy
struct ContentView: View {
@EnvironmentObject var container: DependencyContainer
}4. @Bindable for Two-Way Bindings
// ✅ Use @Bindable when you need bindings to @Observable properties
struct EditView: View {
@Bindable var book: Book // NOT @ObservedObject!
var body: some View {
TextField("Title", text: $book.title)
}
}5. Deferred Initialization for Expensive Objects
// ✅ Prevents recreating ViewModels on every view update
struct HomeView: View {
@State private var viewModel: CravingLogViewModel?
var body: some View {
if let viewModel {
ContentView(viewModel: viewModel)
} else {
Color.clear.task {
viewModel = makeViewModel()
}
}
}
}6. @ObservationIgnored for Non-Tracked Properties
// ✅ Exclude properties from observation tracking
@Observable
@MainActor
final class CravingLogViewModel {
var intensity: Double = 5.0 // Tracked
@ObservationIgnored
private let dateFormatter = DateFormatter() // NOT tracked
}When to use: Formatters, caches, dependencies - anything that shouldn't trigger view updates.
7. @Previewable for Preview-Specific State
// ✅ Create state inside #Preview blocks
#Preview("Craving Log") {
@Previewable @State var viewModel = CravingLogViewModel(
logCravingUseCase: MockLogCravingUseCase()
)
CravingLogForm(viewModel: viewModel)
}1. @Model Macro (SwiftData persistence model)
// ✅ Modern (2025)
@Model
final class CravingModel {
@Attribute(.unique) var id: UUID
var timestamp: Date
var intensity: Int
// SwiftData model (reference type). Keep it out of Domain/Presentation.
}2. @Attribute for Constraints
@Model
final class UserProfile {
@Attribute(.unique) var id: UUID // Uniqueness constraint
@Attribute(.unique) var email: String
var name: String
}3. @Relationship with Delete Rules
@Model
final class CravingModel {
@Relationship(deleteRule: .nullify, inverse: \RecordingModel.linkedCravings)
var recording: RecordingModel?
}
@Model
final class RecordingModel {
@Relationship(deleteRule: .nullify)
var linkedCravings: [CravingModel] = []
}4. @Transient for Non-Persisted Properties
@Model
final class RecordingModel {
var filePath: String
@Transient
var isDownloading: Bool = false // Not saved to database
}5. ModelContext from Environment
// ✅ For simple apps without Clean Architecture
@Environment(\.modelContext) private var modelContext
func saveData() {
modelContext.insert(newModel)
try? modelContext.save()
}
// ✅ For Clean Architecture (our approach)
@Environment(DependencyContainer.self) private var container
let repository = container.cravingRepository // Keeps Domain pure6. @ModelActor for Concurrent SwiftData Access
// ✅ Recommended for concurrent writes
@ModelActor
actor DataHandler {
func saveCraving(_ craving: CravingModel) {
modelContext.insert(craving)
try? modelContext.save()
}
}Why @ModelActor: Thread-safe, no nonisolated(unsafe) needed. Our repositories use nonisolated(unsafe) for Clean Architecture compatibility.
7. @Query for Direct SwiftData Access
// ✅ SwiftUI-native (simple apps)
struct CravingListView: View {
@Query(sort: \CravingModel.timestamp, order: .reverse)
private var cravings: [CravingModel]
}Why We DON'T Use @Query: Violates Clean Architecture (couples UI to Data layer). We use repositories + use cases for testability and framework independence.
While @Environment(\.modelContext) is valid for simple apps, we use Clean Architecture:
- ✅ Domain layer stays framework-independent (no SwiftData imports)
- ✅ Repository pattern enables mocking/testing
- ✅ Use cases are pure business logic
- ✅ Works seamlessly with @Observable (no migration needed)
For detailed examples, see: PHASE_1 Best Practices
Cravey/
├── App/ # Composition Root (DI)
│ ├── CraveyApp.swift # @main entry point
│ ├── DependencyContainer.swift
│ ├── AppStartupHandler.swift # Startup initialization logic
│ ├── AppUnavailableView.swift
│ └── Constants/
│ └── InfrastructureConstants.swift # Timeouts, storage limits (Data layer config)
├── Domain/ # Pure Swift (NO frameworks)
│ ├── Entities/ # Business models
│ │ ├── CravingEntity.swift
│ │ ├── UsageEntity.swift
│ │ ├── RecordingEntity.swift
│ │ ├── MotivationalMessageEntity.swift
│ │ ├── ValidationLimits.swift # Shared validation constants
│ │ └── TriggerOptions.swift # HAALT trigger definitions
│ ├── UseCases/ # Business logic
│ │ ├── LogCravingUseCase.swift
│ │ ├── FetchCravingsUseCase.swift
│ │ ├── LogUsageUseCase.swift
│ │ └── FetchUsageUseCase.swift
│ └── Repositories/ # Protocols ONLY
│ ├── CravingRepositoryProtocol.swift
│ ├── UsageRepositoryProtocol.swift
│ ├── RecordingRepositoryProtocol.swift
│ └── MessageRepositoryProtocol.swift
├── Data/ # Persistence + Storage
│ ├── Models/ # SwiftData @Model
│ │ ├── CravingModel.swift
│ │ ├── UsageModel.swift
│ │ ├── RecordingModel.swift
│ │ └── MotivationalMessageModel.swift
│ ├── Repositories/ # Concrete implementations
│ │ ├── CravingRepository.swift
│ │ ├── UsageRepository.swift
│ │ ├── RecordingRepository.swift
│ │ ├── MessageRepository.swift
│ │ └── RepositoryHelpers.swift # Shared CRUD helpers
│ ├── Mappers/ # Entity ↔ Model conversion
│ │ ├── CravingMapper.swift
│ │ ├── UsageMapper.swift
│ │ ├── RecordingMapper.swift
│ │ └── MessageMapper.swift
│ └── Storage/ # File I/O + ModelContainer
│ ├── FileStorageManager.swift
│ └── ModelContainerSetup.swift
└── Presentation/ # UI Layer
├── Protocols/ # Shared ViewModel protocols
│ ├── LocationHandling.swift # GPS location selection
│ ├── TimestampWarning.swift # Old timestamp validation
│ ├── FormSubmission.swift # Loading/error/success state
│ └── ListViewModel.swift # Fetch/delete patterns
├── Constants/ # UI constants
│ ├── AppConstants.swift # Form defaults (Presentation layer only)
│ └── UIConstants.swift # Toast duration, animation timing
├── ViewModels/ # @Observable state
│ ├── CravingLogViewModel.swift
│ ├── CravingListViewModel.swift
│ ├── UsageLogViewModel.swift
│ ├── UsageListViewModel.swift
│ ├── DashboardViewModel.swift
│ └── SettingsViewModel.swift
└── Views/ # SwiftUI
├── Home/HomeView.swift
├── Log/LogView.swift
├── History/HistoryView.swift
├── Craving/CravingLogForm.swift, CravingListView.swift
├── Usage/UsageLogForm.swift, UsageListView.swift
├── Settings/SettingsView.swift, ExportDataSheet.swift
├── Modifiers/ # Reusable ViewModifiers
│ ├── FormAlertsModifier.swift
│ ├── FormToolbarModifier.swift
│ ├── DeleteConfirmationModifier.swift
│ └── LocationPermissionAlertModifier.swift
└── Components/ # Reusable UI components
├── ChipSelector.swift
├── IntensitySlider.swift
├── TimestampPicker.swift
├── ROAPickerInput.swift
├── EmptyStateView.swift # Generic empty state
├── LocationSelector.swift # GPS-aware location picker
└── LocationOptions.swift
CraveyTests/ # Unit Tests
├── Domain/UseCases/
├── Integration/
└── Presentation/ViewModels/
CraveyUITests/ # UI Tests (XCTest; uses --uitesting in-memory mode)
Presentation → Domain ← Data
↓ ↓ ↓
Views Use Cases Repos
↓ ↓ ↓
ViewModels Entities Models
Key Rules:
- Domain layer = Pure Swift (NO SwiftUI/SwiftData imports)
- Data layer implements Domain protocols
- Presentation depends ONLY on Domain (via Use Cases)
- DependencyContainer wires everything together
# 1. Install all CLI tools
./setup-tools.sh
# 2. Generate Xcode project from project.yml
xcodegen generate
# 3. Open in Xcode
open Cravey.xcodeproj# Build from terminal
xcodebuild -scheme Cravey \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
build | xcbeautify
# Run unit tests only (fast)
xcodebuild test -scheme Cravey \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:CraveyTests | xcbeautify
# Run all tests (slower, includes UI tests)
xcodebuild test -scheme Cravey \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' | xcbeautify
# Format code (run before commit)
swiftformat .
# Lint code
swiftlint
# Auto-fix linting issues
swiftlint --fix
# Clean build
xcodebuild clean -scheme Cravey
# List available simulators
xcrun simctl list devices available | grep iPhone
# Regenerate Xcode project (if project.yml changes)
xcodegen generate# Stage changes
git add .
# Commit with proper message
git commit -m "Your message"
# Push to main
git push origin main
# View repo on GitHub
gh repo view clarity-digital-twin/cravey --web
# Create PR (when working on branches)
gh pr create --title "Title" --body "Description"File: Cravey/Data/Models/CravingModel.swift
Tracks individual craving episodes:
id: UUID- Unique identifiertimestamp: Date- When craving occurred (user-editable)intensity: Int- Scale 1-10triggers: [String]- HAALT triggers (multi-select)location: String?- Where it happened (preset or GPS)notes: String?- User notes (500 char limit)createdAt: Date- Auto-set on creationmodifiedAt: Date?- Set on update
File: Cravey/Data/Models/UsageModel.swift
Tracks cannabis usage:
id: UUID- Unique identifiertimestamp: Date- When usage occurredmethod: String- ROA (Bowls, Joints, Vape, etc.)amount: Double- Amount consumedtriggers: [String]- HAALT triggerslocation: String?- Where it happenednotes: String?- User notes (500 char limit)createdAt: Date- Auto-set on creationmodifiedAt: Date?- Set on update
File: Cravey/Data/Models/RecordingModel.swift
Stores video/audio recordings:
id: UUIDtimestamp: Datetype: String- "video" or "audio"purpose: String- "motivational", "craving", "reflection", "milestone"duration: TimeIntervalfilePath: String- Relative path to file (stored as string in SwiftData)thumbnailPath: String?- For videostitle: String?notes: String?playCount: IntlastPlayedAt: Date?craving: CravingModel?- @Relationship (many-to-one, optional)
File: Cravey/Data/Models/MotivationalMessageModel.swift
Pre-populated and user-created messages:
id: UUIDcontent: Stringcategory: String- "urge", "anxiety", "boredom", "social", "celebration"isCustom: Bool- User-created vs defaultpriority: Int- Display ordertimesShown: IntlastShownAt: Date?isActive: Bool
File: Cravey/Data/Storage/FileStorageManager.swift
~/Documents/
└── Recordings/
├── video_UUID.mov
├── audio_UUID.m4a
└── Thumbnails/
└── video_UUID_thumb.jpg
Important:
- File paths stored as relative strings in SwiftData
DependencyContainer.fileStorageprovides file I/O (injectableFileStorageManager)- Delete file AND database entry together
- Use
nonisolated(unsafe)for ModelContext in Swift 6 strict concurrency
File: Cravey/Data/Storage/ModelContainerSetup.swift
ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
cloudKitDatabase: .none // ⚠️ CRITICAL: Local only
)See
docs/PROJECT_STATUS.mdfor detailed current status.
- Craving Logging - Full form with intensity, triggers, location, notes, timestamp
- Usage Logging - Full form with ROA picker, amounts, triggers, location, notes
- Dashboard - 5 metric cards, streak tracking, intensity trends
- Settings - Export (CSV/JSON) + delete-all (logs + recordings + custom messages)
- Home Screen - Lists cravings + usage with swipe actions
- Clean Architecture folder structure
- Domain layer (7 entities/value objects, 12 use case files, 4 repository protocols)
- Data layer (4 models, 4 mappers, 4 repositories + RepositoryHelpers)
- DependencyContainer with DI + AppStartupHandler
- 4 shared ViewModel protocols (LocationHandling, TimestampWarning, FormSubmission, ListViewModel)
- 4 reusable ViewModifiers (FormAlerts, FormToolbar, DeleteConfirmation, LocationPermissionAlert)
- 7 ViewModels (CravingLog, CravingList, UsageLog, UsageList, Dashboard, Settings, HomeMotivation)
- 10+ SwiftUI Views with reusable components (EmptyStateView, LocationSelector, etc.)
- Centralized constants (InfrastructureConstants (App), AppConstants/UIConstants (Presentation), ValidationLimits (Domain))
- Unit tests (121 Swift Testing tests passing in
CraveyTests) - UI tests (22 XCTest UI tests passing in
CraveyUITests; optional viascripts/verify.sh --ui) - XcodeGen configuration
- Recording Views - AVFoundation integration, recording/playback UI
- Onboarding - WelcomeView, TourView not created
-
Create Repository Implementation
// Cravey/Data/Repositories/RecordingRepository.swift final class RecordingRepository: RecordingRepositoryProtocol { nonisolated(unsafe) private let modelContext: ModelContext init(modelContext: ModelContext) { self.modelContext = modelContext } func save(_ recording: RecordingEntity) async throws { let model = RecordingMapper.toModel(recording) modelContext.insert(model) try modelContext.save() } // ... implement other protocol methods }
-
Update DependencyContainer
// Replace stub in Cravey/App/DependencyContainer.swift let recordingRepo = RecordingRepository(modelContext: modelContext) self.recordingRepository = recordingRepo
-
Create Use Case
// Cravey/Domain/UseCases/SaveRecordingUseCase.swift protocol SaveRecordingUseCase: Sendable { func execute(...) async throws -> RecordingEntity }
-
Create ViewModel
// Cravey/Presentation/ViewModels/RecordingViewModel.swift @Observable @MainActor final class RecordingViewModel { private let saveRecordingUseCase: SaveRecordingUseCase // ... }
-
Create View
// Cravey/Presentation/Views/RecordingView.swift struct RecordingView: View { @State private var viewModel: RecordingViewModel // ... }
-
Write Tests
// CraveyTests/Domain/UseCases/SaveRecordingUseCaseTests.swift // CraveyTests/Presentation/ViewModels/RecordingViewModelTests.swift
Run: xcodebuild test -scheme Cravey -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:CraveyTests | xcbeautify
- Test Domain Use Cases with mock repositories
- Test ViewModels with mock use cases
- Use
actorfor mock implementations (Swift 6 concurrency) - Testing framework: Swift Testing (
import Testing,@Testmacro)
Example:
@Test("Should save valid craving")
func testLogValidCraving() async throws {
let mockRepo = MockCravingRepository()
let useCase = DefaultLogCravingUseCase(repository: mockRepo)
let result = try await useCase.execute(intensity: 5, ...)
#expect(result.intensity == 5)
let count = try await mockRepo.count()
#expect(count == 1)
}Run: xcodebuild test -scheme Cravey -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:CraveyUITests | xcbeautify
- 22 tests covering craving/usage flows, navigation, and empty states
- Uses
--uitestinglaunch argument for in-memory SwiftData - Not part of automated CI gate (manual verification)
- Add to
Domain/Entities/XEntity.swift - Add to
Data/Models/XModel.swift - Update mapper in
Data/Mappers/XMapper.swift - SwiftData handles lightweight migrations automatically
- Update relevant views/ViewModels
// Check database location
print(modelContainer.configurations.first?.url)
// Query manually
let descriptor = FetchDescriptor<CravingModel>()
let results = try modelContext.fetch(descriptor)
print("Found \(results.count) cravings")- Use
nonisolated(unsafe)for ModelContext in repositories - Mark ViewModels with
@MainActor - Use
actorfor test mocks - Don't use
lazy varwith@Observable
- Users may be in crisis → Keep UI simple, large tap targets, clear CTAs
- Privacy is critical → Never add cloud features, no analytics
- Be compassionate → Language should be supportive, not judgmental
- Focus on progress → Celebrate wins, normalize setbacks
- Don't gamify excessively → This isn't a fitness app, avoid harsh streaks
Language Guidelines:
- ✅ "You're doing great"
- ✅ "Every moment of resistance is progress"
- ✅ "Setbacks are part of the journey"
- ❌ "You failed"
- ❌ "Streak broken"
- ❌ "Try harder"
Source of Truth: project.yml (committed to git)
Generated (Not Committed): Cravey.xcodeproj (gitignored)
- After modifying
project.yml - After cloning fresh repo
- After adding new files/folders
- When Xcode project gets corrupted
xcodegen generatename: Cravey
options:
deploymentTarget:
iOS: 18.0
settings:
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: "complete"CFBundleShortVersionStringuses Semver and stays0.x.yuntil the first public release.CFBundleVersionis the build number and must increase monotonically.- Source of truth:
Config/iOS.Info.plist.template(generated into the built app).
Suggested milestones:
- 0.1.0 - Craving + Usage logging (current)
- 0.2.0 - Recordings feature complete
- 0.3.0 - Onboarding flow complete
- 0.9.0 - Feature complete, ready for beta testing
- 1.0.0 - Public App Store release
- docs/PROJECT_STATUS.md - Single source of truth for current status
- docs/ARCHITECTURE.md - Deep dive into Clean Architecture implementation
- docs/GETTING_STARTED.md - Quick 5-minute setup guide
- docs/master/ - Authoritative product/clinical/data-model specs (SSOT)
- docs/specs/ - Active engineering specs
- docs/bugs/ - Bug tracker
- docs/debt/ - Technical debt tracker
- AGENTS.md / CLAUDE.md - Development context (redundant copies; keep in sync)
- README.md - Public-facing project overview
- docs/_archive/ - Historical docs (do not reference)
Configured in: .mcp.json (gitignored, local only)
Usage:
"use context7 to fetch latest SwiftUI documentation"
"use context7 to get SwiftData best practices"
Available Resources:
/mongodb/docs- SwiftUI docs (via website fetching)
- SwiftData docs (via website fetching)
See
docs/PROJECT_STATUS.mdfor prioritized backlog.
Options after stabilization:
- Onboarding - WelcomeView + TourView (improves first-launch)
- Recordings Feature - AVFoundation, RecordingRepository, UI
- Audio recording first (simpler)
- Video recording second (complex)
- Recording library UI
- TestFlight - Beta testing
- App Store assets - Screenshots, description
# Full rebuild + test
xcodegen generate && \
xcodebuild -scheme Cravey \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
build test | xcbeautify
# Format + Lint + Test
swiftformat . && \
swiftlint && \
xcodebuild test -scheme Cravey \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:CraveyTests | xcbeautify
# Commit + Push
git add . && \
git commit -m "Your message" && \
git push origin main🔥 Keep this file updated as architecture evolves! 🔥