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).