Build a Daily Tarot Card Feature with Push Notifications
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:
- Streaks: Track consecutive days opened, reward with badges
- Journaling: Let users add notes to each daily card
- Reminders: "Come back tonight to reflect on your morning card"
- Social Sharing: Generate beautiful card images for Instagram Stories
- 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.