An offline-first multi-chat iOS application built entirely with SwiftUI and SwiftData. Jarvis manages multiple concurrent conversations between a user and a simulated AI agent, persisting everything locally with zero network dependency.
- Requirements
- Setup
- Features
- Architecture
- Project Structure
- Code Flow
- Data Layer
- Service Layer
- Design System
- Assumptions
| Dependency | Version |
|---|---|
| Xcode | 16.2+ |
| iOS Deployment Target | 17.0+ |
| Swift | 5.9+ |
| External Libraries | None |
The project uses only Apple system frameworks: SwiftUI, SwiftData, PhotosUI, and UIKit (for camera).
- Open
Jarvis.xcodeprojin Xcode. - Select a simulator or physical device (iOS 17.0+).
- Build and run (
Cmd + R).
No dependency installation (SPM, CocoaPods, Carthage) is needed.
| Feature | Description |
|---|---|
| Multi-Chat | Create and manage multiple concurrent conversations |
| Text Messaging | Send and receive text messages with styled bubbles |
| Image Messaging | Attach images from Photo Library or Camera |
| AI Agent Simulation | Simulated agent replies with debounced, randomized cadence |
| Swipe-to-Delete | Swipe chats to delete with a confirmation dialog |
| Editable Titles | Auto-generated from the first message; tap to rename |
| Full-Screen Images | Tap images to view full-screen with pinch-to-zoom and pan |
| Smart Timestamps | "Just now", "2m ago", "Yesterday", "Dec 20", "Dec 20, 2024" |
| Seed Data | 3 pre-loaded chats with 10 messages each for demo purposes |
| Offline-First | All data persisted locally via SwiftData; no network required |
The project follows MVVM (Model-View-ViewModel) with a Service Layer:
┌─────────────────────────────────────────────────┐
│ View │
│ (SwiftUI Views — screens & subviews) │
│ Observes ViewModel via @Observable / @Bindable │
└──────────────────────┬──────────────────────────┘
│ user actions / state
▼
┌─────────────────────────────────────────────────┐
│ ViewModel │
│ (@Observable, @MainActor) │
│ Business logic, state management │
│ Delegates persistence to ModelContext │
│ Delegates AI replies to AgentService │
└──────────┬──────────────────┬───────────────────┘
│ │
▼ ▼
┌────────────────┐ ┌─────────────────────────────┐
│ SwiftData │ │ Services │
│ ModelContext │ │ AgentService │
│ (persistence) │ │ ImageCacheService │
└────────────────┘ └─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Models │
│ Chat (@Model) ──< Message (@Model) │
│ SwiftData persistent models │
└─────────────────────────────────────────────────┘
- Separation of concerns: Views are purely declarative UI; ViewModels own all business logic and state.
- Testability: ViewModels can be unit-tested without instantiating views.
- SwiftUI alignment:
@Observableprovides fine-grained reactivity, and@Bindableenables two-way bindings between Views and ViewModels.
| Decision | Rationale |
|---|---|
@Observable over ObservableObject |
Fine-grained reactivity; only properties that change trigger view updates |
@MainActor on ViewModels & AgentService |
Guarantees all UI-related state mutations happen on the main thread |
| SwiftData over Core Data | Modern, declarative persistence with native SwiftUI integration |
| Actor for ImageCacheService | Thread-safe image operations without manual locking |
| No DI container | Project scope is small; SwiftUI's @Environment handles ModelContext injection |
| No external dependencies | Reduces maintenance burden; Apple frameworks cover all requirements |
Jarvis/
├── JarvisApp.swift # App entry point, ModelContainer setup, seed data
│
├── Models/
│ ├── Chat.swift # SwiftData model — chat entity
│ └── Message.swift # SwiftData model — message entity + enums
│
├── Modules/
│ ├── ChatList/ # Chat list feature module
│ │ ├── View/
│ │ │ ├── Screen/
│ │ │ │ └── ChatListView.swift # Main chat list screen
│ │ │ └── SubViews/
│ │ │ ├── ChatRowView.swift # Individual chat row
│ │ │ └── MessageInputView.swift # Text input + image attachment bar
│ │ └── ViewModel/
│ │ └── ChatListViewModel.swift # Create & delete chat logic
│ │
│ └── ChatDetails/ # Chat detail feature module
│ ├── View/
│ │ ├── Screen/
│ │ │ └── ChatDetailView.swift # Detail screen entry point
│ │ └── SubViews/
│ │ ├── ChatDetailContent.swift # Message list + input composition
│ │ ├── EmptyStateView.swift # Empty conversation placeholder
│ │ ├── FullScreenImageView.swift # Pinch-to-zoom image viewer
│ │ ├── ImageMessageView.swift # Image bubble (bundle + async)
│ │ └── MessageBubbleView.swift # Text/image message bubble
│ └── ViewModel/
│ └── ChatDetailViewModel.swift # Send messages, agent replies, title editing
│
├── Services/
│ ├── AgentService.swift # AI agent reply simulation with debouncing
│ └── ImageCacheService.swift # Image saving & thumbnail generation
│
├── Utilities/
│ ├── CameraView.swift # UIImagePickerController wrapper
│ ├── Constants.swift # Agent response templates & config
│ ├── DesignTokens.swift # Spacing, radius, size constants
│ └── TimestampFormatter.swift # Smart relative date formatting
│
├── Data/
│ └── SeedData.swift # Initial demo data (3 chats, 30 messages)
│
└── Assets.xcassets/ # App icon + placeholder images
├── AppIcon.appiconset/
└── placeholder_*.imageset/ # 5 placeholder images for agent replies
Each feature module follows a consistent internal structure:
Module/
├── View/
│ ├── Screen/ # Top-level screen view (one per module)
│ └── SubViews/ # Reusable child views scoped to this module
└── ViewModel/ # @Observable ViewModel for the module
JarvisApp.swift
│
├── Creates ModelContainer with Chat & Message schema
├── Injects container via .modelContainer() modifier
└── On appear:
└── SeedData.seedIfNeeded(context:)
└── If DB is empty → inserts 3 chats + 30 messages
ChatListView
│
├── @Query fetches all chats sorted by lastMessageTimestamp (descending)
├── Displays ChatRowView for each chat
│ └── Shows avatar initial, title, last message, smart timestamp
│
├── Tap "+" button
│ └── ChatListViewModel.createChat()
│ ├── Creates new Chat with default title "New Chat"
│ ├── Inserts into ModelContext and saves
│ └── Navigates to ChatDetailView
│
├── Tap a chat row
│ └── NavigationDestination → ChatDetailView(chat:)
│
└── Swipe to delete
└── Confirmation alert → ChatListViewModel.deleteChat()
└── ModelContext.delete() with cascade (removes all messages)
ChatDetailView
│
└── On appear → creates ChatDetailViewModel(chat:, context:)
│
└── ChatDetailContent (main UI)
│
├── ScrollView with LazyVStack of MessageBubbleView
│ ├── Text messages → styled bubble (blue for user, gray for agent)
│ └── Image messages → ImageMessageView (bundle or async)
│ └── Tap → FullScreenImageView (pinch-to-zoom, pan, double-tap)
│
├── MessageInputView (bottom bar)
│ ├── Text field with send button
│ ├── Attachment button → Photo Library or Camera
│ └── On send:
│ └── ViewModel.sendTextMessage() or sendImageMessage(data:)
│
└── Edit title (toolbar pencil icon)
└── Alert with TextField → ViewModel.saveTitle()
User types message and taps Send
│
└── ChatDetailViewModel.sendTextMessage()
│
├── Creates Message(sender: .user, type: .text)
├── Inserts into ModelContext
├── Updates chat title (if first message), lastMessage, timestamps
├── Saves context
├── Clears input field
├── Refreshes message list
│
└── AgentService.onUserMessageSent(chat:, context:)
│
├── Cancels any previously pending reply task
├── Increments user message counter for this chat
├── Checks against random threshold (2–4 messages)
│
├── If threshold NOT reached → returns (waits for more messages)
│
└── If threshold reached:
├── Resets counter, picks new random threshold
└── Schedules delayed Task (1–2 second random delay)
│
└── generateReply()
├── 70% chance → text reply (random from templates)
├── 30% chance → image reply (placeholder asset)
├── Inserts Message(sender: .agent) into context
├── Updates chat's lastMessage and timestamps
└── Saves context
User taps attachment button
│
├── Photo Library → PhotosPicker selection
│ └── Loads Data from PhotosPickerItem
│
└── Camera → CameraView (UIImagePickerController)
└── Captures JPEG data (0.8 compression)
Both paths → ChatDetailViewModel.sendImageMessage(data:)
│
├── ImageCacheService.saveImageLocally(data:, filename:)
│ └── Writes to Documents/ChatImages/<uuid>.jpg
│
├── ImageCacheService.generateThumbnail(from:)
│ └── Resizes to max 100pt dimension, JPEG at 0.6 quality
│ └── Saves as thumb_<uuid>.jpg
│
└── Creates Message(type: .file, filePath:, fileSize:, thumbnailPath:)
└── Inserts and saves via ModelContext
Chat (@Model)
| Field | Type | Description |
|---|---|---|
id |
String (unique) |
UUID string identifier |
title |
String |
Chat title (auto-generated or user-edited) |
lastMessage |
String |
Preview text for the chat list |
lastMessageTimestamp |
Int64 |
Millisecond Unix timestamp of last message |
createdAt |
Int64 |
Millisecond Unix timestamp of creation |
updatedAt |
Int64 |
Millisecond Unix timestamp of last update |
messages |
[Message] |
Inverse relationship with cascade delete |
Message (@Model)
| Field | Type | Description |
|---|---|---|
id |
String (unique) |
UUID string identifier |
chat |
Chat? |
Parent chat reference |
message |
String |
Text content (empty for image-only messages) |
type |
MessageType |
.text or .file |
filePath |
String? |
Local file path or bundle asset name |
fileSize |
Int? |
File size in bytes |
thumbnailPath |
String? |
Thumbnail file path or bundle asset name |
sender |
SenderType |
.user or .agent |
timestamp |
Int64 |
Millisecond Unix timestamp |
Enums: SenderType (.user, .agent) and MessageType (.text, .file) — both String-backed and Codable.
Chat ──< Message (one-to-many, cascade delete)
Deleting a Chat automatically deletes all associated Message records.
All timestamps are stored as Int64 millisecond Unix timestamps. Computed Date properties on both models handle the conversion:
var date: Date {
Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
}Simulates AI agent responses with a natural conversational cadence.
- Debouncing: Cancels any pending reply if the user sends another message quickly.
- Random threshold: Agent waits for 2–4 user messages before replying (randomized per cycle).
- Typing delay: 1–2 second random delay before the reply appears.
- Response distribution: 70% text responses (from 10 templates), 30% image responses (from 5 placeholder assets).
- Per-chat state: Tracks message counts and thresholds independently per chat.
An actor-based service (singleton) for thread-safe image operations.
- Save locally: Writes image data to
Documents/ChatImages/directory. - Thumbnail generation: Resizes images to a max 100pt dimension with 0.6 JPEG compression.
The project uses a centralized design token system in DesignTokens.swift:
| Token Type | Examples | Usage |
|---|---|---|
Spacing |
_4, _8, _12, _16 |
Padding, gaps, margins |
Radius |
_18, _20 |
Corner radii for bubbles and inputs |
IconSize |
_56 |
SF Symbol sizing |
ComponentSize |
_48, _100, _180, _240 |
Fixed component dimensions |
Timestamp formatting is handled by TimestampFormatter.smartFormat(milliseconds:):
| Condition | Output |
|---|---|
| < 60 seconds ago | "Just now" |
| < 60 minutes ago | "2m ago" |
| Today | "3:45 PM" |
| Yesterday | "Yesterday" |
| Same year | "Dec 20" |
| Different year | "Dec 20, 2024" |
- All timestamps use millisecond Unix format to match the PRD JSON schema.
- Agent responses use bundled placeholder images (not network images) to stay fully offline.
- Camera permission is requested at first use via the standard iOS permission dialog.
- Chat title defaults to "New Chat" and is auto-updated to the first 30 characters of the first user message.
- The
filenested object from the PRD schema is flattened into theMessagemodel asfilePath,fileSize, andthumbnailPathbecause SwiftData does not support nested value types directly. - The app seeds 3 demo chats with 30 total messages on first launch only (skips if data already exists).