Round Persistence Implementation Guide
Phase: 2b Estimated Effort: 4-6 hours Prerequisites: Phase 2a (Round Creation UI) complete
Overview
This guide covers migrating RoundRepository from Android-only code to shared KMP module, then integrating it with iOS RoundCreationViewModel to enable real data persistence.
Current State: Round Creation uses mock repository (data doesn’t persist) Target State: Rounds save to database and persist across app sessions
Step 1: Migrate RoundRepository to Shared KMP Module
Current Architecture
Android-Only:
└── app/src/main/java/com/archeryapprentice/domain/repository/
└── RoundRepository.kt (1,521 lines)
Problem: iOS cannot access this code
Target Architecture
Shared KMP:
└── shared/data/src/commonMain/kotlin/com/archeryapprentice/domain/repository/
└── RoundRepository.kt (migrated from Android)
Result: Both Android and iOS use same repository
Repository Analysis
The current RoundRepository.kt includes:
Core Operations (iOS needs these):
insertRound(round: Round): Long- Save new roundgetAllRounds(): Flow<List<Round>>- List all roundsgetRoundById(roundId: Int): Round?- Get single roundupdateRound(round: Round)- Update existing rounddeleteRound(round: Round)- Remove round
Session Management (iOS needs these):
startRound(roundId: Int): Boolean- Begin scoringcompleteRound(roundId: Int): Boolean- Finish roundpauseRound(roundId: Int): Boolean- Pause session
Scoring Operations (Phase 2d will need these):
scoreEnd(...)- Save end scoresgetNextEndNumber(roundId: Int): Int?- NavigationrecordCompletedEndAndAdvance(...)- Atomic scoring
Statistics (Phase 2e will need these):
calculateRoundStatistics(roundId: Int): RoundStatistics?- Various performance and analytics methods
Migration Steps
1.1 Check Dependencies
RoundRepository depends on:
// Already in shared module ✅
import com.archeryapprentice.database.entities.Round
import com.archeryapprentice.database.entities.RoundStatus
import com.archeryapprentice.database.entities.EndScore
import com.archeryapprentice.database.entities.ArrowScore
import com.archeryapprentice.database.dao.RoundDao
// Android-specific (needs abstraction) ⚠️
import com.archeryapprentice.BuildConfig // For debug logging
import androidx.room.Transaction // Room annotation
import com.archeryapprentice.platform.AndroidLoggingProviderSolution: Create KMP-compatible logging abstraction (if not already exists)
1.2 Create Shared Repository
Option A: Full Migration (Recommended)
Move entire file to shared module:
cd /path/to/archery-apprentice
# Move repository to shared
git mv app/src/main/java/com/archeryapprentice/domain/repository/RoundRepository.kt \
shared/data/src/commonMain/kotlin/com/archeryapprentice/domain/repository/RoundRepository.kt
# Update imports for KMP compatibility
# Replace AndroidLoggingProvider with shared LoggingProvider
# Replace BuildConfig.DEBUG with expect/actual patternOption B: Incremental Migration (If Option A has blockers)
- Create minimal iOS-compatible repository in shared module
- Keep Android repository as-is
- Gradually merge as features are needed
Recommendation: Option A (full migration) is cleaner and prevents code duplication.
1.3 Handle Platform-Specific Code
Debug Logging:
// Current (Android-only)
if (BuildConfig.DEBUG) {
logger.d("RoundRepository", "Message")
}
// KMP Solution - Use expect/actual
// shared/src/commonMain/kotlin
expect fun isDebugBuild(): Boolean
// shared/src/androidMain/kotlin
actual fun isDebugBuild(): Boolean = BuildConfig.DEBUG
// shared/src/iosMain/kotlin
actual fun isDebugBuild(): Boolean = true // Or use preprocessor flagRoom Transactions:
// Current (Android-only)
@Transaction
suspend fun recordCompletedEndAndAdvance(...): Int? { ... }
// KMP Solution - Room supports KMP via expect/actual
// Transaction annotation works in commonMain when Room KMP setup correctly1.4 Expose Repository to iOS
Update KMP framework export configuration:
// shared/build.gradle.kts
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// Existing dependencies
}
}
val iosMain by getting {
dependencies {
// Ensure repository dependencies are available
}
}
}
}1.5 Verify Migration
Build shared framework:
cd shared
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64Check repository is exported:
# In iosApp, check framework headers
ls shared/presentation/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/
# Should see RoundRepository interfaceStep 2: Update iOS RoundCreationViewModel
Current Implementation (Mock)
// RoundCreationViewModel.swift (lines 192-198)
private func saveRound(round: Round) async throws -> KotlinLong {
// TODO: Wire up real RoundRepository when migrated to KMP
// For now, return mock ID
return KotlinLong(value: Int64(Date().timeIntervalSince1970))
}Target Implementation (Real Persistence)
2.1 Inject RoundRepository
import Shared // KMP framework
class RoundCreationViewModel: ObservableObject {
private let roundRepository: RoundRepository
init(roundRepository: RoundRepository) {
self.roundRepository = roundRepository
}
// Alternative: Use dependency injection singleton
init() {
self.roundRepository = IosDependencies.shared.roundRepository
}
}2.2 Replace Mock with Real Save
private func saveRound(round: Round) async throws -> KotlinLong {
do {
// Call KMP repository method
let roundId = try await withCheckedThrowingContinuation { continuation in
roundRepository.insertRound(round: round) { result, error in
if let error = error {
continuation.resume(throwing: error)
} else if let roundId = result {
continuation.resume(returning: roundId)
} else {
continuation.resume(throwing: RoundCreationError.saveFailed)
}
}
}
return roundId
} catch {
throw RoundCreationError.databaseError(error.localizedDescription)
}
}Alternative (if using KMP-NativeCoroutines):
private func saveRound(round: Round) async throws -> KotlinLong {
// KMP-NativeCoroutines bridges suspend functions to Swift async/await
let roundId = try await roundRepository.insertRound(round: round)
return roundId
}2.3 Handle Validation
func createRound() async {
guard isFormValid else { return }
isLoading = true
errorMessage = nil
do {
// Validate round setup before saving
let round = buildRound()
let isValid = try await roundRepository.validateRoundSetup(round: round)
guard isValid else {
throw RoundCreationError.invalidConfiguration
}
// Save to database
let roundId = try await saveRound(round: round)
// Success
self.createdRoundId = roundId
self.showingSuccess = true
} catch {
errorMessage = "Failed to create round: \(error.localizedDescription)"
}
isLoading = false
}2.4 Add Error Handling
enum RoundCreationError: LocalizedError {
case saveFailed
case databaseError(String)
case invalidConfiguration
case networkError(String)
var errorDescription: String? {
switch self {
case .saveFailed:
return "Failed to save round to database"
case .databaseError(let message):
return "Database error: \(message)"
case .invalidConfiguration:
return "Round configuration is invalid"
case .networkError(let message):
return "Network error: \(message)"
}
}
}Step 3: Type Bridging Considerations
Kotlin to Swift Type Mapping
Based on PR #284 learnings:
// Kotlin
suspend fun insertRound(round: Round): Long
// Swift
func insertRound(round: Round, completionHandler: (KotlinLong?, Error?) -> Void)Common Bridging Patterns
Suspend Functions → Async/Await
If KMP-NativeCoroutines configured:
// Kotlin
suspend fun getRoundById(roundId: Int): Round?
// Swift (auto-generated by KMP-NativeCoroutines)
func getRoundById(roundId: Int32) async throws -> Round?If using completion handlers:
// Manual bridging
func getRoundById(roundId: Int32) async throws -> Round? {
try await withCheckedThrowingContinuation { continuation in
roundRepository.getRoundById(roundId: roundId) { round, error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: round)
}
}
}
}Flow → AsyncSequence
// Kotlin
fun getAllRounds(): Flow<List<Round>>
// Swift (with KMP-NativeCoroutines @NativeCoroutines annotation)
var allRounds: AsyncSequence<[Round]>
// Usage
for await rounds in roundRepository.allRounds {
print("Rounds updated: \(rounds.count)")
}Step 4: Testing Checklist
Integration Tests
Test Round Creation End-to-End:
- ✅ Create round via iOS UI
- ✅ Verify database INSERT occurs
- ✅ Confirm round ID returned
- ✅ Fetch round from database
- ✅ Verify all fields persisted correctly
Test Error Handling:
- ✅ Invalid round configuration rejected
- ✅ Database errors display user-friendly message
- ✅ Network errors handled gracefully
- ✅ Loading states show during async operations
Test Edge Cases:
- ✅ Duplicate round names allowed
- ✅ Missing optional fields (weather, notes)
- ✅ Bow setup ID validation
- ✅ Multi-participant support (if applicable)
Manual Testing
Verify Persistence:
// Test sequence:
1. Create round in iOS app
2. Force quit app
3. Relaunch app
4. Verify round appears in list (Phase 2c)Verify Database:
# On simulator, check SQLite database directly
xcrun simctl get_app_container booted com.archeryapprentice.ios data
# Find database file
find ~/Library/Developer/CoreSimulator/Devices -name "*.db"
# Query rounds table
sqlite3 path/to/database.db "SELECT * FROM rounds ORDER BY id DESC LIMIT 5;"Success Criteria
Phase 2b is complete when:
- ✅ RoundRepository migrated to shared KMP module
- ✅ iOS RoundCreationViewModel uses real repository
- ✅ Rounds persist to database successfully
- ✅ Round IDs returned from repository
- ✅ Error handling covers all failure modes
- ✅ End-to-end creation test passes
- ✅ iOS and Android share same repository code
Troubleshooting
Issue: “RoundRepository not found in Shared framework”
Solution:
- Ensure repository is in
commonMainsource set - Rebuild shared framework:
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 - Clean Xcode build: Product → Clean Build Folder
Issue: “Type mismatch: Expected Int32, found Int”
Solution:
// Kotlin Int → Swift Int32
let kotlinInt = Int32(swiftInt)
roundRepository.getRoundById(roundId: kotlinInt)Issue: “Suspend function not callable from Swift”
Solution:
- Add KMP-NativeCoroutines gradle plugin
- Annotate repository methods with
@NativeCoroutines - Rebuild framework
Issue: “Flow not bridging to Swift”
Solution:
// Add @NativeCoroutines annotation
@NativeCoroutines
fun getAllRounds(): Flow<List<Round>>Next Steps
After completing Phase 2b:
- Proceed to Phase 2c: Round List Implementation Guide
- Verify round list displays persisted rounds
- Test round creation → list refresh flow
Related Documentation
- Phase 2 Roadmap
- Round List Implementation Guide
- Active Scoring Implementation Guide
- iOS Development Roadmap
Last Updated: 2025-11-21 Status: Phase 2b planned, awaiting Phase 2a merge