RoxyAPI

Menu

Build a Daily Tarot Card Feature with Push Notifications

17 min read
By Sarah Martinez
TarotDaily ReadingPush NotificationsMobile Development

Step-by-step guide for implementing daily tarot readings with timezone-aware scheduling, push notifications, local caching, and beautiful card UI. Includes complete iOS and Android code examples.

Build a Daily Tarot Card Feature with Push Notifications

Daily tarot cards are one of the most engaging features in spiritual apps. Users return every day for fresh guidance, creating habit loops that boost retention metrics dramatically. Apps like Golden Thread Tarot and Labyrinthos see 60-70% daily active user rates purely from this single feature.

This guide shows you how to build a production-ready daily tarot system with RoxyAPI: timezone-aware scheduling, push notifications at user's chosen time, local caching for instant loads, and elegant card reveal animations.

What You Will Build

By the end of this tutorial, you will have:

  • Deterministic Daily Cards: Same user + same date = same card (powered by RoxyAPI's seeded random algorithm)
  • Timezone-Aware Scheduling: Cards refresh at midnight in user's local timezone
  • Push Notifications: Configurable daily reminders ("Your daily guidance awaits")
  • Offline-First Architecture: Cached yesterday's card displays instantly while fetching today's
  • Card Reveal Animation: Flip animation from card back to revealed card front
  • Reading History: Store past daily cards for reflection and pattern analysis

Prerequisites

Development Environment:

  • iOS 16+ with Swift 5.9+ OR Android API 26+ with Kotlin 1.9+
  • RoxyAPI Tarot API key (get one here)
  • Push notification setup (APNs for iOS, FCM for Android)

API Endpoint:

POST https://roxyapi.com/api/v2/tarot/daily

Request Body:

{
  "date": "2025-12-27",
  "userId": "user_abc123"
}

Response:

{
  "date": "2026-01-09",
  "seed": "user_abc123-2026-01-09",
  "card": {
    "id": "page-of-pentacles",
    "name": "Page of Pentacles",
    "arcana": "minor",
    "suit": "pentacles",
    "number": 11,
    "position": 1,
    "reversed": true,
    "keywords": ["Lack of progress", "procrastination", "learn from failure"],
    "meaning": "The Page of Pentacles reversed says you are exploring...",
    "imageUrl": "https://roxyapi.com/img/tarot/minor/page-of-pentacles.jpg"
  },
  "dailyMessage": "Your card for 2026-01-09: Page of Pentacles (reversed)..."
}

Architecture Overview

User Opens App
    ↓
Check Local Cache (Core Data / Room)
    ↓
Display Cached Card (instant load)
    ↓
Fetch Today's Card from API (background)
    ↓
Compare Dates (has day changed?)
    ↓
If New Day: Update UI + Save to Cache
If Same Day: Keep cached card
    ↓
Schedule Next Day's Notification

This architecture provides instant feedback (cached card) while ensuring fresh content (background API call).

iOS Implementation

Step 1: Data Models

Create models for API response and local storage:

import Foundation

// API Response Model
struct DailyCardResponse: Codable {
    let date: String
    let seed: String
    let card: CardDetail
    let dailyMessage: String
    
    struct CardDetail: Codable {
        let id: String
        let name: String
        let arcana: String
        let suit: String?
        let number: Int?
        let reversed: Bool
        let keywords: [String]
        let meaning: String
        let imageUrl: String
    }
}

// SwiftData Model for Local Storage
import SwiftData

@Model
class DailyCard {
    @Attribute(.unique) var date: String
    var cardId: String
    var cardName: String
    var arcana: String
    var reversed: Bool
    var keywords: [String]
    var meaning: String
    var imageUrl: String
    var fetchedAt: Date
    
    init(date: String, response: DailyCardResponse) {
        self.date = date
        self.cardId = response.card.id
        self.cardName = response.card.name
        self.arcana = response.card.arcana
        self.reversed = response.card.reversed
        self.keywords = response.card.keywords
        self.meaning = response.card.meaning
        self.imageUrl = response.card.imageUrl
        self.fetchedAt = Date()
    }
}

Step 2: API Service with Caching

import Foundation

actor DailyCardService {
    private let apiKey: String
    private let baseURL = "https://roxyapi.com/api"
    private let userId: String
    
    init(apiKey: String, userId: String) {
        self.apiKey = apiKey
        self.userId = userId
    }
    
    func fetchDailyCard(for date: String? = nil) async throws -> DailyCardResponse {
        let todayUTC = date ?? ISO8601DateFormatter().string(from: Date()).prefix(10).description
        
        let url = URL(string: "\(baseURL)/tarot/daily")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let body: [String: String] = [
            "date": todayUTC,
            "userId": userId
        ]
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
        
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw DailyCardError.invalidResponse
        }
        
        return try JSONDecoder().decode(DailyCardResponse.self, from: data)
    }
}

enum DailyCardError: Error {
    case invalidResponse
    case networkError
    case cacheError
}

Step 3: ViewModel with Cache-First Loading

import SwiftUI
import SwiftData

@MainActor
@Observable
class DailyCardViewModel {
    var currentCard: DailyCard?
    var isLoading = false
    var error: String?
    var shouldAnimate = false
    
    private let service: DailyCardService
    private let modelContext: ModelContext
    
    init(service: DailyCardService, modelContext: ModelContext) {
        self.service = service
        self.modelContext = modelContext
    }
    
    func loadTodayCard() async {
        let todayUTC = getTodayUTC()
        
        // Step 1: Load cached card immediately
        if let cached = fetchCachedCard(for: todayUTC) {
            currentCard = cached
        } else {
            isLoading = true
        }
        
        // Step 2: Fetch fresh card from API
        do {
            let response = try await service.fetchDailyCard(for: todayUTC)
            
            // Step 3: Check if card changed (new day)
            if currentCard?.cardId != response.card.id {
                shouldAnimate = true
            }
            
            // Step 4: Update cache and UI
            let newCard = DailyCard(date: todayUTC, response: response)
            saveToCache(newCard)
            currentCard = newCard
            
        } catch {
            self.error = "Failed to fetch today's card"
        }
        
        isLoading = false
    }
    
    private func getTodayUTC() -> String {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withFullDate]
        return formatter.string(from: Date())
    }
    
    private func fetchCachedCard(for date: String) -> DailyCard? {
        let descriptor = FetchDescriptor<DailyCard>(
            predicate: #Predicate { $0.date == date }
        )
        return try? modelContext.fetch(descriptor).first
    }
    
    private func saveToCache(_ card: DailyCard) {
        modelContext.insert(card)
        try? modelContext.save()
    }
    
    func fetchHistory(limit: Int = 30) -> [DailyCard] {
        let descriptor = FetchDescriptor<DailyCard>(
            sortBy: [SortDescriptor(\.date, order: .reverse)]
        )
        return (try? modelContext.fetch(descriptor).prefix(limit).map { $0 }) ?? []
    }
}

Step 4: Daily Card View with Flip Animation

import SwiftUI
import Kingfisher

struct DailyCardView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var viewModel: DailyCardViewModel
    @State private var showingHistory = false
    @State private var isFlipped = false
    
    init(service: DailyCardService, modelContext: ModelContext) {
        _viewModel = State(wrappedValue: DailyCardViewModel(
            service: service,
            modelContext: modelContext
        ))
    }
    
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    // Header
                    VStack(spacing: 8) {
                        Text(getCurrentDate())
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                        
                        Text("Your Daily Guidance")
                            .font(.title.bold())
                    }
                    .padding(.top, 32)
                    
                    // Card Display
                    if let card = viewModel.currentCard {
                        CardDisplay(card: card, isFlipped: $isFlipped)
                            .frame(maxWidth: 320)
                            .padding(.horizontal)
                            .onAppear {
                                if viewModel.shouldAnimate {
                                    withAnimation(.spring(duration: 0.8)) {
                                        isFlipped = true
                                    }
                                    viewModel.shouldAnimate = false
                                } else {
                                    isFlipped = true
                                }
                            }
                    } else if viewModel.isLoading {
                        ProgressView()
                            .frame(height: 480)
                    }
                    
                    // Keywords
                    if let card = viewModel.currentCard, isFlipped {
                        KeywordTags(keywords: card.keywords)
                            .padding(.horizontal)
                            .transition(.opacity.combined(with: .move(edge: .top)))
                    }
                    
                    // Meaning
                    if let card = viewModel.currentCard, isFlipped {
                        Text(card.meaning)
                            .font(.body)
                            .foregroundStyle(.secondary)
                            .multilineTextAlignment(.leading)
                            .padding(.horizontal, 32)
                            .transition(.opacity.combined(with: .move(edge: .top)))
                    }
                    
                    // History Button
                    Button {
                        showingHistory = true
                    } label: {
                        Label("View Past Readings", systemImage: "clock.arrow.circlepath")
                            .font(.subheadline.weight(.medium))
                    }
                    .buttonStyle(.bordered)
                    .padding(.top, 16)
                }
            }
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        Task { await viewModel.loadTodayCard() }
                    } label: {
                        Image(systemName: "arrow.clockwise")
                    }
                }
            }
            .sheet(isPresented: $showingHistory) {
                HistoryView(cards: viewModel.fetchHistory())
            }
            .task {
                await viewModel.loadTodayCard()
            }
        }
    }
    
    private func getCurrentDate() -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "EEEE, MMMM d, yyyy"
        return formatter.string(from: Date())
    }
}

struct CardDisplay: View {
    let card: DailyCard
    @Binding var isFlipped: Bool
    
    var body: some View {
        ZStack {
            // Card Back
            RoundedRectangle(cornerRadius: 20)
                .fill(
                    LinearGradient(
                        colors: [.indigo, .purple],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                )
                .overlay {
                    Image(systemName: "sparkles")
                        .font(.system(size: 80))
                        .foregroundStyle(.white.opacity(0.3))
                }
                .opacity(isFlipped ? 0 : 1)
            
            // Card Front
            VStack(spacing: 16) {
                KFImage(URL(string: card.imageUrl))
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(maxHeight: 400)
                    .cornerRadius(12)
                
                VStack(spacing: 4) {
                    Text(card.cardName)
                        .font(.title2.bold())
                    
                    if card.reversed {
                        Text("(Reversed)")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .padding()
            .background(Color(.systemBackground))
            .cornerRadius(20)
            .shadow(color: .black.opacity(0.1), radius: 20, y: 10)
            .opacity(isFlipped ? 1 : 0)
            .rotation3DEffect(
                .degrees(isFlipped ? 0 : -90),
                axis: (x: 0, y: 1, z: 0)
            )
        }
        .frame(height: 480)
        .rotation3DEffect(
            .degrees(isFlipped ? 0 : 90),
            axis: (x: 0, y: 1, z: 0)
        )
    }
}

struct KeywordTags: View {
    let keywords: [String]
    
    var body: some View {
        FlowLayout(spacing: 8) {
            ForEach(keywords, id: \.self) { keyword in
                Text(keyword)
                    .font(.caption.weight(.medium))
                    .padding(.horizontal, 12)
                    .padding(.vertical, 6)
                    .background(Color.accentColor.opacity(0.1))
                    .foregroundStyle(.accent)
                    .cornerRadius(12)
            }
        }
    }
}

// Simple flow layout for tags
struct FlowLayout: Layout {
    var spacing: CGFloat = 8
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let result = FlowResult(
            in: proposal.replacingUnspecifiedDimensions().width,
            subviews: subviews,
            spacing: spacing
        )
        return result.size
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let result = FlowResult(
            in: bounds.width,
            subviews: subviews,
            spacing: spacing
        )
        for (index, subview) in subviews.enumerated() {
            subview.place(at: result.positions[index], proposal: .unspecified)
        }
    }
    
    struct FlowResult {
        var size: CGSize
        var positions: [CGPoint]
        
        init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) {
            var positions: [CGPoint] = []
            var size: CGSize = .zero
            var x: CGFloat = 0
            var y: CGFloat = 0
            var maxHeightInRow: CGFloat = 0
            
            for subview in subviews {
                let subviewSize = subview.sizeThatFits(.unspecified)
                
                if x + subviewSize.width > maxWidth && x > 0 {
                    x = 0
                    y += maxHeightInRow + spacing
                    maxHeightInRow = 0
                }
                
                positions.append(CGPoint(x: x, y: y))
                maxHeightInRow = max(maxHeightInRow, subviewSize.height)
                x += subviewSize.width + spacing
                size.width = max(size.width, x - spacing)
            }
            
            size.height = y + maxHeightInRow
            self.size = size
            self.positions = positions
        }
    }
}

Step 5: Push Notifications

import UserNotifications

class NotificationManager {
    static let shared = NotificationManager()
    
    func requestAuthorization() async -> Bool {
        do {
            let granted = try await UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .sound, .badge])
            return granted
        } catch {
            return false
        }
    }
    
    func scheduleDailyNotification(at hour: Int, minute: Int) {
        let content = UNMutableNotificationContent()
        content.title = "Your Daily Card Awaits"
        content.body = "Discover what the tarot has to reveal for you today"
        content.sound = .default
        content.badge = 1
        
        var dateComponents = DateComponents()
        dateComponents.hour = hour
        dateComponents.minute = minute
        
        let trigger = UNCalendarNotificationTrigger(
            dateMatching: dateComponents,
            repeats: true
        )
        
        let request = UNNotificationRequest(
            identifier: "daily-card",
            content: content,
            trigger: trigger
        )
        
        UNUserNotificationCenter.current().add(request) { error in
            if let error = error {
                print("Failed to schedule notification: \(error)")
            }
        }
    }
    
    func cancelDailyNotification() {
        UNUserNotificationCenter.current()
            .removePendingNotificationRequests(withIdentifiers: ["daily-card"])
    }
}

Step 6: Settings View for Notifications

struct DailyCardSettingsView: View {
    @AppStorage("notificationsEnabled") private var notificationsEnabled = false
    @AppStorage("notificationHour") private var notificationHour = 9
    @AppStorage("notificationMinute") private var notificationMinute = 0
    
    var body: some View {
        Form {
            Section {
                Toggle("Daily Reminders", isOn: $notificationsEnabled)
                    .onChange(of: notificationsEnabled) { _, newValue in
                        if newValue {
                            Task {
                                let granted = await NotificationManager.shared.requestAuthorization()
                                if granted {
                                    scheduleNotification()
                                } else {
                                    notificationsEnabled = false
                                }
                            }
                        } else {
                            NotificationManager.shared.cancelDailyNotification()
                        }
                    }
                
                if notificationsEnabled {
                    DatePicker(
                        "Notification Time",
                        selection: Binding(
                            get: { dateFromComponents() },
                            set: { updateComponents(from: $0) }
                        ),
                        displayedComponents: .hourAndMinute
                    )
                    .onChange(of: notificationHour) { scheduleNotification() }
                    .onChange(of: notificationMinute) { scheduleNotification() }
                }
            } header: {
                Text("Notifications")
            } footer: {
                Text("Receive a reminder at your chosen time every day")
            }
        }
        .navigationTitle("Daily Card Settings")
    }
    
    private func dateFromComponents() -> Date {
        var components = DateComponents()
        components.hour = notificationHour
        components.minute = notificationMinute
        return Calendar.current.date(from: components) ?? Date()
    }
    
    private func updateComponents(from date: Date) {
        let components = Calendar.current.dateComponents([.hour, .minute], from: date)
        notificationHour = components.hour ?? 9
        notificationMinute = components.minute ?? 0
    }
    
    private func scheduleNotification() {
        NotificationManager.shared.scheduleDailyNotification(
            at: notificationHour,
            minute: notificationMinute
        )
    }
}

Android Implementation

Step 1: Data Models (Kotlin)

// API Response
@Serializable
data class DailyCardResponse(
    val date: String,
    val seed: String,
    val card: CardDetail,
    val dailyMessage: String
)

@Serializable
data class CardDetail(
    val id: String,
    val name: String,
    val arcana: String,
    val suit: String?,
    val number: Int?,
    val reversed: Boolean,
    val keywords: List<String>,
    val meaning: String,
    val imageUrl: String
)

// Room Entity
@Entity(tableName = "daily_cards")
data class DailyCardEntity(
    @PrimaryKey val date: String,
    val cardId: String,
    val cardName: String,
    val arcana: String,
    val reversed: Boolean,
    val keywords: String, // JSON array
    val meaning: String,
    val imageUrl: String,
    val fetchedAt: Long
)

@Dao
interface DailyCardDao {
    @Query("SELECT * FROM daily_cards WHERE date = :date")
    suspend fun getCard(date: String): DailyCardEntity?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertCard(card: DailyCardEntity)
    
    @Query("SELECT * FROM daily_cards ORDER BY date DESC LIMIT :limit")
    suspend fun getHistory(limit: Int): List<DailyCardEntity>
}

Step 2: Repository with Cache-First Loading

class DailyCardRepository(
    private val api: TarotAPI,
    private val dao: DailyCardDao,
    private val apiKey: String,
    private val userId: String
) {
    fun getTodayCard(): Flow<Resource<DailyCardEntity>> = flow {
        val todayUTC = getTodayUTC()
        
        // Emit cached card immediately
        val cached = dao.getCard(todayUTC)
        if (cached != null) {
            emit(Resource.Success(cached))
        } else {
            emit(Resource.Loading())
        }
        
        // Fetch fresh card from API
        try {
            val request = DailyCardRequest(date = todayUTC, userId = userId)
            val response = api.getDailyCard(request, apiKey)
            
            val entity = DailyCardEntity(
                date = response.date,
                cardId = response.card.id,
                cardName = response.card.name,
                arcana = response.card.arcana,
                reversed = response.card.reversed,
                keywords = Json.encodeToString(response.card.keywords),
                meaning = response.card.meaning,
                imageUrl = response.card.imageUrl,
                fetchedAt = System.currentTimeMillis()
            )
            
            dao.insertCard(entity)
            emit(Resource.Success(entity))
            
        } catch (e: Exception) {
            emit(Resource.Error(e.message ?: "Failed to fetch daily card"))
        }
    }
    
    private fun getTodayUTC(): String {
        val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply {
            timeZone = TimeZone.getTimeZone("UTC")
        }
        return formatter.format(Date())
    }
}

sealed class Resource<out T> {
    data class Success<T>(val data: T) : Resource<T>()
    data class Error(val message: String) : Resource<Nothing>()
    class Loading<T> : Resource<T>()
}

Step 3: ViewModel

class DailyCardViewModel(
    private val repository: DailyCardRepository
) : ViewModel() {
    
    private val _cardState = MutableStateFlow<Resource<DailyCardEntity>>(Resource.Loading())
    val cardState: StateFlow<Resource<DailyCardEntity>> = _cardState.asStateFlow()
    
    init {
        loadTodayCard()
    }
    
    fun loadTodayCard() {
        viewModelScope.launch {
            repository.getTodayCard()
                .collect { resource ->
                    _cardState.value = resource
                }
        }
    }
    
    fun getHistory(): Flow<List<DailyCardEntity>> = flow {
        emit(repository.dao.getHistory(30))
    }
}

Step 4: Compose UI with Flip Animation

@Composable
fun DailyCardScreen(viewModel: DailyCardViewModel) {
    val cardState by viewModel.cardState.collectAsState()
    var isFlipped by remember { mutableStateOf(false) }
    
    LaunchedEffect(cardState) {
        if (cardState is Resource.Success) {
            delay(300)
            isFlipped = true
        }
    }
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Daily Tarot") },
                actions = {
                    IconButton(onClick = { viewModel.loadTodayCard() }) {
                        Icon(Icons.Default.Refresh, "Refresh")
                    }
                }
            )
        }
    ) { padding ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            when (val state = cardState) {
                is Resource.Loading -> {
                    CircularProgressIndicator(
                        modifier = Modifier.align(Alignment.Center)
                    )
                }
                is Resource.Success -> {
                    DailyCardContent(
                        card = state.data,
                        isFlipped = isFlipped,
                        modifier = Modifier.fillMaxSize()
                    )
                }
                is Resource.Error -> {
                    ErrorMessage(
                        message = state.message,
                        onRetry = { viewModel.loadTodayCard() },
                        modifier = Modifier.align(Alignment.Center)
                    )
                }
            }
        }
    }
}

@Composable
fun DailyCardContent(
    card: DailyCardEntity,
    isFlipped: Boolean,
    modifier: Modifier = Modifier
) {
    val rotation by animateFloatAsState(
        targetValue = if (isFlipped) 180f else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "card-flip"
    )
    
    LazyColumn(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally,
        contentPadding = PaddingValues(16.dp)
    ) {
        item {
            // Header
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.padding(vertical = 24.dp)
            ) {
                Text(
                    text = getCurrentDate(),
                    style = MaterialTheme.typography.labelLarge,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Your Daily Guidance",
                    style = MaterialTheme.typography.headlineMedium,
                    fontWeight = FontWeight.Bold
                )
            }
        }
        
        item {
            // Card with flip animation
            Card(
                modifier = Modifier
                    .size(width = 280.dp, height = 420.dp)
                    .graphicsLayer {
                        rotationY = rotation
                        cameraDistance = 12f * density
                    },
                elevation = CardDefaults.cardElevation(8.dp)
            ) {
                Box(modifier = Modifier.fillMaxSize()) {
                    if (rotation < 90f) {
                        // Card Back
                        Box(
                            modifier = Modifier
                                .fillMaxSize()
                                .background(
                                    Brush.linearGradient(
                                        colors = listOf(
                                            MaterialTheme.colorScheme.primary,
                                            MaterialTheme.colorScheme.tertiary
                                        )
                                    )
                                ),
                            contentAlignment = Alignment.Center
                        ) {
                            Icon(
                                Icons.Default.Star,
                                contentDescription = null,
                                modifier = Modifier.size(120.dp),
                                tint = Color.White.copy(alpha = 0.3f)
                            )
                        }
                    } else {
                        // Card Front
                        Column(
                            modifier = Modifier
                                .fillMaxSize()
                                .padding(16.dp)
                                .graphicsLayer { rotationY = 180f },
                            horizontalAlignment = Alignment.CenterHorizontally
                        ) {
                            AsyncImage(
                                model = card.imageUrl,
                                contentDescription = card.cardName,
                                modifier = Modifier
                                    .weight(1f)
                                    .fillMaxWidth()
                                    .clip(RoundedCornerShape(12.dp)),
                                contentScale = ContentScale.Fit
                            )
                            
                            Spacer(modifier = Modifier.height(16.dp))
                            
                            Text(
                                text = card.cardName,
                                style = MaterialTheme.typography.titleLarge,
                                fontWeight = FontWeight.Bold
                            )
                            
                            if (card.reversed) {
                                Text(
                                    text = "(Reversed)",
                                    style = MaterialTheme.typography.bodySmall,
                                    color = MaterialTheme.colorScheme.onSurfaceVariant
                                )
                            }
                        }
                    }
                }
            }
        }
        
        if (isFlipped) {
            item {
                Spacer(modifier = Modifier.height(24.dp))
                
                // Keywords
                val keywords = Json.decodeFromString<List<String>>(card.keywords)
                FlowRow(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    keywords.forEach { keyword ->
                        AssistChip(
                            onClick = {},
                            label = { Text(keyword) }
                        )
                    }
                }
            }
            
            item {
                Spacer(modifier = Modifier.height(24.dp))
                
                // Meaning
                Text(
                    text = card.meaning,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

private fun getCurrentDate(): String {
    val formatter = SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault())
    return formatter.format(Date())
}

Step 5: WorkManager for Daily Refresh

class DailyCardWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {
    
    override async fun doWork(): Result {
        return try {
            // Pre-fetch tomorrow's card in background
            val repository = DailyCardRepository(/* inject dependencies */)
            repository.getTodayCard().collect { /* cache result */ }
            
            // Schedule notification
            showNotification()
            
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
    
    private fun showNotification() {
        val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_tarot)
            .setContentTitle("Your Daily Card Awaits")
            .setContentText("Discover what the tarot reveals for you today")
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setAutoCancel(true)
            .build()
        
        NotificationManagerCompat.from(applicationContext)
            .notify(NOTIFICATION_ID, notification)
    }
    
    companion object {
        const val CHANNEL_ID = "daily_card_channel"
        const val NOTIFICATION_ID = 1
    }
}

// Schedule in Application class
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        scheduleDailyCardWorker()
    }
    
    private fun scheduleDailyCardWorker() {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
        
        val dailyWork = PeriodicWorkRequestBuilder<DailyCardWorker>(
            repeatInterval = 1,
            repeatIntervalTimeUnit = TimeUnit.DAYS
        )
            .setConstraints(constraints)
            .setInitialDelay(calculateInitialDelay(), TimeUnit.MILLISECONDS)
            .build()
        
        WorkManager.getInstance(this)
            .enqueueUniquePeriodicWork(
                "daily_card_worker",
                ExistingPeriodicWorkPolicy.KEEP,
                dailyWork
            )
    }
    
    private fun calculateInitialDelay(): Long {
        val calendar = Calendar.getInstance().apply {
            set(Calendar.HOUR_OF_DAY, 9)
            set(Calendar.MINUTE, 0)
            set(Calendar.SECOND, 0)
            
            if (timeInMillis < System.currentTimeMillis()) {
                add(Calendar.DAY_OF_YEAR, 1)
            }
        }
        
        return calendar.timeInMillis - System.currentTimeMillis()
    }
}

Production Tips

1. Handle Timezone Edge Cases

Users traveling across timezones may see card changes mid-day. Solutions:

Lock card to morning fetch:

@AppStorage("lastFetchDate") private var lastFetchDate: String = ""
@AppStorage("lastFetchTimezone") private var lastFetchTimezone: String = ""

func shouldRefreshCard() -> Bool {
    let currentDate = getTodayUTC()
    let currentTimezone = TimeZone.current.identifier
    
    return lastFetchDate != currentDate || lastFetchTimezone != currentTimezone
}

Alternative: Lock to local midnight:

fun getTodayLocal(): String {
    val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply {
        timeZone = TimeZone.getDefault() // User's timezone
    }
    return formatter.format(Date())
}

Choose based on your UX goals: UTC consistency vs local-time accuracy.

2. Pre-fetch Tomorrow's Card

Reduce perceived latency by pre-fetching at 11:50 PM:

func scheduleTomorrowPreFetch() {
    let center = UNUserNotificationCenter.current()
    
    var components = DateComponents()
    components.hour = 23
    components.minute = 50
    
    // Silent background fetch trigger
    // Implementation varies by platform
}

3. Offline Fallback Messaging

When network fails, show cached card with clear indicator:

if let error = viewModel.error, viewModel.currentCard == nil {
    ContentUnavailableView(
        "No Internet Connection",
        systemImage: "wifi.slash",
        description: Text("Cannot fetch today's card. Check your connection.")
    )
} else if let card = viewModel.currentCard {
    CardDisplay(card: card, isFlipped: $isFlipped)
    
    if viewModel.error != nil {
        HStack {
            Image(systemName: "exclamationmark.triangle")
            Text("Showing cached card from \(card.fetchedAt.formatted())")
        }
        .font(.caption)
        .foregroundStyle(.orange)
    }
}

4. A/B Test Notification Copy

Daily notifications determine retention. Test variations:

  • "Your daily guidance awaits ✨"
  • "What does the tarot reveal today? 🔮"
  • "Your (DayOfWeek) tarot reading is ready"
  • "Discover today's message from the cards"

Track open rates and correlate with next-day retention.

Next Steps

You now have a production-ready daily tarot feature! Enhance it further:

  1. Streaks: Track consecutive days opened, reward with badges
  2. Journaling: Let users add notes to each daily card
  3. Reminders: "Come back tonight to reflect on your morning card"
  4. Social Sharing: Generate beautiful card images for Instagram Stories
  5. Card Collections: Highlight rare cards (Death, Tower) in user's history

Troubleshooting

Q: Users see different cards on different devices A: Ensure you pass consistent userId parameter to API. Store user ID in Keychain (iOS) or EncryptedSharedPreferences (Android).

Q: Notifications not appearing A: Check authorization status, notification channel setup (Android), and background fetch capabilities (iOS).

Q: Card changes mid-day A: Lock card to morning fetch timezone or use local midnight calculation (see Timezone Edge Cases above).

Resources

  • RoxyAPI Tarot Docs: roxyapi.com/docs
  • Push Notification Setup: Apple Developer Docs, Firebase Cloud Messaging
  • Sample Code: GitHub repository with complete implementation

About the Author: Sarah Martinez is a mobile developer specializing in spiritual and wellness apps, with apps in production serving 500k+ daily active users across iOS and Android platforms.