Leaderboard Settings Architecture

The leaderboard settings system provides personalized preferences for users with an offline-first synchronization pattern. Users can customize their default filters, visibility preferences, and notification settings.

Overview

┌─────────────────────────────────────────────────────────────────┐
│                       UI Layer                                   │
│  ┌───────────────────────┐    ┌───────────────────────────────┐ │
│  │ LeaderboardSettings   │    │ LeaderboardSettingsView       │ │
│  │ Screen (Android)      │    │ (iOS SwiftUI)                 │ │
│  └───────────┬───────────┘    └───────────────┬───────────────┘ │
└──────────────│────────────────────────────────│─────────────────┘
               │                                │
               ▼                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Service Layer                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │              LeaderboardSettingsService                    │  │
│  │  - loadSettings()    - updateSettings()                    │  │
│  │  - forceSync()       - observeRemoteSettings()             │  │
│  └───────────────────────────┬───────────────────────────────┘  │
└──────────────────────────────│──────────────────────────────────┘
               ┌───────────────┴───────────────┐
               ▼                               ▼
┌─────────────────────────┐    ┌─────────────────────────────────┐
│   SharedPreferences     │    │   Firestore                     │
│   (Local Cache)         │    │   users/{uid}/settings/         │
│                         │    │   leaderboard                   │
└─────────────────────────┘    └─────────────────────────────────┘

LeaderboardSettings Model (KMP)

Location: shared/domain/src/commonMain/kotlin/.../leaderboard/LeaderboardSettings.kt

@Serializable
data class LeaderboardSettings(
    val userId: String = "",
    val defaultBowType: BowType? = null,           // Default filter (null = show all)
    val defaultTimePeriod: TimePeriod = TimePeriod.ALL_TIME,
    val showOwnScores: Boolean = true,             // Highlight own scores
    val notifyRankChanges: Boolean = true,         // Rank change notifications
    val notifyPersonalBests: Boolean = true,       // PB notifications
    val createdAt: Long = 0L,
    val updatedAt: Long = 0L
) {
    fun withUpdatedTimestamp(timestamp: Long): LeaderboardSettings
    fun withInitialTimestamps(timestamp: Long): LeaderboardSettings
    fun toFirestoreMap(): Map<String, Any>
 
    companion object {
        fun fromFirestoreMap(map: Map<String, Any?>): LeaderboardSettings
    }
}

Setting Descriptions

SettingTypeDefaultDescription
defaultBowTypeBowType?nullDefault bow type filter for leaderboards
defaultTimePeriodTimePeriodALL_TIMEDefault time period filter
showOwnScoresBooleantrueHighlight user’s own entries
notifyRankChangesBooleantruePush notifications on rank changes
notifyPersonalBestsBooleantruePush notifications on new PBs

Offline-First Pattern

Why Offline-First?

  1. Instant UI: Settings appear immediately on app launch
  2. No blocking: Network failures don’t block the user
  3. Eventual consistency: Firebase sync happens in background

Data Flow

┌──────────────────────────────────────────────────────────────────┐
│                        READ FLOW                                  │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│  App Launch                                                       │
│      │                                                            │
│      ▼                                                            │
│  ┌────────────────────┐                                           │
│  │ Load from cache    │ ──→ UI shows immediately                  │
│  │ (SharedPreferences)│                                           │
│  └─────────┬──────────┘                                           │
│            │                                                      │
│            ▼                                                      │
│  ┌────────────────────┐                                           │
│  │ Sync from Firebase │ ──→ (background)                          │
│  │ if online          │                                           │
│  └─────────┬──────────┘                                           │
│            │                                                      │
│            ▼                                                      │
│  ┌────────────────────┐                                           │
│  │ Update cache if    │ ──→ UI updates via StateFlow              │
│  │ remote is newer    │                                           │
│  └────────────────────┘                                           │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│                        WRITE FLOW                                 │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│  User Changes Setting                                             │
│      │                                                            │
│      ▼                                                            │
│  ┌────────────────────┐                                           │
│  │ Update cache       │ ──→ UI shows immediately                  │
│  │ (SharedPreferences)│                                           │
│  └─────────┬──────────┘                                           │
│            │                                                      │
│            ▼                                                      │
│  ┌────────────────────┐                                           │
│  │ Sync to Firebase   │ ──→ (async, non-blocking)                 │
│  │ if online          │                                           │
│  └─────────┬──────────┘                                           │
│            │                                                      │
│       ┌────┴────┐                                                 │
│       │ Success │                                                 │
│       │    or   │                                                 │
│       │ Failure │                                                 │
│       └────┬────┘                                                 │
│            │                                                      │
│            ▼                                                      │
│  ┌────────────────────┐                                           │
│  │ If failed: mark    │ ──→ Retry on next forceSync()             │
│  │ pendingSync = true │                                           │
│  └────────────────────┘                                           │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

LeaderboardSettingsService

Location: app/src/main/java/.../services/LeaderboardSettingsService.kt

Constructor

class LeaderboardSettingsService(
    context: Context,
    private val firebaseDataSource: FirebaseLeaderboardSettingsDataSource
)

StateFlow Observation

// Observable settings state
val settings: StateFlow<LeaderboardSettings?>
 
// Individual setting flows for granular observation
val defaultBowType: Flow<BowType?>
val defaultTimePeriod: Flow<TimePeriod>
val showOwnScores: Flow<Boolean>

Key Operations

// Load settings (cache-first, then sync)
suspend fun loadSettings(userId: String): LeaderboardSettings
 
// Update with lambda transform
suspend fun updateSettings(
    userId: String,
    update: (LeaderboardSettings) -> LeaderboardSettings
): Result<LeaderboardSettings>
 
// Convenience setters
suspend fun setDefaultBowType(userId: String, bowType: BowType?)
suspend fun setDefaultTimePeriod(userId: String, timePeriod: TimePeriod)
suspend fun setShowOwnScores(userId: String, showOwnScores: Boolean)
suspend fun setNotificationPreferences(
    userId: String,
    notifyRankChanges: Boolean,
    notifyPersonalBests: Boolean
)
 
// Sync operations
suspend fun forceSync(userId: String): Result<Unit>
fun observeRemoteSettings(userId: String): Flow<LeaderboardSettings?>
 
// Cache management
fun clearCache()  // Call on logout

Local Cache Structure

SharedPreferences Name: leaderboard_settings

KeyTypeDescription
settings_jsonStringFull settings object as JSON
user_idStringCurrent user ID (for multi-user detection)
pending_syncBooleanTrue if local changes need Firebase sync

Multi-User Handling

When user ID changes (e.g., different account login), the service clears the cache to prevent settings from one user being applied to another.


Firestore Schema

Path: users/{userId}/settings/leaderboard

{
  "userId": "firebase_uid_123",
  "defaultBowType": "RECURVE",
  "defaultTimePeriod": "SEASON",
  "showOwnScores": true,
  "notifyRankChanges": true,
  "notifyPersonalBests": true,
  "createdAt": 1735567200000,
  "updatedAt": 1735567200000
}

Security Rules

match /users/{userId}/settings/leaderboard {
  // Users can only read/write their own settings
  allow read, write: if request.auth != null
                     && request.auth.uid == userId;
}

UI Integration

Android (Compose)

Location: app/src/main/java/.../ui/leaderboard/LeaderboardSettingsScreen.kt

@Composable
fun LeaderboardSettingsScreen(
    viewModel: LeaderboardSettingsViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    val settings by viewModel.settings.collectAsState()
 
    // UI components for each setting
    BowTypeSelector(
        selected = settings.defaultBowType,
        onSelect = viewModel::setDefaultBowType
    )
 
    TimePeriodSelector(
        selected = settings.defaultTimePeriod,
        onSelect = viewModel::setDefaultTimePeriod
    )
 
    SwitchPreference(
        title = "Show My Scores",
        checked = settings.showOwnScores,
        onCheckedChange = viewModel::setShowOwnScores
    )
 
    // ... notification settings
}

iOS (SwiftUI)

Location: iosApp/iosApp/Views/Leaderboard/LeaderboardSettingsView.swift

struct LeaderboardSettingsView: View {
    @StateObject var viewModel: LeaderboardSettingsViewModel
 
    var body: some View {
        Form {
            Section("Default Filters") {
                Picker("Bow Type", selection: $viewModel.defaultBowType) {
                    // options
                }
 
                Picker("Time Period", selection: $viewModel.defaultTimePeriod) {
                    // options
                }
            }
 
            Section("Display") {
                Toggle("Highlight My Scores", isOn: $viewModel.showOwnScores)
            }
 
            Section("Notifications") {
                Toggle("Rank Changes", isOn: $viewModel.notifyRankChanges)
                Toggle("Personal Bests", isOn: $viewModel.notifyPersonalBests)
            }
        }
        .navigationTitle("Leaderboard Settings")
    }
}

Error Handling

Network Failures

When Firebase sync fails:

  1. Local cache is already updated (user sees change immediately)
  2. pendingSync flag is set to true
  3. Next forceSync() call will retry the upload

Parse Failures

When parsing cached JSON fails:

  • Return null and load defaults
  • Log warning for debugging

Invalid Enum Values

The fromFirestoreMap() handles unknown enum values gracefully:

  • Unknown BowTypenull (show all)
  • Unknown TimePeriodALL_TIME

Testing

Test Coverage

ComponentTestsCoverage
LeaderboardSettings model8100%
LeaderboardSettingsService1090%

Key Test Scenarios

  • Load settings from empty cache (creates defaults)
  • Load settings with existing cache (uses cache)
  • Update settings offline (caches locally, marks pending)
  • Sync on reconnect (uploads pending changes)
  • User switch (clears cache for new user)
  • Invalid enum handling (falls back to defaults)