Skip to content

Commit 9100616

Browse files
Refactoring: shared code is now in a dedicated, shared package
1 parent 4463853 commit 9100616

35 files changed

+839
-992
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ DerivedData/
66
*.xcuserstate
77
xcuserdata/
88

9+
# Swift Package Manager
10+
.build/
11+
.swiftpm/
12+
913
# Build artifacts
1014
macos/build/
1115
ios/build/
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# Camera2URL Architecture and Code Design
2+
3+
This document provides an overview of the Camera2URL codebase architecture, helping developers understand the project structure, design patterns, and code organization.
4+
5+
## Project Overview
6+
7+
Camera2URL is a native camera application for iOS and macOS that captures photos and uploads them to a configurable HTTP endpoint. The app supports:
8+
9+
- Manual photo capture with immediate upload
10+
- Automatic timed capture at configurable intervals
11+
- Multiple camera selection (front/back on iOS, external/continuity on macOS)
12+
- Upload history tracking with request/response details
13+
14+
## Repository Structure
15+
16+
```
17+
camera2url/
18+
├── shared/ # Shared Swift Package (cross-platform code)
19+
├── ios/ # iOS application
20+
├── macos/ # macOS application
21+
└── docs/ # Documentation
22+
```
23+
24+
### Shared Package (`shared/`)
25+
26+
A Swift Package containing all platform-agnostic business logic:
27+
28+
```
29+
shared/
30+
├── Package.swift
31+
├── Sources/Camera2URLShared/
32+
│ ├── Models/ # Data models (RequestConfig, TimerConfig, etc.)
33+
│ ├── Protocols/ # Platform abstraction protocols
34+
│ ├── Services/ # Network services (UploadService)
35+
│ ├── Stores/ # Persistence (ConfigStore)
36+
│ ├── ViewModels/ # Main app state (AppViewModel)
37+
│ └── PlatformImage.swift # Cross-platform image typealias
38+
└── Tests/
39+
```
40+
41+
### Platform Apps (`ios/` and `macos/`)
42+
43+
Each platform app contains only platform-specific code:
44+
45+
```
46+
{platform}/
47+
├── camera2url.xcodeproj/
48+
├── camera2url/
49+
│ ├── Services/ # Platform-specific CameraService
50+
│ ├── Views/ # SwiftUI views
51+
│ ├── ContentView.swift
52+
│ └── camera2urlApp.swift
53+
└── camera2urlTests/
54+
```
55+
56+
## Architecture Overview
57+
58+
### Layer Diagram
59+
60+
```
61+
┌─────────────────────────────────────────────────────────────┐
62+
│ SwiftUI Views │
63+
│ (Platform-specific UI) │
64+
├─────────────────────────────────────────────────────────────┤
65+
│ AppViewModel │
66+
│ (Shared state management) │
67+
├───────────────────────┬─────────────────────────────────────┤
68+
│ CameraService │ UploadService │
69+
│ (Platform-specific) │ (Shared) │
70+
├───────────────────────┴─────────────────────────────────────┤
71+
│ Models & Stores │
72+
│ (Shared) │
73+
└─────────────────────────────────────────────────────────────┘
74+
```
75+
76+
### Key Design Decisions
77+
78+
1. **Protocol-based abstraction for platform code**: The `CameraServiceProtocol` defines the camera interface, allowing the shared `AppViewModel` to work with platform-specific camera implementations.
79+
80+
2. **Generic ViewModel**: `AppViewModel` is generic over the camera service type, enabling type-safe platform-specific camera device handling while sharing all business logic.
81+
82+
3. **Platform image typealias**: `PlatformImage` resolves to `UIImage` on iOS and `NSImage` on macOS, enabling the ViewModel to work with images without platform conditionals.
83+
84+
## Code Sharing Strategy
85+
86+
### What's Shared (~60% of code)
87+
88+
| Component | Description |
89+
|-----------|-------------|
90+
| **Models** | All data structures (RequestConfig, TimerConfig, UploadRecord, etc.) |
91+
| **UploadService** | HTTP multipart upload logic |
92+
| **ConfigStore** | UserDefaults-based configuration persistence |
93+
| **AppViewModel** | All app state, timer logic, upload orchestration |
94+
| **Protocols** | CameraServiceProtocol, CameraDeviceInfo |
95+
96+
### What's Platform-Specific
97+
98+
| Component | Why Platform-Specific |
99+
|-----------|----------------------|
100+
| **CameraService** | Different device types (iOS: front/back cameras; macOS: external/continuity) |
101+
| **CameraPreviewView** | UIViewRepresentable vs NSViewRepresentable |
102+
| **Config UI** | iOS uses NavigationStack/Form; macOS uses dialog-style layout |
103+
| **ContentView** | Different layouts, safe areas, window management |
104+
| **UploadHistoryView** | iOS uses push navigation; macOS uses split view |
105+
106+
## Key Abstractions
107+
108+
### CameraServiceProtocol
109+
110+
Defines the contract for camera operations:
111+
112+
```swift
113+
protocol CameraServiceProtocol {
114+
associatedtype CameraDeviceType: CameraDeviceInfo
115+
116+
var session: AVCaptureSession? { get }
117+
var availableCameras: [CameraDeviceType] { get }
118+
var currentCamera: CameraDeviceType? { get }
119+
var delegate: CameraServiceDelegate? { get set }
120+
121+
func prepareIfNeeded() async throws
122+
func start()
123+
func stop()
124+
func capturePhoto()
125+
func switchCamera(to camera: CameraDeviceType) throws
126+
}
127+
```
128+
129+
### CameraServiceDelegate
130+
131+
Callback interface for camera events:
132+
133+
```swift
134+
protocol CameraServiceDelegate {
135+
func cameraServiceDidCapturePhoto(_ data: Data)
136+
func cameraServiceDidEncounterError(_ error: Error)
137+
func cameraServiceDidUpdateAvailableCameras()
138+
}
139+
```
140+
141+
### AppViewModel
142+
143+
The central state manager, generic over the camera service:
144+
145+
```swift
146+
class AppViewModel<CameraService: CameraServiceProtocol>: ObservableObject {
147+
// Published state for UI binding
148+
@Published var uploadStatus: UploadStatus
149+
@Published var isTimerActive: Bool
150+
@Published var capturedPhoto: CapturedPhoto?
151+
// ... etc
152+
}
153+
```
154+
155+
## Data Flow
156+
157+
### Manual Capture Flow
158+
159+
```
160+
User taps "Take Photo"
161+
→ AppViewModel.takeAndSendPhoto()
162+
→ CameraService.capturePhoto()
163+
→ [AVFoundation captures image]
164+
→ CameraServiceDelegate.cameraServiceDidCapturePhoto(data)
165+
→ AppViewModel creates PlatformImage
166+
→ UploadService.upload(photoData:using:)
167+
→ [HTTP request]
168+
→ UploadHistory.addSuccess/addFailure()
169+
→ UI updates via @Published
170+
```
171+
172+
### Timer Capture Flow
173+
174+
```
175+
User starts timer
176+
→ AppViewModel.startTimer()
177+
→ Task loop with configurable interval
178+
→ captureTimerPhoto() [immediate + repeated]
179+
→ CameraService.capturePhoto()
180+
→ Upload runs in background Task
181+
→ UploadHistory tracks results
182+
```
183+
184+
## State Management
185+
186+
The app uses SwiftUI's native state management:
187+
188+
- **@StateObject**: App-level ViewModel instance (in App struct)
189+
- **@ObservedObject**: ViewModel reference in views
190+
- **@Published**: Observable state properties in ViewModel
191+
- **@State**: View-local UI state (sheets, selections)
192+
193+
### Key State Properties
194+
195+
| Property | Purpose |
196+
|----------|---------|
197+
| `showingConfigSheet` | Controls config sheet/dialog presentation |
198+
| `currentConfig` | Active upload target configuration |
199+
| `uploadStatus` | Current upload state (idle/capturing/uploading/success/failure) |
200+
| `isCameraReady` | Camera session prepared and running |
201+
| `isTimerActive` | Auto-capture timer running |
202+
| `uploadHistory` | Rolling history of upload attempts |
203+
204+
## Persistence
205+
206+
### ConfigStore
207+
208+
Persists request configurations to UserDefaults as JSON. Implements:
209+
210+
- **Upsert**: Add new or move existing config to front
211+
- **Deduplication**: Matches by verb + URL + note
212+
- **Auto-persist**: Saves on every mutation
213+
214+
### Upload History
215+
216+
In-memory rolling buffer (max 100 records) tracking:
217+
218+
- Success/failure status
219+
- Request/response summaries
220+
- Timestamp and capture number
221+
- Manual vs timer capture flag
222+
223+
## Error Handling
224+
225+
Errors are modeled as structured types:
226+
227+
- **CameraError**: Permission denied, configuration failed, no camera found, capture failed
228+
- **UploadErrorReport**: Network errors, HTTP errors, includes request/response summaries for debugging
229+
230+
Errors flow through the delegate pattern (camera) or are thrown and caught (upload), then surfaced to the UI via `uploadStatus`.
231+
232+
## Testing Strategy
233+
234+
### Shared Package Tests
235+
236+
Located in `shared/Tests/`, these test:
237+
238+
- ConfigStore persistence and deduplication
239+
- TimerConfig value clamping
240+
- UploadService request building and error handling
241+
- UploadHistory record management
242+
243+
### Platform Tests
244+
245+
Each platform has a `camera2urlTests` target that imports the shared package and can test platform-specific behavior.
246+
247+
### Running Tests
248+
249+
```bash
250+
# Shared package tests
251+
cd shared && swift test
252+
253+
# iOS tests
254+
cd ios && make test
255+
256+
# macOS tests
257+
cd macos && make test
258+
```
259+
260+
## Build System
261+
262+
Each platform uses:
263+
264+
- **Xcode project** with the shared package as a local dependency
265+
- **Makefile** for common operations (`make build`, `make test`, `make ui-test`)
266+
- **File system synchronized groups** for automatic source file inclusion
267+
268+
### Adding the Shared Package
269+
270+
Both Xcode projects reference the shared package via local path (`../shared`). The package is automatically resolved and linked.
271+
272+
## Future Considerations
273+
274+
When extending the codebase:
275+
276+
1. **New shared logic**: Add to the shared package under appropriate directory
277+
2. **New platform-specific feature**: Implement in each platform app
278+
3. **New camera capability**: Extend CameraServiceProtocol and both implementations
279+
4. **New model**: Add to shared/Models with public access modifiers
280+
5. **New UI**: Implement separately per platform, sharing ViewModel interactions

ios/camera2url.xcodeproj/project.pbxproj

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
objectVersion = 77;
77
objects = {
88

9+
/* Begin PBXBuildFile section */
10+
A10000002F00000000000200 /* Camera2URLShared in Frameworks */ = {
11+
isa = PBXBuildFile;
12+
productRef = A10000002F00000000000201 /* Camera2URLShared */;
13+
};
14+
A10000002F00000000000202 /* Camera2URLShared in Frameworks */ = {
15+
isa = PBXBuildFile;
16+
productRef = A10000002F00000000000203 /* Camera2URLShared */;
17+
};
18+
/* End PBXBuildFile section */
19+
920
/* Begin PBXContainerItemProxy section */
1021
A10000002F00000000000100 /* PBXContainerItemProxy */ = {
1122
isa = PBXContainerItemProxy;
@@ -52,13 +63,15 @@
5263
isa = PBXFrameworksBuildPhase;
5364
buildActionMask = 2147483647;
5465
files = (
66+
A10000002F00000000000200 /* Camera2URLShared in Frameworks */,
5567
);
5668
runOnlyForDeploymentPostprocessing = 0;
5769
};
5870
A10000002F00000000000021 /* Frameworks */ = {
5971
isa = PBXFrameworksBuildPhase;
6072
buildActionMask = 2147483647;
6173
files = (
74+
A10000002F00000000000202 /* Camera2URLShared in Frameworks */,
6275
);
6376
runOnlyForDeploymentPostprocessing = 0;
6477
};
@@ -112,6 +125,7 @@
112125
);
113126
name = camera2url;
114127
packageProductDependencies = (
128+
A10000002F00000000000201 /* Camera2URLShared */,
115129
);
116130
productName = camera2url;
117131
productReference = A10000002F00000000000001 /* Camera2URL.app */;
@@ -135,6 +149,7 @@
135149
);
136150
name = camera2urlTests;
137151
packageProductDependencies = (
152+
A10000002F00000000000203 /* Camera2URLShared */,
138153
);
139154
productName = camera2urlTests;
140155
productReference = A10000002F00000000000002 /* camera2urlTests.xctest */;
@@ -195,6 +210,9 @@
195210
);
196211
mainGroup = A10000002F00000000000030;
197212
minimizedProjectReferenceProxies = 1;
213+
packageReferences = (
214+
A10000002F00000000000210 /* XCLocalSwiftPackageReference "Camera2URLShared" */,
215+
);
198216
preferredProjectObjectVersion = 77;
199217
productRefGroup = A10000002F00000000000031 /* Products */;
200218
projectDirPath = "";
@@ -595,6 +613,24 @@
595613
defaultConfigurationName = Release;
596614
};
597615
/* End XCConfigurationList section */
616+
617+
/* Begin XCLocalSwiftPackageReference section */
618+
A10000002F00000000000210 /* XCLocalSwiftPackageReference "shared" */ = {
619+
isa = XCLocalSwiftPackageReference;
620+
relativePath = ../shared;
621+
};
622+
/* End XCLocalSwiftPackageReference section */
623+
624+
/* Begin XCSwiftPackageProductDependency section */
625+
A10000002F00000000000201 /* Camera2URLShared */ = {
626+
isa = XCSwiftPackageProductDependency;
627+
productName = Camera2URLShared;
628+
};
629+
A10000002F00000000000203 /* Camera2URLShared */ = {
630+
isa = XCSwiftPackageProductDependency;
631+
productName = Camera2URLShared;
632+
};
633+
/* End XCSwiftPackageProductDependency section */
598634
};
599635
rootObject = A10000002F00000000000050 /* Project object */;
600636
}

0 commit comments

Comments
 (0)