Привет, Хабр! Я 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 локально на устройстве. Никакого бэкенда, никакой синхронизации.

Причины:

  1. Не хочу держать сервер и платить за него

  2. Не хочу собирать персональные данные пользователей

  3. Финансовые данные — это чувствительная информация, и хранить её у себя на устройстве безопаснее для пользователя

Схема базы данных:

@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.