Arcology Engine

Quiz Notifications — Due-Card Alerts & Daily Reminders

Contents

Introduction

QuizNotificationManager and QuizReminderWorker are the notification infrastructure for the spaced repetition system. QuizNotificationManager creates the "Quiz Reminders" Android notification channel, shows due-cards notifications with badge counts, and schedules/cancels daily reminder periodic work. QuizReminderWorker is a lightweight CoroutineWorker that counts due flashcards once per day and either posts or clears the notification.

These two classes are consumed by QuizViewModel (updates notification after each review) and QuizSettingsScreen (daily reminder toggle).

The two classes are thin wrappers around Android's NotificationManager and WorkManager APIs. QuizNotificationManager creates the notification channel on init, builds NotificationCompat.Builder instances with PendingIntents that open MainActivity, and manages a unique periodic work request (quiz_daily_reminder) via WorkManager.enqueueUniquePeriodicWork. QuizReminderWorker uses KoinComponent with by inject() to resolve FlashcardService and QuizNotificationManager from the Koin container at runtime (WorkManager's constructor signature doesn't allow Koin-injected dependencies).

Design Decisions

KoinComponent for worker DI, not constructor injection.

QuizReminderWorker uses KoinComponent with by inject() because WorkManager requires Context, WorkerParameters constructor signature — there's no way to pass Koin dependencies through the constructor without a custom WorkerFactory. This is the same pattern used by IndexingWorker in app/data.org. The trade-off is that unit testing requires instrumented tests or a custom WorkerFactory test double.

Due-card count, not due-card content, in notifications.

The notification shows count text ("5 cards are due for review") rather than card details. This matches Android notification guidelines: notifications should be brief, actionable nudges. Tapping the notification opens MainActivity; the user then navigates to the Quiz screen manually. An alternative would be a deep link directly to QuizScreen, but the current single-top intent is simpler and avoids edge cases where Koin hasn't fully initialized yet.

PeriodicWork with initial delay for time-of-day scheduling.

scheduleDailyReminder creates a PeriodicWorkRequest with 1-day period and an initial delay calculated to hit the user's chosen hour:minute. The initial delay is reminderTime - now, or bumped to tomorrow if the time has already passed. This means the first reminder fires at the chosen time today or tomorrow, and subsequent reminders fire at 24-hour intervals.

The daily reminder no longer requires a network connection: counting due cards only touches the local database, so a NETWORK_CONNECTED constraint would silently skip the reminder when the device is offline. The enabled state and chosen time are persisted to SharedPreferences so the app can re-schedule the work on cold start and so the settings toggle reflects the real state. Previously the code queried WorkManager.getWorkInfosForUniqueWork(...).isDone, which only reports whether the returned future resolved and returned an inverted result; reading the persisted preference is the reliable source of truth.

Implementation

QuizReminderWorker — daily quiz reminder

A lightweight CoroutineWorker that counts due flashcards via FlashcardService.countDueCards() and either posts a notification (if cards are due) or clears any existing notification (if none are due). Scheduled daily by QuizNotificationManager.

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/worker/QuizReminderWorker.kt
package computer.whatthefuck.arcology.app.worker

import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import computer.whatthefuck.arcology.app.notification.QuizNotificationManager
import computer.whatthefuck.arcology.flashcard.FlashcardService
import computer.whatthefuck.arcology.indexer.IndexingConfig
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

private const val TAG = "QuizReminderWorker"

/**
 * WorkManager worker that shows a daily reminder notification for quiz sessions.
 * This worker runs periodically to check for due cards and notify the user.
 */
class QuizReminderWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams), KoinComponent {

    private val notificationManager: QuizNotificationManager by inject()
    private val flashcardService: FlashcardService by inject()

    override suspend fun doWork(): Result {
        try {
            // Get total due cards count (not limited to 100)
            val dueCount = flashcardService.countDueCards().toInt()

            if (dueCount > 0) {
                // Show notification
                notificationManager.showDueCardsNotification(dueCount)
                Log.d(TAG, "Showing notification for $dueCount due cards")
            } else {
                // Cancel any existing notification
                notificationManager.showDueCardsNotification(0)
                Log.d(TAG, "No cards due, cleared notification")
            }

            return Result.success()
        } catch (e: Exception) {
            Log.e(TAG, "Error in QuizReminderWorker", e)
            return Result.failure()
        }
    }
}

QuizNotificationManager — quiz due-cards notification channel

Manages the "Quiz Reminders" notification channel (IMPORTANCE_DEFAULT, with badge) and handles due-cards notifications, daily reminder scheduling via WorkManager periodic work, and badge count updates. The scheduleDailyReminder method enqueues QuizReminderWorker as a unique periodic work request with a calculated initial delay so it fires at the user's chosen hour/minute.

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/notification/QuizNotificationManager.kt
package computer.whatthefuck.arcology.app.notification

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.content.SharedPreferences
import androidx.core.app.NotificationCompat
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import computer.whatthefuck.arcology.app.MainActivity
import computer.whatthefuck.arcology.app.R
import computer.whatthefuck.arcology.app.worker.QuizReminderWorker
import java.util.Calendar
import java.util.concurrent.TimeUnit

/**
 * Manages notifications for the quiz/spaced repetition feature.
 * Handles due cards notifications, daily reminders, and badge updates.
 */
class QuizNotificationManager(private val context: Context) {

    companion object {
        const val CHANNEL_ID = "arcology_quiz"
        const val NOTIFICATION_ID = 2001
        const val REMINDER_NOTIFICATION_ID = 2002

        // SharedPreferences file name (matches AppPreferences for central storage)
        private const val PREFS_NAME = "arcology_prefs"

        // Preference keys
        const val PREF_DAILY_REMINDER_ENABLED = "quiz_daily_reminder_enabled"
        const val PREF_DAILY_REMINDER_HOUR = "quiz_daily_reminder_hour"
        const val PREF_DAILY_REMINDER_MINUTE = "quiz_daily_reminder_minute"
    }

    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

    init {
        createNotificationChannel()
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                "Quiz Reminders",
                NotificationManager.IMPORTANCE_DEFAULT
            ).apply {
                description = "Notifications for spaced repetition quiz sessions"
                setShowBadge(true)
            }
            notificationManager.createNotificationChannel(channel)
        }
    }

    /**
     * Show a notification with the count of due cards.
     */
    fun showDueCardsNotification(dueCount: Int) {
        if (dueCount <= 0) {
            notificationManager.cancel(NOTIFICATION_ID)
            return
        }

        val contentIntent = PendingIntent.getActivity(
            context,
            0,
            Intent(context, MainActivity::class.java).apply {
                flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
            },
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        val builder = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle("Quiz Ready")
            .setContentText(getContentText(dueCount))
            .setContentIntent(contentIntent)
            .setAutoCancel(true)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)
            .setNumber(dueCount)
            .setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)

        notificationManager.notify(NOTIFICATION_ID, builder.build())
    }

    private fun getContentText(dueCount: Int): String {
        return when {
            dueCount == 1 -> "1 card is due for review"
            dueCount < 10 -> "$dueCount cards are due for review"
            else -> "$dueCount cards are due for review"
        }
    }

    /**
     * Update the app badge count.
     * Note: Badge count support varies by device manufacturer and Android version.
     */
    fun updateBadgeCount(count: Int) {
        // Badge count API is device-specific and not available in standard Android API
        // On some devices, badges are controlled via NotificationManager
        // This is a no-op on devices that don't support it
    }

    /**
     * Schedule a daily reminder notification.
     */
    fun scheduleDailyReminder(hour: Int, minute: Int) {
        prefs.edit().apply {
            putBoolean(PREF_DAILY_REMINDER_ENABLED, true)
            putInt(PREF_DAILY_REMINDER_HOUR, hour)
            putInt(PREF_DAILY_REMINDER_MINUTE, minute)
            apply()
        }

        val workRequest = PeriodicWorkRequest.Builder(
            QuizReminderWorker::class.java,
            1, TimeUnit.DAYS
        ).apply {
            setInitialDelay(calculateInitialDelay(hour, minute), TimeUnit.MILLISECONDS)
        }.build()

        WorkManager.getInstance(context)
            .enqueueUniquePeriodicWork(
                "quiz_daily_reminder",
                androidx.work.ExistingPeriodicWorkPolicy.REPLACE,
                workRequest
            )
    }

    /**
     * Cancel the daily reminder.
     */
    fun cancelDailyReminder() {
        prefs.edit().apply {
            putBoolean(PREF_DAILY_REMINDER_ENABLED, false)
            apply()
        }
        WorkManager.getInstance(context).cancelUniqueWork("quiz_daily_reminder")
        notificationManager.cancel(REMINDER_NOTIFICATION_ID)
    }

    /**
     * Check if daily reminder is scheduled.
     *
     * Reads the persisted preference rather than querying WorkManager futures,
     * because [WorkManager.getWorkInfosForUniqueWork] returns a [ListenableFuture]
     * whose [ListenableFuture.isDone] flag only indicates whether the future
     * itself resolved, not whether work exists.
     */
    fun isDailyReminderScheduled(): Boolean {
        return prefs.getBoolean(PREF_DAILY_REMINDER_ENABLED, false)
    }

    /**
     * Re-schedule the daily reminder if the user previously enabled it.
     * Call this from [Application.onCreate] so reminders survive WorkManager
     * state loss or app updates.
     */
    fun ensureDailyReminderScheduled() {
        if (isDailyReminderScheduled()) {
            val hour = prefs.getInt(PREF_DAILY_REMINDER_HOUR, 9)
            val minute = prefs.getInt(PREF_DAILY_REMINDER_MINUTE, 0)
            scheduleDailyReminder(hour, minute)
        }
    }

    private fun calculateInitialDelay(hour: Int, minute: Int): Long {
        val now = Calendar.getInstance()
        val reminderTime = Calendar.getInstance().apply {
            set(Calendar.HOUR_OF_DAY, hour)
            set(Calendar.MINUTE, minute)
            set(Calendar.SECOND, 0)
            set(Calendar.MILLISECOND, 0)
        }

        var delay = reminderTime.timeInMillis - now.timeInMillis

        // If the time has passed today, schedule for tomorrow
        if (delay < 0) {
            delay += TimeUnit.DAYS.toMillis(1)
        }

        return delay
    }

    /**
     * Create a builder for reminder notifications.
     */
    fun buildReminderNotification(dueCount: Int): NotificationCompat.Builder {
        val contentIntent = PendingIntent.getActivity(
            context,
            0,
            Intent(context, MainActivity::class.java).apply {
                flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
            },
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        return NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle("Daily Quiz Reminder")
            .setContentText(getContentText(dueCount))
            .setContentIntent(contentIntent)
            .setAutoCancel(true)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)
    }
}

Related Modules

  • app/data.org — AppPreferences, IndexingWorker, and IndexingNotificationManager

  • quiz/viewmodel.org — QuizViewModel (calls QuizNotificationManager after each review)

  • quiz/screen.org — QuizSettingsScreen (daily reminder toggle calls QuizNotificationManager)

  • quiz/flashcard.org — FlashcardService (countDueCards consumed by QuizReminderWorker)