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