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
| Setting | Type | Default | Description |
|---|---|---|---|
defaultBowType | BowType? | null | Default bow type filter for leaderboards |
defaultTimePeriod | TimePeriod | ALL_TIME | Default time period filter |
showOwnScores | Boolean | true | Highlight user’s own entries |
notifyRankChanges | Boolean | true | Push notifications on rank changes |
notifyPersonalBests | Boolean | true | Push notifications on new PBs |
Offline-First Pattern
Why Offline-First?
- Instant UI: Settings appear immediately on app launch
- No blocking: Network failures don’t block the user
- 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 logoutLocal Cache Structure
SharedPreferences Name: leaderboard_settings
| Key | Type | Description |
|---|---|---|
settings_json | String | Full settings object as JSON |
user_id | String | Current user ID (for multi-user detection) |
pending_sync | Boolean | True 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:
- Local cache is already updated (user sees change immediately)
pendingSyncflag is set totrue- Next
forceSync()call will retry the upload
Parse Failures
When parsing cached JSON fails:
- Return
nulland load defaults - Log warning for debugging
Invalid Enum Values
The fromFirestoreMap() handles unknown enum values gracefully:
- Unknown
BowType→null(show all) - Unknown
TimePeriod→ALL_TIME
Testing
Test Coverage
| Component | Tests | Coverage |
|---|---|---|
| LeaderboardSettings model | 8 | 100% |
| LeaderboardSettingsService | 10 | 90% |
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)
Related Documentation
- 2025-12-30-phase4-advanced-features - Phase 4 session notes
- seasonal-transitions - Seasonal transition handling
- leaderboard-phase1-foundation - Leaderboard foundation