Arcology Engine

Agenda Notifications — Task & Habit Reminders

Contents

Introduction

TaskNotificationManager and HabitNotificationManager schedule once-daily WorkManager reminders for the agenda system. TaskReminderWorker checks for overdue tasks via AgendaRepository.countOverdueTasks() and posts a summary notification. HabitReminderWorker checks for habits due today via AgendaRepository.getHabitsDueToday() and lists any that are still pending.

These four classes mirror the quiz notification pattern in quiz/notifications.org but use separate notification channels and unique work names so task, habit, and quiz reminders can be configured independently. The enabled state and reminder time for each are stored in AppPreferences.

Design Decisions

Separate workers per feature.

Task, habit, and quiz reminders each have their own PeriodicWorkRequest. This keeps scheduling logic simple and lets the user enable each reminder independently at different times. A unified worker would reduce WorkManager bookkeeping but would couple unrelated concepts and complicate the settings UI.

Once-daily frequency.

The user chose a once-daily check for task deadline notifications. Deadlines are typically day-level events in org-mode; notifying more frequently would be noisy. Both task and habit workers run once per day at the configured time.

No network constraint.

Like the quiz reminder, neither worker needs network access. Tasks and habits are read from the local SQLite database, so the work request has no Constraints. This prevents reminders from being silently skipped when the device is offline.

Preference-backed state.

The enabled flag and time are persisted in AppPreferences, the same central store used by quiz settings. This lets ArcologyApplication re-schedule reminders on cold start and lets the settings UI read the current state without querying WorkManager futures.

Implementation

TaskReminderWorker — daily overdue-task reminder

A lightweight CoroutineWorker that counts overdue tasks and posts or clears the task reminder notification.

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/worker/TaskReminderWorker.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.TaskNotificationManager
import computer.whatthefuck.arcology.database.AgendaRepository
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

private const val TAG = "TaskReminderWorker"

/**
 * WorkManager worker that shows a daily reminder notification for overdue tasks.
 */
class TaskReminderWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams), KoinComponent {

    private val notificationManager: TaskNotificationManager by inject()
    private val agendaRepository: AgendaRepository by inject()

    override suspend fun doWork(): Result {
        return try {
            val overdueCount = agendaRepository.countOverdueTasks().toInt()
            if (overdueCount > 0) {
                notificationManager.showOverdueTasksNotification(overdueCount)
                Log.d(TAG, "Showing notification for $overdueCount overdue tasks")
            } else {
                notificationManager.cancelOverdueTasksNotification()
                Log.d(TAG, "No overdue tasks, cleared notification")
            }
            Result.success()
        } catch (e: Exception) {
            Log.e(TAG, "Error in TaskReminderWorker", e)
            Result.failure()
        }
    }
}

TaskNotificationManager — task reminder channel & scheduling

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/notification/TaskNotificationManager.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 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.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.worker.TaskReminderWorker
import java.util.Calendar
import java.util.concurrent.TimeUnit

/**
 * Manages daily reminders for overdue tasks.
 */
class TaskNotificationManager(
    private val context: Context,
    private val appPreferences: AppPreferencesInterface
) {

    companion object {
        const val CHANNEL_ID = "arcology_task"
        const val NOTIFICATION_ID = 3001
        const val WORK_NAME = "task_daily_reminder"
    }

    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    init {
        createNotificationChannel()
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                "Task Reminders",
                NotificationManager.IMPORTANCE_DEFAULT
            ).apply {
                description = "Daily reminders for overdue tasks"
                setShowBadge(false)
            }
            notificationManager.createNotificationChannel(channel)
        }
    }

    /**
     * Show a notification summarising overdue tasks.
     */
    fun showOverdueTasksNotification(count: Int) {
        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 text = when (count) {
            1 -> "1 task is overdue"
            else -> "$count tasks are overdue"
        }

        val builder = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle("Tasks Overdue")
            .setContentText(text)
            .setContentIntent(contentIntent)
            .setAutoCancel(true)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)

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

    /**
     * Cancel any outstanding overdue-task notification.
     */
    fun cancelOverdueTasksNotification() {
        notificationManager.cancel(NOTIFICATION_ID)
    }

    /**
     * Schedule a daily reminder at the configured time.
     */
    fun scheduleDailyReminder(hour: Int, minute: Int) {
        appPreferences.setTaskReminderEnabled(true)
        appPreferences.setTaskReminderHour(hour)
        appPreferences.setTaskReminderMinute(minute)

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

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

    /**
     * Cancel the daily reminder.
     */
    fun cancelDailyReminder() {
        appPreferences.setTaskReminderEnabled(false)
        WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
        notificationManager.cancel(NOTIFICATION_ID)
    }

    /**
     * Re-schedule the daily reminder if the user previously enabled it.
     */
    fun ensureDailyReminderScheduled() {
        if (appPreferences.isTaskReminderEnabled()) {
            scheduleDailyReminder(
                appPreferences.getTaskReminderHour(),
                appPreferences.getTaskReminderMinute()
            )
        }
    }

    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 (delay < 0) {
            delay += TimeUnit.DAYS.toMillis(1)
        }
        return delay
    }
}

HabitReminderWorker — daily pending-habit reminder

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/worker/HabitReminderWorker.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.HabitNotificationManager
import computer.whatthefuck.arcology.database.AgendaRepository
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

private const val TAG = "HabitReminderWorker"

/**
 * WorkManager worker that shows a daily reminder notification for pending habits.
 */
class HabitReminderWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams), KoinComponent {

    private val notificationManager: HabitNotificationManager by inject()
    private val agendaRepository: AgendaRepository by inject()

    override suspend fun doWork(): Result {
        return try {
            val habits = agendaRepository.getHabitsDueToday()
            if (habits.isNotEmpty()) {
                notificationManager.showHabitsDueNotification(habits)
                Log.d(TAG, "Showing notification for ${habits.size} pending habits")
            } else {
                notificationManager.cancelHabitsDueNotification()
                Log.d(TAG, "No pending habits, cleared notification")
            }
            Result.success()
        } catch (e: Exception) {
            Log.e(TAG, "Error in HabitReminderWorker", e)
            Result.failure()
        }
    }
}

HabitNotificationManager — habit reminder channel & scheduling

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/notification/HabitNotificationManager.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 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.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.worker.HabitReminderWorker
import computer.whatthefuck.arcology.domain.TaskHeading
import java.util.Calendar
import java.util.concurrent.TimeUnit

/**
 * Manages daily reminders for pending habits.
 */
class HabitNotificationManager(
    private val context: Context,
    private val appPreferences: AppPreferencesInterface
) {

    companion object {
        const val CHANNEL_ID = "arcology_habit"
        const val NOTIFICATION_ID = 4001
        const val WORK_NAME = "habit_daily_reminder"
    }

    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    init {
        createNotificationChannel()
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                "Daily Habits",
                NotificationManager.IMPORTANCE_DEFAULT
            ).apply {
                description = "Daily reminders for habits due today"
                setShowBadge(false)
            }
            notificationManager.createNotificationChannel(channel)
        }
    }

    /**
     * Show a notification listing habits that are due today.
     */
    fun showHabitsDueNotification(habits: List<TaskHeading>) {
        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 titles = habits.take(3).mapNotNull { it.title }.joinToString(", ")
        val remainder = habits.size - 3
        val text = if (remainder > 0) {
            "$titles and $remainder more"
        } else {
            titles
        }

        val builder = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle("Daily Habits")
            .setContentText(text)
            .setContentIntent(contentIntent)
            .setAutoCancel(true)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)

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

    /**
     * Cancel any outstanding habit notification.
     */
    fun cancelHabitsDueNotification() {
        notificationManager.cancel(NOTIFICATION_ID)
    }

    /**
     * Schedule a daily reminder at the configured time.
     */
    fun scheduleDailyReminder(hour: Int, minute: Int) {
        appPreferences.setHabitReminderEnabled(true)
        appPreferences.setHabitReminderHour(hour)
        appPreferences.setHabitReminderMinute(minute)

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

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

    /**
     * Cancel the daily reminder.
     */
    fun cancelDailyReminder() {
        appPreferences.setHabitReminderEnabled(false)
        WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
        notificationManager.cancel(NOTIFICATION_ID)
    }

    /**
     * Re-schedule the daily reminder if the user previously enabled it.
     */
    fun ensureDailyReminderScheduled() {
        if (appPreferences.isHabitReminderEnabled()) {
            scheduleDailyReminder(
                appPreferences.getHabitReminderHour(),
                appPreferences.getHabitReminderMinute()
            )
        }
    }

    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 (delay < 0) {
            delay += TimeUnit.DAYS.toMillis(1)
        }
        return delay
    }
}

Related Modules