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 round
  • getAllRounds(): Flow<List<Round>> - List all rounds
  • getRoundById(roundId: Int): Round? - Get single round
  • updateRound(round: Round) - Update existing round
  • deleteRound(round: Round) - Remove round

Session Management (iOS needs these):

  • startRound(roundId: Int): Boolean - Begin scoring
  • completeRound(roundId: Int): Boolean - Finish round
  • pauseRound(roundId: Int): Boolean - Pause session

Scoring Operations (Phase 2d will need these):

  • scoreEnd(...) - Save end scores
  • getNextEndNumber(roundId: Int): Int? - Navigation
  • recordCompletedEndAndAdvance(...) - 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.AndroidLoggingProvider

Solution: 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 pattern

Option B: Incremental Migration (If Option A has blockers)

  1. Create minimal iOS-compatible repository in shared module
  2. Keep Android repository as-is
  3. 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 flag

Room 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 correctly

1.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:linkDebugFrameworkIosSimulatorArm64

Check repository is exported:

# In iosApp, check framework headers
ls shared/presentation/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/
# Should see RoundRepository interface

Step 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:

  1. ✅ Create round via iOS UI
  2. ✅ Verify database INSERT occurs
  3. ✅ Confirm round ID returned
  4. ✅ Fetch round from database
  5. ✅ Verify all fields persisted correctly

Test Error Handling:

  1. ✅ Invalid round configuration rejected
  2. ✅ Database errors display user-friendly message
  3. ✅ Network errors handled gracefully
  4. ✅ Loading states show during async operations

Test Edge Cases:

  1. ✅ Duplicate round names allowed
  2. ✅ Missing optional fields (weather, notes)
  3. ✅ Bow setup ID validation
  4. ✅ 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 commonMain source 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:

  1. Proceed to Phase 2c: Round List Implementation Guide
  2. Verify round list displays persisted rounds
  3. Test round creation → list refresh flow


Last Updated: 2025-11-21 Status: Phase 2b planned, awaiting Phase 2a merge