Привет, Хабр! Я Android-разработчик-одиночка. За последний месяц я выпустил несколько версий своего приложения Todo Budget — комбайна для задач, финансов, заметок и помодоро-таймера на Jetpack Compose. Про сам процесс разработки я уже писал раньше, но сегодня другой разговор: в v5.0 я полностью переписал главный экран — и хочу объяснить, как именно это сделано, почему такой подход, и найти людей, готовых его по-настоящему потрепать.
Что было не так с прошлым UI
До пятой версии главный экран был функциональным, но визуально скучным. Типичный Material Design без характера. Я получил несколько честных отзывов — в том числе жёстких — и решил переделать всё.
Новый Command Center строится на трёх принципах:
Glass-морфизм — карточки с прозрачностью и эффектом свечения
Кастомная палитра — Electric Cyan, Gold, Emerald вместо дефолтного серого
Анимированные фоны — градиентные переходы между состояниями
Ниже разберу каждый из этих элементов с реальным кодом.
1. Glass-морфизм в Jetpack Compose
Стандартного glass-компонента в Compose нет. Приходится собирать самому через Modifier + drawBehind + BlurMaskFilter.
fun Modifier.glassCard( blurRadius: Float = 20f, alpha: Float = 0.15f, glowColor: Color = Color.Cyan ): Modifier = this .drawBehind { val paint = Paint().asFrameworkPaint().apply { isAntiAlias = true color = android.graphics.Color.TRANSPARENT setShadowLayer(blurRadius, 0f, 0f, glowColor.copy(alpha = 0.6f).toArgb()) } drawIntoCanvas { canvas -> canvas.nativeCanvas.drawRoundRect( 0f, 0f, size.width, size.height, 24f, 24f, paint ) } } .background( color = Color.White.copy(alpha = alpha), shape = RoundedCornerShape(24.dp) ) .border( width = 1.dp, brush = Brush.linearGradient( colors = listOf( Color.White.copy(alpha = 0.4f), Color.White.copy(alpha = 0.05f) ) ), shape = RoundedCornerShape(24.dp) )
Важный момент: BlurMaskFilter не работает на всех устройствах одинаково. На некоторых старых GPU аппаратное ускорение отключает blur совсем. Поэтому всегда делайте fallback:
@Composable fun GlassCard( modifier: Modifier = Modifier, content: @Composable () -> Unit ) { val supportsBlur = remember { android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S } Box( modifier = modifier .then( if (supportsBlur) Modifier.glassCard() else Modifier.background( color = Color(0xFF1A1A2E).copy(alpha = 0.85f), shape = RoundedCornerShape(24.dp) ) ) .padding(16.dp) ) { content() } }
2. Анимированный градиентный фон
Для анимации фона я использую InfiniteTransition + animateFloat + кастомный Canvas:
@Composable fun AnimatedGradientBackground( modifier: Modifier = Modifier, content: @Composable () -> Unit ) { val infiniteTransition = rememberInfiniteTransition(label = "bg") val shift by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(8000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "shift" ) val colors = listOf( Color(0xFF0A0A1A), Color(0xFF0D1B2A), Color(0xFF1A0A2E), Color(0xFF0A1A0A) ) Box(modifier = modifier) { Canvas(modifier = Modifier.fillMaxSize()) { val gradientBrush = Brush.linearGradient( colors = colors, start = Offset(size.width * shift, 0f), end = Offset(size.width * (1f - shift), size.height) ) drawRect(brush = gradientBrush) } content() } }
Ключевая деталь: анимация идёт по оси X — начало и конец градиента сдвигаются в противоположные стороны. Это создаёт ощущение «дышащего» фона без лишних перерасчётов.
3. Свечение иконок (Glow Effect)
Эффект свечения на иконках — это тот же BlurMaskFilter, но применённый не к контейнеру, а к drawBehind конкретного элемента:
fun Modifier.glowEffect( glowColor: Color, glowRadius: Dp = 12.dp ): Modifier = this.drawBehind { val radiusPx = glowRadius.toPx() val paint = Paint().asFrameworkPaint().apply { isAntiAlias = true color = android.graphics.Color.TRANSPARENT setShadowLayer( radiusPx, 0f, 0f, glowColor.copy(alpha = 0.8f).toArgb() ) } drawIntoCanvas { canvas -> canvas.nativeCanvas.drawCircle( center.x, center.y, size.minDimension / 2f + radiusPx / 3, paint ) } }
Использование:
Icon( imageVector = Icons.Default.Star, contentDescription = null, tint = Color(0xFF00FFFF), // Electric Cyan modifier = Modifier .size(24.dp) .glowEffect(glowColor = Color(0xFF00FFFF)) )
4. Виджет баланса на рабочем столе
Отдельная история — виджет баланса. В Compose это делается через GlanceAppWidget (библиотека androidx.glance):
class BalanceWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val balance = BalanceRepository(context).getBalance() provideContent { GlanceTheme { BalanceWidgetContent(balance = balance) } } } } @Composable fun BalanceWidgetContent(balance: Double) { Column( modifier = GlanceModifier .fillMaxSize() .background(ColorProvider(Color(0xFF0D1B2A))) .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = "Баланс", style = TextStyle( color = ColorProvider(Color(0xFF00FFFF)), fontSize = 12.sp ) ) Text( text = "${balance.formatAsCurrency()} ₽", style = TextStyle( color = ColorProvider(Color.White), fontSize = 20.sp, fontWeight = FontWeight.Bold ) ) } }
Подводные камни Glance:
Нет поддержки
Canvasи кастомной отрисовки — только базовые компонентыСтейт виджета обновляется через
GlanceAppWidgetManager.updateIf<>(), а не через обычныйStateНа Android 12+ виджеты автоматически получают скруглённые углы от системы — не нужно добавлять
RoundedCornerShape
5. Архитектура: почему нет сервера
Это сознательное решение. Все данные живут в Room локально на устройстве. Никакого бэкенда, никакой синхронизации.
Причины:
Не хочу держать сервер и платить за него
Не хочу собирать персональные данные пользователей
Финансовые данные — это чувствительная информация, и хранить её у себя на устройстве безопаснее для пользователя
Схема базы данных:
@Database( entities = [Task::class, Transaction::class, Note::class, PomodoroSession::class], version = 5, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { abstract fun taskDao(): TaskDao abstract fun transactionDao(): TransactionDao abstract fun noteDao(): NoteDao abstract fun pomodoroDao(): PomodoroDao }
Миграции при обновлении версий — отдельная боль. Каждое изменение схемы требует явной миграции, иначе пользователь потеряет данные при обновлении. Я на это напоролся в v3.0 и потратил день на фикс.
val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "ALTER TABLE tasks ADD COLUMN pomodoro_count INTEGER NOT NULL DEFAULT 0" ) } }
Что я прошёл за этот месяц — честно
Первая публикация получила минусы и жёсткие отзывы: «написано ИИ», «дизайн из нулевых», «вайбкод-слоп». Часть критики была справедливой. Я переосмыслил UI, переписал главный экран и выпустил v5.0.
Главный урок: публиковать рано — нормально. Получать жёсткий фидбек — нормально. Переделывать — тоже нормально. Единственное, что не нормально — игнорировать пользователей и не меняться.
Почему нужны тестировщики, а не просто отзывы в сторе
Когда публикуешь обновление в открытый доступ, получаешь фидбек примерно так: «не работает», «плохо», «5 звёзд». Это мало помогает.
Закрытое тестирование устроено иначе. Мне нужны люди, которые:
Установят приложение и будут пользоваться им как реальным инструментом
Сообщат, если что-то упало, зависло или выглядит странно на их устройстве
Честно скажут, если новый дизайн неудобен — даже если он красивый
Это именно то, чего не даёт автотестирование. Разработчик всегда знает, как пользоваться своим приложением. Живой пользователь — нет.
Что нужно от тестировщика:
Android-устройство с версией 5.0+
10–15 минут в день в течение недели
Готовность написать пару строк о том, что сломалось или неудобно
Что я даю взамен: ранний доступ к новым фичам и возможность реально повлиять на следующий релиз.
В личные — пишите, пришлю APK или ссылку на закрытое тестирование в Play Market.
Телеграм
Приложение бесплатное, с ненавязчивой рекламой, без подписок. Таким и останется.
Если есть вопросы по реализации конкретных фич — спрашивайте в комментариях. Особенно интересно обсудить Glance-виджеты и работу с BlurMaskFilter на разных GPU.
