Привет, Хабр! Продолжаю серию о разработке Todo Budget — Android-приложения, объединяющего задачи, бюджет, заметки и помодоро-таймер в одном месте. В прошлой статье я рассказывал о создании приложения и первых релизах. Сегодня — о том, как подготовил крупное обновление v4.0 с десятью новыми фичами, какие решения принимал и какие грабли собрал.

Немного контекста

Todo Budget — это приложение-комбайн: список задач с приоритетами и подзадачами, учёт доходов/расходов/долгов, помодоро-таймер и заметки с голосовым вводом. Стек: Kotlin + Jetpack Compose + Material 3 + Room + Yandex Ads. Минимальная версия Android 5.0 (API 21), целевая — Android 15 (API 35).

После публикации предыдущей версии я получил обратную связь: пользователи хотели больше аналитики, защиту данных и возможность не потерять контекст работы при сворачивании приложения. Я решил не выпускать фичи по одной, а собрать всё в один большой релиз.

Что вошло в v4.0

1. 🔒 PIN-код при запуске

Проблема: пользователи хранят финансовые данные — хотят защитить от случайного доступа.

Решение: отдельная PinLockActivity с кастомной цифровой клавиатурой на Compose. PIN сохраняется в DataStore, проверяется в MainActivity.onCreate() через runBlocking (да, blocking — но это единственный способ гарантировать, что экран не мелькнёт до проверки).

// MainActivity.kt — проверка PIN при запуске
val pinEnabled = runBlocking {
    settingsDataStore.data.map { it[PrefsKeys.PIN_ENABLED] ?: false }.first()
}
if (pinEnabled) {
    startActivity(Intent(this, PinLockActivity::class.java))
}

Кнопка «Назад» на экране PIN вызывает finishAffinity() — из приложения нельзя выйти в обход.

2. 📊 Графики аналитики (Canvas API)

Проблема: таблицы цифр — скучно. Нужна визуализация.

Решение: вместо подключения тяжёлых библиотек (MPAndroidChart, Vico) использовал Compose Canvas API напрямую. Получились два графика:

  • Круговая диаграмма расходов по категориям с цветовой легендой

  • Столбчатый график доходов vs расходов по дням недели

// Pie Chart на Canvas
Canvas(modifier = Modifier.size(200.dp)) {
    var startAngle = -90f
    slices.forEach { (category, amount) ->
        val sweep = (amount / total * 360f).toFloat()
        drawArc(
            color = categoryColor,
            startAngle = startAngle,
            sweepAngle = sweep,
            useCenter = true
        )
        startAngle += sweep
    }
}

Почему не библиотека? Для двух простых графиков Canvas API — это ~80 строк кода vs. отдельная зависимость с 2+ МБ в APK. При минимальном SDK 21 каждый мегабайт на счету.

3. 🎯 Цели накоплений

Новая Room-сущность SavingsGoal с полями: название, целевая сумма, текущая сумма, дедлайн, статус. DAO возвращает Flow<List<SavingsGoal>> — UI обновляется реактивно.

На Dashboard отображается карточка каждой цели с LinearProgressIndicator:

LinearProgressIndicator(
    progress = (goal.currentAmount / goal.targetAmount)
        .toFloat().coerceIn(0f, 1f),
    modifier = Modifier.fillMaxWidth().height(8.dp)
)

Можно пополнять через диалог — введи сумму, она прибавится к currentAmount. Когда цель достигнута — isCompleted = true.

4. 🔄 Регулярные платежи

Ещё одна Entity — RecurringTransaction. Поддержка частоты: ежедневно, еженедельно, ежемесячно, ежегодно. Тип: расход или доход. На Dashboard отображается список с возможностью удаления.

Интересный момент — FilterChip для выбора частоты:

Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
    listOf("daily" to "День", "weekly" to "Нед", 
           "monthly" to "Мес", "yearly" to "Год")
        .forEach { (key, label) ->
            FilterChip(
                selected = frequency == key,
                onClick = { frequency = key },
                label = { Text(label) }
            )
        }
}

5. 🏷️ Теги для задач

Добавил поле tags: String? в Entity Task — теги хранятся через запятую. На экране создания задачи — OutlinedTextField с подсказкой «Теги (через запятую)». В списке задач теги рендерятся как SuggestionChip:

task.tags?.split(",")
    ?.map { it.trim() }
    ?.filter { it.isNotEmpty() }
    ?.forEach { tag ->
        SuggestionChip(
            onClick = {},
            label = { Text(tag, fontSize = 10.sp) }
        )
    }

Простое решение без отдельной таблицы тегов. Для приложения такого масштаба — достаточно.

6. 🔍 Поиск в заметках

В NotesActivity добавил переключаемую строку поиска прямо в TopAppBar. Кнопка 🔍 раскрывает OutlinedTextField, фильтрация по title и content:

val filteredNotes = if (searchQuery.isBlank()) notes 
    else notes.filter {
        it.title.contains(searchQuery, ignoreCase = true) ||
        it.content.contains(searchQuery, ignoreCase = true)
    }

7. ↩️ Отмена удаления (Snackbar)

Классический паттерн: при удалении задачи/заметки показываем Snackbar с кнопкой «Отмена». Если пользователь нажимает — элемент вставляется обратно в БД:

val deleted = task.copy()
scope.launch {
    withContext(Dispatchers.IO) { db.taskDao().deleteTask(task) }
    val result = snackbarHostState.showSnackbar(
        message = "Задача удалена",
        actionLabel = "Отмена",
        duration = SnackbarDuration.Short
    )
    if (result == SnackbarResult.ActionPerformed) {
        withContext(Dispatchers.IO) { db.taskDao().insertTask(deleted) }
    }
}

8. ⏱️ Помодоро как Foreground Service

Проблема: при сворачивании приложения LaunchedEffect-таймер останавливался — корутина привязана к Composition.

Решение: PomodoroService — полноценный foreground service с уведомлением:

class PomodoroService : Service() {
    private val _remainingSeconds = MutableStateFlow(25 * 60)
    val remainingSeconds: StateFlow<Int> = _remainingSeconds
    
    fun startTimer() {
        startForeground(NOTIFICATION_ID, buildNotification())
        timerJob = scope.launch {
            while (_isRunning.value && _remainingSeconds.value > 0) {
                delay(1000)
                _remainingSeconds.value -= 1
                updateNotification()
            }
        }
    }
}

ProductivityToolsActivity биндится к сервису и подписывается на StateFlow. Таймер продолжает тикать в фоне, уведомление обновляется каждую секунду.

9. 📱 Улучшенный виджет

Раньше виджет показывал только баланс. Теперь — баланс + до 3 задач на сегодня с иконками приоритета (🔴🟡🟢):

val todayTasks = allTasks.filter { 
    !it.isCompleted && it.dueDate == today 
}

Если задач на сегодня нет — показывает ближайшие незавершённые.

10. 🎨 Единая тема

Баг: тёмная тема из настроек не применялась на некотор��х экранах. Причина — каждая Activity имела свой preferencesDataStore.

Решение: общий Preferences.kt с единым settingsDataStore:

val Context.settingsDataStore by preferencesDataStore(name = "settings")

object PrefsKeys {
    val DARK_THEME = booleanPreferencesKey("dark_theme")
    val PIN_ENABLED = booleanPreferencesKey("pin_enabled")
    val PIN_CODE = stringPreferencesKey("pin_code")
    // ...
}

Каждая Activity читает тему:

val darkTheme by settingsDataStore.data
    .map { it[PrefsKeys.DARK_THEME] ?: true }
    .collectAsState(initial = true)
TaskProTheme(useDarkTheme = darkTheme) { ... }

Архитектурные решения

Почему DataStore, а не SharedPreferences?

DataStore — это корутинный API, который автоматически записывает на Dispatchers.IO и потокобезопасен. SharedPreferences блокирует UI-поток при записи и может вызывать ANR. Для нового проекта на Compose — только DataStore.

Почему Canvas вместо библиотеки графиков?

Критерий

Canvas API

MPAndroidChart

Размер APK

+0 KB

+2 MB

Compose-совместимость

Нативная

Через AndroidView

Гибкость

Полная

Ограничена API

Время разработки

~1 час

~30 мин

Для двух простых графиков — Canvas выигрывает.

Почему Foreground Service для помодоро?

Альтернативы:

  • WorkManager — не подходит, т.к. нужно обновление каждую секунду

  • AlarmManager — слишком грубый для секундного таймера

  • LifecycleScope — умирает вместе с Activity

Foreground Service — единственный правильный способ держать поток-таймер живым. Да, треб��ет уведомление — но пользователю оно полезно (видит оставшееся время).

База данных: миграция

Версию Room БД поднял с 8 до 9. Добавились две таблицы (savings_goals, recurring_transactions) и поле tags в tasks. Использую fallbackToDestructiveMigration() — для персонального приложения это допустимо. В продакшене с тысячами пользователей написал бы полноценные миграции:

Room.databaseBuilder(context, AppDatabase::class.java, DB_NAME)
    .fallbackToDestructiveMigration()
    .build()

Размеры

Артефакт

Размер

APK (release, R8)

8.5 MB

AAB (release)

14 MB

APK (debug)

29 MB

R8 срезает размер в 3.4 раза. Для приложения с Compose + Material 3 + Yandex Ads — очень неплохо.

Что дальше

В планах:

  • Биометрия (отпечаток / Face ID) как альтернатива PIN-коду

  • Экспорт/импорт целей накоплений и регулярных платежей

  • Уведомления о достижении целей и приближении дедлайнов

  • Автоматическое создание транзакций из регулярных платежей

  • Локализация на английский

Итого

10 фич за один релиз. Ни одной внешней библиотеки не добавлено (кроме уже имеющихся). Compose Canvas оказался удобнее, чем ожидал. Foreground Service для таймера — правильное решение, которое стоило сделать с самого начала.

Приложение доступно в RuStore и на GitHub.