Привет, Хабр! Продолжаю серию о разработке 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 для таймера — правильное решение, которое стоило сделать с самого начала.
Комментарии (9)

impalex
24.02.2026 11:39Прошу прощения, я немного покритикую.
PIN сохраняется в DataStore
Держать "чувствительную" информацию в DataStore - не самая лучшая идея. Хотя бы шифруйте как-нибудь (если что, для этого есть библиотеки). Делайте лучше биометрию, там ничего сложного, и доверьте заботу о безопасности устройству.
Сервис в таком виде, что сейчас - не самый лучший вариант. Если бы Вы публиковались в Google Play, то с большой вероятностью приложение завернули бы на модерации.
Обновление уведомления каждую секунду - нагрузка на систему, лишний расход батареи.
Использование delay(1000) для измерения времени - плохая практика. Вызов delay(1000) не даст ровно одну секунду, со временем накопится дрифт. Используйте System.currentTimeMillis() при измерении отрезков времени.
Из контекста не ясно, откуда берется scope. У Service() нет своего лайфцикла (речь про lifecycleScope). Не знаю, что там за кадром, но в теории, если быть неаккуратным со scope, можно получить утечку памяти.
Если сервис нужен только для таймера, а я не вижу больше ничего, обратите внимание на методы setUsesChronometer и setChronometerCountdown у уведомлений.

develmax
24.02.2026 11:39Было бы гораздо понятнее и интереснее, если бы вы код и описание иллюстрировали скринами вашего приложения.

JDJ
24.02.2026 11:39стало только хуже в плане кол-ва багов, я так и не увидел уведомление от таймера, и не увидел запрос на его отображение, MIUI android 13 , мало того это какой то общий счётчик, если таймер запусить то можно переключать варианты , фокус, и оба перерыва, и он просто начинается заного, при повороте экрана он останавливается, при переключении вариантов он сам запускается, но больше всего мне понравилась математика аналитики с максимумом в 99% ,ну и хотелоь бы всё таки видеть статус бар телефона


dev-gvs
Нет, можно и нужно использовать
androidx.core:core-splashscreenс проверкой вsetKeepOnScreenConditionили черезViewTreeObserver.OnPreDrawListener, как в документации.Emi_Dev Автор
спасибо за совет, приму во внимание, изучу