# Project export: DishDash

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2025
- Tagline: A dating app for recipes—swipe, save, and fall in love with meals tailored to your taste. Discover, match, and cook your perfect dish.
- Devpost: https://devpost.com/software/dishdash-p1vj5t
- GitHub: https://github.com/UpsideDownTurtle/DishDash.git
- Video: https://www.youtube.com/embed/4L6ZsBnIMZQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — UpsideDownTurtle (2 commits)

## Devpost submission (written by the team)

### Inspiration

We wanted to make meal planning fun and effortless—like dating apps, but for food. With so many recipes out there, DishDash helps users discover meals they'll truly love.

### What it does

DishDash is a swipe-based recipe discovery app. Users swipe right to save dishes, and our AI learns their preferences to recommend better recipes over time. Users can also add their own recipes and auto-generate tags using Claude.

### How we built it

We built the app in SwiftUI. Recipes are loaded from a local recipes.json file and rendered in a Tinder-style interface. Users can upload their own recipes with an image or URL, and Claude AI generates smart tags to improve search and personalization. All saved data and images are stored in the app’s Documents directory.

### Challenges we ran into

Managing file I/O and distinguishing between bundled vs. user-generated content Displaying consistently sized cards regardless of image content or description length Ensuring stable integration with the Claude API Working with SwiftUI layout quirks and image caching

### Accomplishments we're proud of

A clean, swipeable UI that feels intuitive Fully working authoring experience with image import and tag generation Smart recommendation engine that adapts to user swipes Persistent local storage without requiring internet connection

### What we learned

How to use SwiftUI’s layout system with custom components Safe file handling in the iOS sandbox REST API integration with a language model (Claude) Optimizing user flows for both browsing and content creation

### What's next

Cloud storage and user login for syncing across devices Support for dietary filters, cuisine types, and ingredient-based search AI meal planning and grocery list generation Publishing on the App Store Created at UC Berkeley AI Hackathon

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 12 recognized source files, 21 KB.
- Swift (language) — detected in the code

## Codebase structure (from repository index)

### Files (21 of 21)

```
DishDash.xcodeproj/project.pbxproj
DishDash.xcodeproj/project.xcworkspace/contents.xcworkspacedata
DishDash.xcodeproj/project.xcworkspace/xcuserdata/ajaybodla.xcuserdatad/IDEFindNavigatorScopes.plist
DishDash.xcodeproj/xcuserdata/ajaybodla.xcuserdatad/xcschemes/xcschememanagement.plist
DishDash/Assets.xcassets/AppIcon.appiconset/Contents.json
DishDash/Assets.xcassets/Contents.json
DishDash/Assets.xcassets/Logo.imageset/Contents.json
DishDash/AuthorView.swift
DishDash/ContentView.swift
DishDash/DishDashApp.swift
DishDash/Models/Recipes.swift
DishDash/Resources/recipes 2.json
DishDash/Resources/recipes.json
DishDash/SavedRecipesView.swift
DishDash/Services/ClaudeAPI.swift
DishDash/SwipeView.swift
DishDash/ViewModels/RecipeViewModel.swift
DishDash/Views/RecipeCardView.swift
DishDashTests/DishDashTests.swift
DishDashUITests/DishDashUITests.swift
DishDashUITests/DishDashUITestsLaunchTests.swift
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- commiting all code at once
- Initial Commit

## Key source files (fetched from GitHub, selected and truncated for size)

### DishDash/DishDashApp.swift

```swift
import SwiftUI

@main
struct DishDashApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

```

### DishDashTests/DishDashTests.swift

```swift
//
//  DishDashTests.swift
//  DishDashTests
//
//  Created by Ajay Bodla on 6/22/25.
//

import Testing
@testable import DishDash

struct DishDashTests {

    @Test func example() async throws {
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
    }

}

```

### DishDash/ContentView.swift

```swift
import SwiftUI

struct ContentView: View {
    @StateObject var viewModel = RecipeViewModel()

    var body: some View {
        TabView {
            SwipeView(viewModel: viewModel)
                .tabItem {
                    Label("Browse", systemImage: "fork.knife")
                }

            AuthorView()
                .tabItem {
                    Label("Author", systemImage: "square.and.pencil")
                }

            SavedRecipesView(viewModel: viewModel)
                .tabItem {
                    Label("Saved", systemImage: "bookmark.fill")
                }
        }
    }
}

```

### DishDashUITests/DishDashUITestsLaunchTests.swift

```swift
//
//  DishDashUITestsLaunchTests.swift
//  DishDashUITests
//
//  Created by Ajay Bodla on 6/22/25.
//

import XCTest

final class DishDashUITestsLaunchTests: XCTestCase {

    override class var runsForEachTargetApplicationUIConfiguration: Bool {
        true
    }

    override func setUpWithError() throws {
        continueAfterFailure = false
    }

    @MainActor
    func testLaunch() throws {
        let app = XCUIApplication()
        app.launch()

        // Insert steps here to perform after app launch but before taking a screenshot,
        // such as logging into a test account or navigating somewhere in the app

        let attachment = XCTAttachment(screenshot: app.screenshot())
        attachment.name = "Launch Screen"
        attachment.lifetime = .keepAlways
        add(attachment)
    }
}

```

### DishDashUITests/DishDashUITests.swift

```swift
//
//  DishDashUITests.swift
//  DishDashUITests
//
//  Created by Ajay Bodla on 6/22/25.
//

import XCTest

final class DishDashUITests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false

        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    @MainActor
    func testExample() throws {
        // UI tests must launch the application that they test.
        let app = XCUIApplication()
        app.launch()

        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }

    @MainActor
    func testLaunchPerformance() throws {
        // This measures how long it takes to launch your application.
        measure(metrics: [XCTApplicationLaunchMetric()]) {
            XCUIApplication().launch()
        }
    }
}

```

### DishDash/SavedRecipesView.swift

```swift
import SwiftUI

struct SavedRecipesView: View {
    @ObservedObject var viewModel: RecipeViewModel

    var body: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 20) {
                    ForEach(viewModel.savedRecipes, id: \.title) { recipe in
                        RecipeCard(recipe: recipe)
                    }
                }
                .padding()
            }
            .navigationTitle("Saved Recipes")
        }
    }
}

struct RecipeCard: View {
    let recipe: Recipe

    var body: some View {
        VStack(alignment: .leading) {
            let imagePath = FileManager.default
                .urls(for: .documentDirectory, in: .userDomainMask)[0]
                .appendingPathComponent(recipe.image)

            if let uiImage = UIImage(contentsOfFile: imagePath.path) {
                Image(uiImage: uiImage)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(height: 200)
                    .clipped()
            } else {
                Rectangle()
                    .fill(Color.gray.opacity(0.3))
                    .frame(height: 200)
                    .overlay(Text("❌ Image not found"))
            }

            Text(recipe.title)
                .font(.headline)
                .padding(.top, 8)

            Text(recipe.description)
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
        .padding()
        .background(Color.white)
        .cornerRadius(12)
        .shadow(radius: 5)
    }
}

```

### DishDash/SwipeView.swift

```swift
import SwiftUI

struct SwipeView: View {
    @ObservedObject var viewModel: RecipeViewModel
    @State private var topIndex = 0
    @State private var dragOffset: CGSize = .zero

    var body: some View {
        NavigationView {
            ZStack {
                Color.white.ignoresSafeArea()

                if topIndex < viewModel.recipes.count {
                    let recipe = viewModel.recipes[topIndex]

                    RecipeCardView(recipe: recipe, onSwipedAway: {
                        animateOutAndAdvance(toRight: true)
                    })
                    .frame(width: UIScreen.main.bounds.width * 0.9,
                           height: UIScreen.main.bounds.height * 0.75)
                    .offset(dragOffset)
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                dragOffset = value.translation
                            }
                            .onEnded { value in
                                if value.translation.width > 100 {
                                    viewModel.swipeRight(on: recipe)
                                    animateOutAndAdvance(toRight: true)
                                } else if value.translation.width < -100 {
                                    viewModel.swipeLeft(on: recipe)
                                    animateOutAndAdvance(toRight: false)
                                } else {
                                    withAnimation {
                                        dragOffset = .zero
                                    }
                                }
                            }
                    )
                    .animation(.spring(), value: dragOffset)

                } else {
                    Text("No more recipes!")
                        .font(.title)
                        .foregroundColor(.gray)
                        .onAppear {
                            viewModel.refillSwipeStack()
                            topIndex = 0
                        }
                }
            }
            .onChange(of: viewModel.recipes) { _ in
                topIndex = 0
            }
            .navigationBarHidden(true)
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }

    private func animateOutAndAdvance(toRight: Bool) {
        withAnimation {
            dragOffset = CGSize(width: toRight ? 1000 : -1000, height: 0)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            dragOffset = .zero
            topIndex += 1
        }
    }
}

```

### DishDash/AuthorView.swift

```swift
import SwiftUI
import PhotosUI

struct AuthorView: View {
    @State private var title: String = ""
    @State private var imageURL: String = ""
    @State private var uiImage: UIImage? = nil
    @State private var description: String = ""
    @State private var tags: [String] = []
    @State private var isGenerating: Bool = false
    @State private var photoPickerPresented: Bool = false

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 20) {
                Text("Add New Recipe")
                    .font(.title)
                    .bold()

                TextField("Title", text: $title)
                    .textFieldStyle(RoundedBorderTextFieldStyle())

                TextField("Or paste image URL", text: $imageURL)
                    .textFieldStyle(RoundedBorderTextFieldStyle())

                Button("Pick Photo from Library") {
                    photoPickerPresented = true
                }

                if let uiImage = uiImage {
                    Image(uiImage: uiImage)
                        .resizable()
                        .scaledToFit()
                        .frame(height: 200)
                }

                Text("Description")
                    .font(.headline)
                TextEditor(text: $description)
                    .frame(height: 150)
                    .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray))

                Button(isGenerating ? "Generating..." : "Generate Tags & Save") {
                    isGenerating = true
                    ClaudeAPI.generateTags(from: "\(title) \(description)") { generated in
                        self.tags = generated
                        let newRecipe = Recipe(title: title, image: imageURL, description: description, tags: tags)
                        saveRecipeToJSON(newRecipe)
                        resetForm()
                        isGenerating = false
                    }
                }
                .disabled(isGenerating || title.isEmpty || description.isEmpty)
                .padding()
                .background(Color.blue)
                .foregroundColor(.white)
                .cornerRadius(8)

                if !tags.isEmpty {
                    Text("Suggested Tags:")
                        .font(.headline)
                    VStack(alignment: .leading) {
                        ForEach(tags, id: \ .self) { tag in
                            Text("- \(tag)")
                        }
                    }
                }
            }
            .padding()
            .sheet(isPresented: $photoPickerPresented) {
                PhotoPicker(image: $uiImage)
            }
        }
    }

    private func saveRecipeToJSON(_ recipeBase: Recipe) {
        let fileManager = FileManager.default

        guard let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            print("❌ Could not access documents directory.")
            return
        }

        let imagesFolder = documents.appendingPathComponent("images")
        let recipesFileURL = documents.appendingPathComponent("recipes.json")

        // 1. Ensure images folder exists
        if !fileManager.fileExists(atPath: imagesFolder.path) {
            do {
                try fileManager.createDirectory(at: imagesFolder, withIntermediateDirectories: true)
                print("📁 Created images folder")
            } catch {
                print("❌ Failed to create images folder: \(error)")
                return
            }
        }

        // 2. Save image to images/ if one was picked
        var imagePathString = recipeBase.image // fallback to pasted URL
        if let uiImage = uiImage {
            let safeFileName = title
                .lowercased()
                .replacingOccurrences(of: " ", with: "_")
                .filter { $0.isLetter || $0.isNumber || $0 == "_" } + ".png"

            let imageURL = imagesFolder.appendingPathComponent(safeFileName)
            if let imageData = uiImage.pngData() {
                do {
                    try imageData.write(to: imageURL)
                    imagePathString = "images/\(safeFileName)"
                    print("🖼️ Saved image to \(imagePathString)")
                } catch {
                    print("❌ Failed to save image: \(error)")
                }
            }
        }

        // 3. Copy bundled JSON if needed
        if !fileManager.fileExists(atPath: recipesFileURL.path) {
            if let bundledURL = Bundle.main.url(forResource: "recipes", withExtension: "json") {
                do {
                    try fileManager.copyItem(at: bundledURL, to: recipesFileURL)
                    print("📄 Copied bundled recipes.json to Documents.")
                } catch {
                    print("❌ Failed to copy bundled file: \(error)")
                    return
                }
            } else {
                print("❌ Could not find bundled recipes.json")
                return
            }
        }

        // 4. Load, append, and save
        var allRecipes: [Recipe] = []
        if let data = try? Data(contentsOf: recipesFileURL),
           let existing = try? JSONDecoder().decode([Recipe].self, from: data) {
            allRecipes = existing
        }

        let finalRecipe = Recipe(title: title, image: imagePathString, description: description, tags: tags)
        allRecipes.append(finalRecipe)

        do {
            let updatedData = try JSONEncoder().encode(allRecipes)
            try updatedData.write(to: recipesFileURL)
            print("✅ Saved new recipe to: \(recipesFileURL)")
        } catch {
            print("❌ Failed to write recipes.json: \(error)")
        }
    }

    private func resetForm() {
        title = ""
        imageURL = ""
        description = ""
        tags = []
        uiImage = nil
    }
}

struct PhotoPicker: UIViewControllerRepresentable {
    @Binding var image: UIImage?

   
[truncated — 1189 more characters]
```

### DishDash/Models/Recipes.swift

```swift
import Foundation

struct Recipe: Codable, Equatable, Hashable {
    let title: String
    let image: String
    let description: String
    let tags: [String]
}

//
//  Recipes.swift
//  DishDash
//
//  Created by Ajay Bodla on 6/22/25.
//


```

### DishDash/Views/RecipeCardView.swift

```swift
import SwiftUI

struct RecipeCardView: View {
    let recipe: Recipe
    let onSwipedAway: () -> Void

    var body: some View {
        VStack(spacing: 0) {
            // Load image from documents directory
            let imagePath = FileManager.default
                .urls(for: .documentDirectory, in: .userDomainMask)[0]
                .appendingPathComponent(recipe.image)

            if let uiImage = UIImage(contentsOfFile: imagePath.path) {
                Image(uiImage: uiImage)
                    .resizable()
                    .scaledToFill()
                    .frame(height: 300)
                    .frame(maxWidth: .infinity)
                    .clipped()
            } else {
                Text("❌ Image not found")
                    .frame(height: 300)
                    .frame(maxWidth: .infinity)
                    .background(Color.gray.opacity(0.2))
            }

            ScrollView {
                VStack(alignment: .leading, spacing: 12) {
                    Text(recipe.title)
                        .font(.title2)
                        .bold()
                        .padding(.top)

                    Text(recipe.description)
                        .font(.body)
                        .multilineTextAlignment(.leading)
                        .fixedSize(horizontal: false, vertical: true)
                }
                .padding(.horizontal)
                .padding(.bottom)
            }
        }
        .background(Color(.systemGray6))
        .cornerRadius(16)
        .shadow(radius: 8)
        .padding()
    }
}

```

[2 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]