Yaklaşım: Clean Architecture (Simplified 3-Layer) + MVVM + Protocol-Oriented + Dependency Injection
Hedef: Scalable, testable, maintainable iOS app architecture
┌─────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ (SwiftUI Views + ViewModels) │
│ • User Interface │
│ • User Interaction │
│ • State Management (@Observable) │
└─────────────────────────────────────────────────┘
↓ ↑
(Dependency: Domain protocols)
↓ ↑
┌─────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ (Business Logic & Protocols) │
│ • Entities (SwiftData Models) │
│ • Repository Protocols │
│ • Business Rules & Validation │
└─────────────────────────────────────────────────┘
↓ ↑
(Implementation: Concrete repositories)
↓ ↑
┌─────────────────────────────────────────────────┐
│ DATA LAYER │
│ (Data Management) │
│ • Concrete Repositories │
│ • SwiftData Persistence │
│ • Libraries (Exercise/Food) │
└─────────────────────────────────────────────────┘
Kural: Presentation → Domain ← Data
Neden?
Sorumluluklar:
Bileşenler:
Klasör Yapısı:
Features/
├── Home/
│ ├── Views/
│ │ ├── HomeView.swift
│ │ └── Components/
│ │ ├── QuickActionButton.swift
│ │ └── RecentWorkoutRow.swift
│ └── ViewModels/
│ └── HomeViewModel.swift
│
├── Workouts/
│ ├── Views/
│ │ └── WorkoutsView.swift # Main view with segmented control
│ ├── History/
│ │ ├── Views/
│ │ │ ├── WorkoutsOverviewView.swift # Overview section
│ │ │ └── Components/
│ │ └── ViewModels/
│ │ └── WorkoutsViewModel.swift
│ ├── Templates/
│ │ ├── Views/
│ │ │ ├── WorkoutTemplatesView.swift # Templates section
│ │ │ └── Components/
│ │ └── ViewModels/
│ │ └── TemplatesViewModel.swift
│ └── Programs/
│ ├── Views/
│ │ ├── WorkoutProgramsView.swift # Programs section
│ │ └── Components/
│ │ └── ActiveProgramCard.swift
│ └── ViewModels/
│ └── ProgramProgressTimelineViewModel.swift
│
├── Profile/ # v1.2: New Profile tab
│ ├── Views/
│ │ ├── ProfileView.swift # Main profile tab
│ │ └── Components/
│ │ ├── ProfileNameEditorSheet.swift
│ │ ├── ProfileHeightEditorSheet.swift
│ │ ├── ProfileGenderEditorSheet.swift
│ │ ├── ProfileDateOfBirthEditorSheet.swift
│ │ ├── ProfileActivityLevelEditorSheet.swift
│ │ ├── ProfileBodyweightEntrySheet.swift
│ │ └── ProfileBodyweightHistorySheet.swift
│ └── ViewModels/
│ └── ProfileViewModel.swift
│
├── AICoach/ # v1.3: AI Fitness Coach
│ ├── Views/
│ │ ├── AICoachView.swift # Main AI Coach tab
│ │ └── Components/
│ │ ├── ChatMessageBubble.swift # Message bubble UI
│ │ ├── ChatInputField.swift # Chat input with send button
│ │ ├── QuickActionChips.swift # Quick suggestion chips
│ │ ├── TypewriterTextView.swift # Typewriter animation for AI
│ │ ├── TypingIndicator.swift # "AI is typing..." indicator
│ │ └── ErrorBanner.swift # Error message banner
│ └── ViewModels/
│ └── AICoachViewModel.swift # Chat state + API orchestration
│
├── Settings/ # v1.2: Simplified (app preferences only)
│ └── Views/
│ └── SettingsView.swift # fullScreenCover from Home/Profile
Note:
ViewModel Pattern:
@Observable @MainActor
final class LiftingSessionViewModel {
// Dependencies (injected via protocol)
private let workoutRepository: WorkoutRepositoryProtocol
private let exerciseLibrary: ExerciseLibraryProtocol
// UI State
var exercises: [WorkoutExercise] = []
var isLoading = false
var errorMessage: String?
// Dependency Injection
init(
workoutRepository: WorkoutRepositoryProtocol,
exerciseLibrary: ExerciseLibraryProtocol
) {
self.workoutRepository = workoutRepository
self.exerciseLibrary = exerciseLibrary
}
// Business logic orchestration
func addExercise(_ exercise: Exercise) async {
// Orchestrate repository calls
// Update UI state
}
}
Neden @MainActor? (Swift 6 Requirement)
Kural:
Sorumluluklar:
Bileşenler:
Klasör Yapısı:
Core/Domain/
├── Models/
│ ├── Workout/
│ │ ├── Workout.swift
│ │ ├── WorkoutSet.swift
│ │ └── WorkoutExercise.swift
│ ├── Exercise/
│ │ └── Exercise.swift
│ ├── Nutrition/
│ │ ├── NutritionLog.swift
│ │ └── Meal.swift
│ └── AICoach/ # v1.3: AI Coach models
│ ├── ChatMessage.swift # SwiftData @Model for messages
│ ├── ChatConversation.swift # SwiftData @Model for conversation
│ └── WorkoutContext.swift # DTO for AI context building
└── Protocols/
├── Repositories/
│ ├── WorkoutRepositoryProtocol.swift
│ ├── NutritionRepositoryProtocol.swift
│ └── ChatRepositoryProtocol.swift # v1.3: Chat persistence
└── Libraries/
├── ExerciseLibraryProtocol.swift
└── FoodLibraryProtocol.swift
Repository Protocol Pattern:
// Protocol definition (Domain layer)
protocol WorkoutRepositoryProtocol {
func fetchAll() async throws -> [Workout]
func fetch(id: UUID) async throws -> Workout?
func save(_ workout: Workout) async throws
func delete(_ workout: Workout) async throws
}
Model Pattern:
import SwiftData
@Model
final class Workout {
@Attribute(.unique) var id: UUID
var date: Date
var type: WorkoutType
var duration: TimeInterval
var notes: String?
@Relationship(deleteRule: .cascade)
var exercises: [WorkoutExercise] = []
init(date: Date, type: WorkoutType) {
self.id = UUID()
self.date = date
self.type = type
self.duration = 0
}
// Business logic method
func validate() throws {
if type == .lifting && exercises.isEmpty {
throw ValidationError.liftingRequiresExercises
}
}
}
Sorumluluklar:
Bileşenler:
Klasör Yapısı:
Core/Data/
├── Repositories/
│ ├── WorkoutRepository.swift
│ ├── NutritionRepository.swift
│ ├── ExerciseRepository.swift
│ └── ChatRepository.swift # v1.3: Chat message persistence
├── Libraries/
│ ├── ExerciseLibrary/
│ │ ├── ExerciseLibrary.swift
│ │ ├── BarbellExercises.swift
│ │ └── DumbbellExercises.swift
│ └── FoodLibrary/
│ ├── FoodLibrary.swift
│ └── ProteinFoods.swift
├── Services/ # v1.3: External API services
│ ├── GeminiAPIService.swift # Gemini 2.5 Flash-Lite API
│ ├── GeminiConfig.swift # API config + prompts
│ ├── Protocols/
│ │ └── GeminiAPIServiceProtocol.swift
│ └── WorkoutContextBuilder.swift # Builds AI context from user data
└── Persistence/
└── PersistenceController.swift
Repository Implementation Pattern:
import SwiftData
@ModelActor
actor WorkoutRepository: WorkoutRepositoryProtocol {
// ModelContext otomatik provide edilir via @ModelActor
func fetchAll() async throws -> [Workout] {
let descriptor = FetchDescriptor<Workout>(
sortBy: [SortDescriptor(\.date, order: .reverse)]
)
return try modelContext.fetch(descriptor)
}
func save(_ workout: Workout) async throws {
try workout.validate() // Domain validation
modelContext.insert(workout)
try modelContext.save()
}
func delete(_ workout: Workout) async throws {
modelContext.delete(workout)
try modelContext.save()
}
}
Neden @ModelActor? (Swift 6 Best Practice)
Amaç: Tüm dependencies’i merkezi olarak yönetmek
Konum: App/AppDependencies.swift
import SwiftData
@Observable
final class AppDependencies {
// Repositories (@ModelActor - initialized with ModelContainer)
let workoutRepository: WorkoutRepositoryProtocol
let nutritionRepository: NutritionRepositoryProtocol
let exerciseRepository: ExerciseRepositoryProtocol
// Libraries
let exerciseLibrary: ExerciseLibraryProtocol
let foodLibrary: FoodLibraryProtocol
init(modelContainer: ModelContainer) {
// Initialize repositories with ModelContainer
// @ModelActor will create its own ModelContext
self.workoutRepository = WorkoutRepository(modelContainer: modelContainer)
self.nutritionRepository = NutritionRepository(modelContainer: modelContainer)
self.exerciseRepository = ExerciseRepository(modelContainer: modelContainer)
// Initialize libraries
self.exerciseLibrary = ExerciseLibrary()
self.foodLibrary = FoodLibrary()
}
// Preview/Test initializer (mock dependencies)
static var preview: AppDependencies {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(
for: Workout.self, Exercise.self, NutritionLog.self,
configurations: config
)
return AppDependencies(modelContainer: container)
}
}
App Entry Point:
import SwiftUI
import SwiftData
@main
struct AntrainApp: App {
let modelContainer: ModelContainer
let dependencies: AppDependencies
init() {
do {
modelContainer = try ModelContainer(
for: Workout.self, Exercise.self, NutritionLog.self
)
dependencies = AppDependencies(
modelContainer: modelContainer // Pass container, not context
)
} catch {
fatalError("Could not initialize ModelContainer")
}
}
var body: some Scene {
WindowGroup {
HomeView()
.environment(dependencies)
}
}
}
View Usage:
struct LiftingSessionView: View {
@Environment(AppDependencies.self) private var deps
@State private var viewModel: LiftingSessionViewModel?
var body: some View {
Group {
if let viewModel {
// Render view with viewModel
}
}
.onAppear {
if viewModel == nil {
viewModel = LiftingSessionViewModel(
workoutRepository: deps.workoutRepository,
exerciseLibrary: deps.exerciseLibrary
)
}
}
}
}
Neden bu pattern?
throwsNeden?
protocol WorkoutRepositoryProtocol {
func save(_ workout: Workout) async throws
}
actor WorkoutRepository: WorkoutRepositoryProtocol {
func save(_ workout: Workout) async throws {
try workout.validate() // Validation error
modelContext.insert(workout)
try modelContext.save() // SwiftData error
}
}
Neden?
@Observable @MainActor
final class LiftingSessionViewModel {
var errorMessage: String?
var isLoading = false
func saveWorkout() async {
isLoading = true
errorMessage = nil
do {
try await workoutRepository.save(currentWorkout)
// Success - navigate or show confirmation
} catch let error as ValidationError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Workout kaydedilemedi. Lütfen tekrar deneyin."
}
isLoading = false
}
}
Neden?
struct LiftingSessionView: View {
@State var viewModel: LiftingSessionViewModel
var body: some View {
// ... content
.alert("Hata", isPresented: $viewModel.hasError) {
Button("Tamam", role: .cancel) {
viewModel.clearError()
}
} message: {
Text(viewModel.errorMessage ?? "Bilinmeyen hata")
}
}
}
enum ValidationError: LocalizedError {
case emptyField(String)
case invalidValue(String)
case businessRuleViolation(String)
var errorDescription: String? {
switch self {
case .emptyField(let field):
return "\(field) boş olamaz"
case .invalidValue(let message):
return "Geçersiz değer: \(message)"
case .businessRuleViolation(let message):
return message
}
}
}
enum RepositoryError: LocalizedError {
case saveFailed
case fetchFailed
case deleteFailed
case notFound
var errorDescription: String? {
switch self {
case .saveFailed:
return "Veri kaydedilemedi"
case .fetchFailed:
return "Veri yüklenemedi"
case .deleteFailed:
return "Veri silinemedi"
case .notFound:
return "Veri bulunamadı"
}
}
}
Neden kullanıyoruz?
ViewModel Pattern:
import Observation
@Observable @MainActor
final class LiftingSessionViewModel {
// Automatically observable - no @Published needed
var exercises: [WorkoutExercise] = []
var selectedExercise: Exercise?
var isLoading = false
var errorMessage: String?
// Private properties NOT observable (optimization)
private let workoutRepository: WorkoutRepositoryProtocol
private var currentWorkout: Workout
func addSet(reps: Int, weight: Double) async {
// State update automatically triggers view refresh
isLoading = true
// ... business logic
isLoading = false
}
}
ViewModel Lifecycle:
Pattern:
struct LiftingSessionView: View {
@Environment(AppDependencies.self) private var deps
@State private var viewModel: LiftingSessionViewModel?
var body: some View {
Group {
if let viewModel {
// View content
}
}
.onAppear {
if viewModel == nil {
viewModel = LiftingSessionViewModel(
workoutRepository: deps.workoutRepository
)
}
}
}
}
Neden optional @State?
Pattern: Enum-based state
enum LoadingState<T> {
case idle
case loading
case success(T)
case error(Error)
}
@Observable @MainActor
final class WorkoutHistoryViewModel {
var loadingState: LoadingState<[Workout]> = .idle
func loadWorkouts() async {
loadingState = .loading
do {
let workouts = try await repository.fetchAll()
loadingState = .success(workouts)
} catch {
loadingState = .error(error)
}
}
}
Kural: 100-200 satır ideal, 300 satır MAX
Neden?
Stratejiler:
Örnek Extraction:
❌ Kötü (tek dosya 400 satır):
LiftingSessionView.swift
✅ İyi (4 dosya, her biri ~100 satır):
LiftingSessionView.swift
ExerciseCard.swift
SetRow.swift
ExerciseSelectionSheet.swift
| Type | Convention | Örnek |
|---|---|---|
| View | [Feature][Type]View | LiftingSessionView |
| ViewModel | [Feature]ViewModel | LiftingSessionViewModel |
| Component | DS[Type] veya [Purpose]Component | DSPrimaryButton, ExerciseCard |
| Repository | [Entity]Repository | WorkoutRepository |
| Protocol | [Entity]RepositoryProtocol | WorkoutRepositoryProtocol |
| Model | [Entity] | Workout, Exercise |
Goal: Transform large, monolithic SwiftUI views into maintainable, component-based architecture following atomic design principles.
Success Metrics:
| File | Before | After | Reduction | Components Created |
|---|---|---|---|---|
WorkoutsOverviewView.swift |
679 | 345 | -49% | 3 |
SettingsView.swift |
522 | 239 | -54% | 2 + ViewModel updates |
DayDetailView.swift |
520 | 182 | -65% | 5 (2 Design System) |
TemplatesListView.swift |
501 | 320 | -36% | N/A (preview optimization) |
WorkoutSummaryView.swift |
496 | 256 | -48% | 5 |
| TOTAL | 2,718 | 1,342 | -50% | 20 components |
Two-Tier Component Organization:
Shared/DesignSystem/Components/)
Features/[Feature]/Views/Components/)
1. StatItemView.swift (45 lines)
/// Reusable stat item view with icon, value, and label
struct StatItemView: View {
let icon: String
let value: String
let label: String
var iconColor: Color = DSColors.primary
}
Usage: Profile stats, workout stats, nutrition dashboard, program metrics
2. ModifierChipView.swift (67 lines)
/// Reusable modifier chip for displaying intensity/volume adjustments
struct ModifierChipView: View {
let icon: String
let label: String
let value: String
let color: Color
}
Usage: Training program modifiers, workout intensity indicators, deload week markers
1. CalendarItemCardView.swift (168 lines)
2. WorkoutListView.swift (165 lines)
3. QuickActionsCard.swift (39 lines)
1. NotificationSettingsSection.swift (144 lines)
2. DataManagementSection.swift (68 lines)
ViewModel Enhancement:
SettingsViewModel1. DayInfoCard.swift (147 lines)
2. TemplateExerciseList.swift (101 lines)
3. WeekContextCard.swift (46 lines)
Plus Design System components: StatItemView, ModifierChipView (listed above)
1. PRSectionView.swift (43 lines)
2. WorkoutStatsGrid.swift (64 lines)
3. ComparisonSection.swift (68 lines)
4. MuscleGroupSection.swift (38 lines)
5. ExerciseDetailsList.swift (98 lines)
| Type | Pattern | Example |
|---|---|---|
| Design System | [Purpose]View |
StatItemView, ModifierChipView |
| Feature Component | [Feature][Purpose]View/Card/Section/Row |
CalendarItemCardView, MuscleGroupSection |
| List Components | [Entity]ListView |
WorkoutListView, ExerciseDetailsList |
| Private SubComponents | private struct [Name]Row |
SummarySetRow, SetRow |
Naming Conflict Resolution:
SummarySetRow instead of generic SetRow)1. Component Extraction Triggers:
@ViewBuilder helper methods2. Preview Optimization:
@Previewable macro for dynamic state3. State Management:
4. Component Boundaries:
5. Build Validation:
Files identified for Phase 2 refactoring (200-400 lines):
NutritionLogView.swift (377 lines)NutritionDashboardView.swift (330 lines)LiftingSessionView.swift (319 lines)ProgramDetailView.swift (309 lines)ExerciseEditorView.swift (275 lines)Strategy: Same component extraction approach, prioritize by active development frequency.
@Test
func testWorkoutRepository_SaveAndFetch() async throws {
// Arrange
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Workout.self, configurations: config)
let repository = WorkoutRepository(modelContext: container.mainContext)
let workout = Workout(date: Date(), type: .lifting)
// Act
try await repository.save(workout)
let fetched = try await repository.fetchAll()
// Assert
#expect(fetched.count == 1)
#expect(fetched.first?.type == .lifting)
}
actor MockWorkoutRepository: WorkoutRepositoryProtocol {
var savedWorkouts: [Workout] = []
func save(_ workout: Workout) async throws {
savedWorkouts.append(workout)
}
func fetchAll() async throws -> [Workout] {
return savedWorkouts
}
}
@Test
func testViewModel_SaveWorkout() async throws {
// Arrange
let mockRepo = MockWorkoutRepository()
let viewModel = LiftingSessionViewModel(workoutRepository: mockRepo)
// Act
await viewModel.saveWorkout()
// Assert
let saved = try await mockRepo.fetchAll()
#expect(saved.count == 1)
}
Xcode Setting: Strict Concurrency Checking = Complete
Swift 6 ile gelen değişiklikler:
Repository’ler → @ModelActor
@ModelActor
actor WorkoutRepository: WorkoutRepositoryProtocol {
// ModelContext otomatik inject
// Background thread'de çalışır
// Serial execution guarantee
}
ViewModels → @Observable @MainActor
@Observable @MainActor
final class LiftingSessionViewModel {
// Main thread'de çalışır
// UI updates safe
// Animation issues yok
}
Model objects Sendable değil - PersistentIdentifier kullan:
// ❌ YANLIŞ - Workout sendable değil
let workout = Workout(...)
Task {
await repository.save(workout) // Compile error!
}
// ✅ DOĞRU - PersistentIdentifier sendable
let id = workout.persistentModelID
Task {
await repository.save(id)
}
MVP için simplified approach:
// Repository async method'ları direkt çağır
@Observable @MainActor
final class LiftingSessionViewModel {
func saveWorkout() async {
do {
try await repository.save(currentWorkout)
} catch {
errorMessage = error.localizedDescription
}
}
}
Kural: Her actor kendi ModelContext’ini oluşturur
@ModelActor
actor WorkoutRepository {
// modelContext bu actor'a ait
// Background thread'de operations
}
// MainContext (UI thread)
@main
struct AntrainApp: App {
let modelContainer: ModelContainer
var body: some Scene {
WindowGroup {
HomeView()
.modelContainer(modelContainer)
}
}
}
@Query → Main thread’de çalışır (SwiftUI views’da OK)
@ModelActor kullanıyor@Observable @MainActor kullanıyorAmaç: Future-proof localization, şimdilik İngilizce, gelecekte Türkçe
File: Resources/Localizable.xcstrings
SwiftUI views otomatik localizable:
// Otomatik String Catalog'a eklenir
Text("Start Workout")
Button("Save") { }
Label("Add Exercise", systemImage: "plus")
// TextField placeholder
TextField("Exercise name", text: $name)
Xcode otomatik olarak:
Localizable.xcstrings dosyasına eklerKod içinde string’ler:
let message = String(localized: "Workout saved successfully")
// Context ile (translator'lar için)
let message = String(
localized: "You lifted \(weight) kg",
comment: "Example: You lifted 100 kg"
)
String Catalog’da “Vary by Plural” kullan:
// SwiftUI'da
Text("\(count) exercises")
// String Catalog'da:
// - "one": "1 exercise"
// - "other": "%lld exercises"
String interpolation:
Text("Welcome, \(userName)")
Text("You completed \(setCount) sets")
// String Catalog:
// "Welcome, %@"
// "You completed %lld sets"
Phase 1 (MVP - Şimdi):
Phase 2 (Post-MVP):
MVP için:
Localizable.xcstrings dosyası oluşturulduString Catalog Best Practices:
// ✅ İYİ - Localizable
Text("Start Workout")
String(localized: "Workout saved")
// ❌ KÖTÜ - Hardcoded
Text("Start Workout") // OK ama comment ekle
let message = "Workout saved" // String(localized:) kullan
// ✅ CONTEXT İLE
String(
localized: "\(reps) reps × \(weight) kg",
comment: "Example: 10 reps × 100 kg"
)
Resources/
└── Localizable.xcstrings
├── Source Language: English (Base)
└── Localizations:
└── Turkish (Phase 2)
String Catalog yapısı:
{
"sourceLanguage" : "en",
"strings" : {
"Start Workout" : {
"localizations" : {
"en" : { "stringUnit" : { "value" : "Start Workout" } },
"tr" : { "stringUnit" : { "value" : "Antrenmana Başla" } }
}
}
},
"version" : "1.0"
}
Not: Xcode otomatik bu formatı manage eder, manuel JSON yazma gereksiz.
Before Writing Code:
During Development:
After Feature Complete:
Son Güncelleme: 2025-02-11 (Swift 6 + Localization updates) Dosya Boyutu: ~240 satır Swift 6 Compliance: ✅ @ModelActor, @Observable @MainActor Localization: ✅ String Catalog strategy
TrainingProgram (MacroCycle):
@Model
final class TrainingProgram {
@Attribute(.unique) var id: UUID
var name: String
var programDescription: String?
var category: ProgramCategory
var difficulty: DifficultyLevel
var durationWeeks: Int
var progressionPattern: WeekProgressionPattern
var isCustom: Bool
var createdAt: Date
@Relationship(deleteRule: .cascade)
var weeks: [ProgramWeek]
}
ProgramWeek (MicroCycle):
@Model
final class ProgramWeek {
@Attribute(.unique) var id: UUID
var weekNumber: Int
var name: String?
var notes: String?
var phaseTag: TrainingPhase?
var intensityModifier: Double
var volumeModifier: Double
var isDeload: Bool
var program: TrainingProgram
@Relationship(deleteRule: .cascade)
var days: [ProgramDay]
}
ProgramDay:
@Model
final class ProgramDay {
@Attribute(.unique) var id: UUID
var dayOfWeek: Int
var name: String?
var notes: String?
var week: ProgramWeek
var template: WorkoutTemplate? // Reference, not copy
var intensityOverride: Double?
var volumeOverride: Double?
var suggestedRPE: Int?
}
ProgressiveOverloadService:
@MainActor
final class ProgressiveOverloadService {
func suggestWorkout(
for template: WorkoutTemplate,
weekModifier: Double,
previousWorkouts: [Workout]
) -> SuggestedWorkout
// RPE-based algorithm:
// RPE 1-6: +5% (too easy)
// RPE 7-8: +2.5% (perfect)
// RPE 9-10: -2.5% (too hard)
}
Workout (Extended):
@Model
final class Workout {
// ... existing fields
// v2.0 Addition
var rpe: Int? // 1-10 Rate of Perceived Exertion
}
UserProfile (Extended):
@Model
final class UserProfile {
// ... existing fields
// v2.0 Additions
var activeProgram: TrainingProgram?
var activeProgramStartDate: Date?
var currentWeekNumber: Int?
}
TrainingProgramRepository:
@ModelActor
actor TrainingProgramRepository: TrainingProgramRepositoryProtocol {
func create(_ program: TrainingProgram) async throws
func fetchAll() async throws -> [TrainingProgram]
func fetchById(_ id: UUID) async throws -> TrainingProgram?
func fetchByCategory(_ category: ProgramCategory) async throws -> [TrainingProgram]
func update(_ program: TrainingProgram) async throws
func delete(_ program: TrainingProgram) async throws
func findProgramsUsingTemplate(_ template: WorkoutTemplate) async throws -> [String]
}
Template Deletion Safety:
TrainingProgram (MacroCycle)
└── ProgramWeek (MicroCycle)
└── ProgramDay (Training Day)
└── WorkoutTemplate (Reference)
└── Exercise (Single Source of Truth)
Benefits:
antrain/
├── Core/
│ ├── Domain/
│ │ ├── Models/
│ │ │ └── Program/
│ │ │ ├── TrainingProgram.swift
│ │ │ ├── ProgramWeek.swift
│ │ │ ├── ProgramDay.swift
│ │ │ └── [Enums].swift
│ │ └── Protocols/Repositories/
│ │ └── TrainingProgramRepositoryProtocol.swift
│ └── Data/
│ ├── Repositories/
│ │ └── TrainingProgramRepository.swift
│ ├── Services/
│ │ └── ProgressiveOverloadService.swift
│ └── Libraries/
│ └── ProgramLibrary/
└── Features/
└── Workouts/
└── Programs/
├── ViewModels/ (6 ViewModels)
└── Views/ (19 Views & Components)
AI Coach feature integrates Google Gemini 2.5 Flash-Lite API to provide personalized fitness coaching based on user’s workout history, nutrition data, active programs, and personal records.
Key Features:
┌─────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ AICoachView + AICoachViewModel │
│ • Chat UI │
│ • User input handling │
│ • Message display │
└─────────────────────────────────────────────┘
↓ ↑
(Dependencies via AppDependencies)
↓ ↑
┌─────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ Models: ChatMessage, WorkoutContext │
│ Protocols: ChatRepositoryProtocol, │
│ GeminiAPIServiceProtocol │
└─────────────────────────────────────────────┘
↓ ↑
(Implementation)
↓ ↑
┌─────────────────────────────────────────────┐
│ DATA LAYER │
│ • ChatRepository (SwiftData persistence) │
│ • GeminiAPIService (API calls) │
│ • WorkoutContextBuilder (context builder) │
└─────────────────────────────────────────────┘
User Sends Message:
1. User types message in ChatInputField
2. AICoachViewModel.sendMessage() called
3. ViewModel adds user message to messages array
4. ChatRepository.saveMessage() persists user message
5. WorkoutContextBuilder builds context from:
- Recent workouts (30 days)
- Detailed workout data (last 5 workouts)
- Personal records
- Active training program (current + next week)
- Nutrition summary (7 days)
- User profile data
6. GeminiAPIService.sendMessage() sends:
- User message
- WorkoutContext
- Chat history (last 10 messages)
- isNewUser flag
7. Gemini API returns response
8. ViewModel displays AI message with typewriter effect
9. ChatRepository.saveMessage() persists AI message
ChatMessage (SwiftData):
@Model
final class ChatMessage {
var id: UUID
var content: String
var isFromUser: Bool
var timestamp: Date
var conversation: ChatConversation?
@Transient var isSending: Bool // UI state only
}
ChatConversation (SwiftData):
@Model
final class ChatConversation {
var id: UUID
var createdAt: Date
var lastMessageAt: Date?
@Relationship(deleteRule: .cascade)
var messages: [ChatMessage] = []
}
WorkoutContext (DTO):
MODELS.md for detailed structureGeminiAPIService:
@MainActor
final class GeminiAPIService: GeminiAPIServiceProtocol {
func sendMessage(
_ message: String,
context: WorkoutContext,
chatHistory: [ChatHistoryItem],
isNewUser: Bool
) async throws -> String
}
Key Features:
WorkoutContextBuilder:
@MainActor
final class WorkoutContextBuilder {
func buildContext() async throws -> WorkoutContext
}
Context Building:
ChatRepository (@ModelActor):
@ModelActor
actor ChatRepository: ChatRepositoryProtocol {
func fetchOrCreateConversation() async throws -> ChatConversation
func saveMessage(content: String, isFromUser: Bool) async throws -> ChatMessage
func fetchAllMessages() async throws -> [ChatMessage]
func clearHistory() async throws
}
Design Decisions:
@ModelActor for thread-safe SwiftData accessAICoachViewModel:
@Observable @MainActor
final class AICoachViewModel {
// Dependencies
private let chatRepository: ChatRepositoryProtocol
private let geminiAPIService: GeminiAPIServiceProtocol
private let workoutContextBuilder: WorkoutContextBuilder
// State
var messages: [ChatMessage] = []
var userInput: String = ""
var isLoading: Bool = false
var errorMessage: String?
var currentAIMessage: String = "" // For typewriter effect
var showTypewriter: Bool = false
// Business Logic
func sendMessage() async
func loadChatHistory() async
func clearChat() async
}
State Management:
@Observable for SwiftUI reactivity@MainActor for UI thread safetyAICoachView:
ChatMessageBubble:
TypewriterTextView:
TypingIndicator:
Model: gemini-2.5-flash-lite
System Prompt:
API Configuration:
temperature: 0.7 // Balanced creativity
topK: 40
topP: 0.95
maxOutputTokens: 2048
Error Types:
enum GeminiAPIError: LocalizedError {
case messageEmpty
case messageTooLong(limit: Int)
case invalidAPIKey
case rateLimitExceeded(retryAfter: Int)
case serverError(statusCode: Int)
case timeout
case noInternetConnection
case invalidResponse
case unknown(Error)
}
User-Facing Messages:
API Key Storage:
Data Privacy:
Context Building:
Chat History:
UI Performance:
Unit Tests:
Integration Tests:
UI Tests:
Planned Features:
Technical Debt:
Last Updated: 2025-11-10 v1.2+ View Layer Refactoring Documentation Added v2.0 Training Programs Extension Added v1.3 AI Fitness Coach Feature Added