Если коротко: это не статья про “я нарисовал смешной экран”. Внутри пришлось решать вполне обычные мобильные проблемы: как получить шаги на Android, как пережить перезапуск приложения, как не сломать локальные уведомления, как восстановить состояние после открытия пуша и как не поверить Expo Pedometer слишком рано.

Большинство фитнес-приложений устроены примерно одинаково: они считают шаги, закрывают кольца, хвалят за активность и мягко намекают, что вы снова недостаточно хороши.

Мне стало интересно, что будет, если перевернуть эту механику наоборот.

Не мотивировать пользователя двигаться больше, а защищать его право не суетиться. Не поздравлять с 10 000 шагов, а спрашивать: “Кому ты что доказываешь?” Не закрывать кольца активности, а радоваться, когда пользователь наконец-то оставил их в покое.

До этого я сделал прокси-шакализатор - сервис для осознанной деградации сайтов, который неожиданно дошёл до финала конкурса Яндекса “Сделано с ИИ”. Видимо, тема деградации оказалась перспективнее, чем планировалось.

Поэтому следующим логичным шагом было перейти от деградации сайтов к деградации пользователя.

Так появился Stoned - анти-шагомер на Expo и React Native.

Это мобильное приложение, которое считает шаги как моральный ущерб, награждает за неподвижность, отправляет токсичные локальные пуши, проводит ежедневный чек-ап “я живой, но мне лень” и делает вид, что лень - это не слабость, а премиальный lifestyle-продукт.

Абсурдная идея довольно быстро перестала быть просто шуткой. Чтобы анти-шагомер работал как настоящее Android-приложение, пришлось разбираться с датчиками, background tasks, локальными уведомлениями, native step counter, Zustand, восстановлением состояния после пушей и production-граблями Expo.

В этой статье как я собирал offline-first приложение без бэкенда, почему Pedometer на Android не всегда говорит правду и как кнопка “я живой, но мне лень” внезапно превратилась в маленькую state machine.

Что это вообще такое

Обычный wellness-продукт говорит:

“Ты молодец, ты прошёл 10 000 шагов”.

Stoned говорит:

“Кому ты что доказываешь?”

Обычный фитнес-трекер мотивирует закрывать кольца. Stoned делает кольцо, которое лучше выглядит, когда вы его не портите шагами.

Обычное приложение присылает заботливые пуши. Stoned отправляет локальное уведомление в духе:

“Оптимизация покоя провалена”

Обычный профиль показывает прогресс. Stoned выдаёт Stoned ID - карточку человека, который понял: личная эффективность переоценена, а диван нет.

В первой версии есть четыре основных экрана:

Покой - главный экран с шагами, таймером неподвижности, стриком и сэкономленными калориями;

Чек-ап - ежедневная проверка, что пользователь жив, но не слишком активен;

Коллекция - анти-достижения за статику, суету и странные состояния;

Профиль - Stoned ID, фейковый premium-gate и share-карточка для сторис.

Задача

Я не хотел делать просто мемный экран с кнопкой “я лежу”. Хотелось собрать полноценное мобильное приложение, у которого абсурдная идея держится на настоящей технической логике.

Требования к первой версии были такие:

  • считать шаги;

  • фиксировать неподвижность;

  • отправлять локальные пуши;

  • выдавать ачивки;

  • запускать ежедневный чек-ап;

  • работать без интернета;

  • не иметь бэкенда;

  • не полагаться на внешние API;

  • устанавливаться как обычное Android-приложение и работать без сервера после установки.

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

Поэтому всё живёт на устройстве.

Стек

Стек получился минималистичный, но не игрушечный:

  • Expo / React Native;

  • TypeScript; • Zustand;

  • expo-sensors;

  • expo-notifications;

  • expo-background-fetch;

  • expo-task-manager;

  • небольшой native Android-модуль для TYPE_STEP_COUNTER;

  • react-native-view-shot для генерации story-карточки.

Почему Expo?

Потому что на первой версии хочется быстро проверять продуктовую гипотезу, даже если само приложение осуждает движение.

Почему Zustand?

Потому что вся логика должна жить локально, переживать перезапуск приложения и не превращаться в Redux-музей.

Почему пришлось лезть в native Android?

Потому что Android-датчики - это место, где оптимизм заканчивается, а начинается полевая инженерия.

Архитектура

Приложение разделено на три слоя.

src/store

  useStoneStore.ts

src/services

  backgroundWorker.ts
  notifications.ts
  checkupChallenge.ts
  pedometer.ts
  nativeStepCounter.ts

src/screens

  DashboardScreen.tsx
  CheckUpChallengeScreen.tsx
  AntiAchievementsScreen.tsx
  ShareCardScreen.tsx

store - источник правды. Там шаги, стрик, состояние чек-апа, ачивки, дневные сбросы, имя пользователя, premium-лень и прочая локальная бюрократия.

services - всё, что может укусить: датчики, пуши, фоновые задачи, расписание чек-апа, native step counter.

screens - композиция UI и вызовы сервисов. Экран не должен сам решать, когда пользователь стал “Фантомным Курьером”. Он должен просто показать диагноз.

Главный принцип: экран не вычисляет продуктовую реальность. Он только показывает то, что уже посчитали сервисы и store.

Первый слой боли: шагомер

На бумаге шагомер выглядит как одна строчка:

const result = await Pedometer.getStepCountAsync(startOfDay, now);

В жизни эта строчка быстро превращается в маленький философский трактат о доверии.

На Android Pedometer может:

  • быть недоступен;

  • не отдавать диапазон за день;

  • работать только в foreground;

  • молчать на конкретном телефоне;

  • вести себя иначе после переустановки;

  • проигрывать борьбу системному энергосбережению.

Первый полевой тест на моем Nothing Phone 1 выглядел примерно так: доступ к активности выдан, пользователь прошёлся, приложение смотрит в пустоту и делает вид, что всё нормально.

Для приложения, которое ругает за шаги, это неловкая ситуация.

Пришлось добавлять уровни деградации.

  1. Если доступен нативный Android-сервис - берём шаги из него.

  2. Если нет - пробуем Expo Pedometer.

  3. Если и он молчит - включаем estimated-режим через акселерометр в открытом приложении.

    Фоновое чтение шагов в итоге стало выглядеть примерно так:

const readBackgroundStepCountAsync = async (now: Date) => {
  if (isNativeStepCounterAvailable()) {
    const snapshot = await getNativeStepCounterSnapshotAsync();
    if (snapshot.sensorAvailable || snapshot.running) {
      return snapshot.todaySteps;
    }
  }
  const pedometerAvailable = await Pedometer.isAvailableAsync();
  if (!pedometerAvailable) {
    return null;
  }
  return getTodayStepCountAsync(now);
};

Да, estimated steps - это не медицинский шагомер.

Но это и не медицинское приложение. Это сатирический счётчик суеты. Для него приблизительная фиксация “пользователь опять куда-то понёс телефон” лучше, чем честный ноль.

Почему native Android всё-таки понадобился

Expo хорошо подходит для быстрой сборки интерфейса и проверки идеи, но шаги на Android - это отдельная реальность. В Android есть аппаратный датчик TYPE_STEP_COUNTER. Он считает количество шагов с момента последней перезагрузки устройства. Это не “шаги за сегодня”, а общий счётчик. Поэтому задача приложения не просто прочитать число, а превратить его в дневную статистику.

Условная логика такая:

  • получить текущее значение сенсора;

  • сохранить baseline на начало дня;

  • сравнивать текущее значение с baseline;

  • при смене локального дня обновлять baseline;

  • учитывать перезагрузку устройства или странный сброс значения;

  • не доверять одному источнику, если он внезапно начал возвращать мусор.

То есть шагомер быстро перестаёт быть “получить число” и становится системой доверия к данным. Особенно когда приложение должно жить без сервера, без аккаунта и без возможности сказать: “ну мы потом это восстановим из облака”.

Второй слой боли: background fetch - это не cron

Следующая наивная идея:

давайте приложение будет каждые 15–30 минут просыпаться, читать шаги и отправлять пуш.

Звучит как cron. Но expo-background-fetch - это не cron. Это просьба к операционной системе. Очень вежливая просьба. Операционная система может её услышать, может проигнорировать, может вспомнить через час, а может решить, что ваш анти-шагомер не входит в число стратегических задач человечества. Поэтому фоновая логика должна быть идемпотентной и терпимой к задержкам.

Воркер делает следующее:

  • проверяет смену локального дня;

  • истекает просроченный чек-ап;

  • читает текущие шаги;

  • проверяет токсичные thresholds;

  • отправляет локальный пуш при пересечении порога;

  • планирует ежедневный чек-ап, если пользователь достаточно долго неподвижен;

  • сохраняет минимум служебного состояния, чтобы не отправлять одни и те же уведомления повторно.

Пример фоновой синхронизации:

export const runBackgroundSyncAsync = async () => {
  const resetAt = new Date();
  useStoneStore.getState().resetForNewDayIfNeeded(resetAt);
  maybeExpireOverdueChallenge();
  const store = useStoneStore.getState();
  const previousSteps = store.todaySteps;
  const steps = await readBackgroundStepCountAsync(new Date());
  if (steps === null) {
    await maybeNotifyImmobilityAsync();
    await maybeScheduleDailyChallengeAsync();
    return BackgroundFetch.BackgroundFetchResult.NoData;
  }
  store.syncTodaySteps(steps, new Date());
  if (hasCrossedShameThreshold(previousSteps, steps)) {
    await sendStepShamePushAsync(steps);
  }
  await maybeNotifyImmobilityAsync();
  await maybeScheduleDailyChallengeAsync();
  return previousSteps !== steps
    ? BackgroundFetch.BackgroundFetchResult.NewData
    : BackgroundFetch.BackgroundFetchResult.NoData;
};

Здесь есть важная бытовая деталь: дневной сброс нельзя делать по UTC. Пользователь живёт не в toISOString(). Он живёт в своём часовом поясе, с локальной полночью и локальным ощущением, что вчерашняя суета должна остаться вчера. Поэтому дата для дневного состояния считается локально, а не через UTC-ключ.

Примерно так:

const getLocalDateKey = (date: Date) => {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return ${year}-${month}-${day};
};

Мелочь, но если её не учесть, приложение начинает жить по календарю, который пользователь не заказывал.

Третий слой боли: токсичные пуши без сервера

Stoned должен уметь отправлять пуши полностью локально.

Не через Firebase.

Не через backend.

Не через “мы потом добавим аналитику”.

Приложение само решает, что пользователь опять слишком активно существует, и само же сообщает ему об этом.

Например:

  • шаги пересекли порог - летит уведомление “Утечка энергии”;

  • долго нет движения - приложение хвалит пользователя за стратегическую статику;

  • пришло время чек-апа - открывается 10-минутное окно подозрений.

Пример планирования чек-апа:

export const scheduleDailyCheckupPromptAsync = async (scheduledFor: Date) => {
  const expiresAt = new Date(
    scheduledFor.getTime() + CHECKUP_RESPONSE_WINDOW_MS
  );
  const notificationId = await Notifications.scheduleNotificationAsync({
    content: {
      title: "Чек-ап неподвижности",
      body: pickRandomTemplate("checkup"),
      data: {
        type: "checkup",
        scheduledFor: scheduledFor.toISOString(),
        expiresAt: expiresAt.toISOString(),
        build: buildLabel,
        buildNumber: String(buildInfo.buildNumber),
      },
    },
    trigger: {
      type: Notifications.SchedulableTriggerInputTypes.DATE,
      date: scheduledFor,
      channelId: ANDROID_CHANNEL_ID,
    },
  });
  useStoneStore
    .getState()
    .scheduleCheckupChallenge(scheduledFor, expiresAt);
  return { notificationId, expiresAt };
};

Самое важное здесь - payload уведомления.

Если локальный пуш открывает экран, но не передаёт время события и дедлайн, пользователь попадает в пустое состояние. Для чек-апа это критично: окно ответа должно восстанавливаться из самого уведомления. Иначе приложение говорит “у тебя 10 минут”, пользователь нажимает на пуш через 4 минуты, а приложение такое:

“Я не знаю, кто ты и зачем пришёл”.

Для анти-шагомера это слишком много экзистенциальной неопределённости.

Чек-ап неподвижности

Это самая важная и самая глупая механика приложения.

Раз в день Stoned может прислать пуш:

“Докажи, что ты не умер. У тебя есть 10 минут”.

Пользователь открывает экран чек-апа. Там большая кнопка:

Но просто нажать мало. Нужно нажать так плавно, чтобы телефон почти не дрогнул. То есть приложение проверяет не только факт жизни, но и качество неподвижности.

Алгоритм такой:

  1. Пользователь нажимает кнопку.

  2. Приложение ждёт небольшую задержку, чтобы убрать шум тапа.

  3. 900 мс собирает акселерометр и гироскоп.

  4. Считает peak delta по модулю.

  5. Если пик выше порога - рука выдала стартап-питч, стрик сгорает.

const SAMPLE_INTERVAL_MS = 60;
const SETTLE_DELAY_MS = 250;
const CAPTURE_WINDOW_MS = 900;
const ACCELEROMETER_THRESHOLD = 0.045;
const GYROSCOPE_THRESHOLD = 0.09;
const magnitude = ({ x, y, z }: MotionSample) =>
  Math.sqrt(x  x + y  y + z * z);
const getPeakDelta = (samples: MotionSample[]) => {
  if (samples.length < 2) {
    return Number.POSITIVE_INFINITY;
  }
  let peak = 0;
  for (let index = 1; index < samples.length; index += 1) {
    const previous = magnitude(samples[index - 1]);
    const current = magnitude(samples[index]);
    peak = Math.max(peak, Math.abs(current - previous));
  }
  return peak;
};

Поверх этого появилась полноценная state machine.

У чек-апа есть несколько состояний:

  • idle - сегодня ничего не запланировано;

  • scheduled - пуш запланирован;

  • pending - окно ответа открыто;

  • checking - приложение собирает данные с датчиков;

  • passed - пользователь доказал, что жив, но не слишком;

  • failed - рука дрогнула;

  • expired - пользователь проигнорировал дедлайн.

И вот это был один из забавных моментов разработки. Снаружи фича выглядит как шутка. Внутри она требует нормального состояния, дедлайнов, восстановления после открытия из пуша, проверки просрочки, обработки датчиков и защиты от повторного прохождения. Абсурдный продукт внезапно начинает требовать вполне взрослой дисциплины.

Ачивки: дофамин за отсутствие дофамина

В приложении сейчас 19 анти-достижений. Они делятся на несколько групп.

Базовый покой

  • “Первичная кристаллизация” - 1 час без движения;

  • “Резонанс Пустоты” - 12 часов без движения;

  • “Цифровой Мох” - 24 часа без движения.

Утечка энергии

  • “Дешёвый Дофамин” - больше 5000 шагов;

  • “Фантомный Курьер” - 10 000 шагов;

  • “Бегство от себя” - 20 000 шагов;

  • “Ночной Невроз” - шаги между 2:00 и 4:00.

Соматический сенсор

  • “Хирург в Нирване” - пройти чек-ап почти идеально;

  • “Тремор Достигатора” - провалить чек-ап;

  • “Управляемый Риск” - нажать в последнюю минуту.

Скрытые состояния

Это уже мета-слой. Условия скрыты, потому что приложению лень их объяснять. Самое смешное, что коллекция не декоративная. Условия реально считаются в Zustand, тестируются и сохраняются локально. Где-то в проекте есть тест, который проверяет, что пользователь получил достижение за капитуляцию перед диваном.

И я считаю, что ради таких моментов TypeScript и существует.

Логика ачивок живёт рядом с состоянием, но не в UI. Экран коллекции не должен знать, как именно человек получил “Цифровой Мох”. Он должен только отобразить факт морального разложения.

Условно это выглядит так:

const unlockAchievementIfNeeded = (
  achievementId: AchievementId,
  unlockedAt = new Date()
) => {
  const store = useStoneStore.getState();
  if (store.unlockedAchievements.includes(achievementId)) {
    return;
  }
  store.unlockAchievement(achievementId, unlockedAt);
};

Главная защита здесь - от повторного начисления и от рассинхронизации условий.

Если пуш ругает пользователя после 5000 шагов, UI тоже должен говорить про 5000 шагов. Если экран обещает “моральное давление после 5000”, а уведомление прилетает на 1600, это уже не сатира.

Это баг.

Share-карточка: потому что абсурд должен быть экспортируемым

Если результат нельзя отправить в сторис, современный продукт считается недособранным. Поэтому Stoned ID можно сохранить как 9:16 изображение. Технически это делается через скрытый view и react-native-view-shot:

  • на экране показывается интерактивная карточка;

  • отдельно рендерится story-версия 1080x1920;

  • captureRef снимает PNG;

  • файл сохраняется в галерею;

  • открывается системный share sheet.

Самая неприятная часть оказалась не в captureRef, а в визуальных слоях. На Android красивые прозрачности, тени, SVG, blur-like эффекты и голографические текстуры очень быстро превращаются в прямоугольники, если не следить за границами, overflow и тем, что именно умеет конкретный renderer.

В какой-то момент карточка выглядела как дорогой fintech-pass, поверх которого кто-то положил полупрозрачный Excel. Пришлось делать отдельную PNG-текстуру фольги и накладывать её как мягкий растянутый слой, а не пытаться собрать весь эффект из десятка полупрозрачных View.

UI: дорогая wellness-апка, которая просит вас не вставать

С визуальным стилем быстро стало понятно: если сделать приложение про лень просто мрачным, шутка будет слишком прямой. Гораздо смешнее, когда оно выглядит как дорогой wellness-продукт, который случайно перепутал цель и начал поощрять отсутствие движения.

Поэтому базовая версия родилась светлой и почти стерильной:

  • тёплый фон;

  • мягкие карточки;

  • аккуратная типографика;

  • большое кольцо активности;

  • premium dock;

  • голографическая membership card.

Снаружи это почти приложение для осознанности, роста и контроля. Внутри пассивно-агрессивный защитник дивана. Именно на этом контрасте держится продукт. Визуально он обещает заботу, дисциплину и дорогую подписку. Текстом и механиками защищает право пользователя быть предметом интерьера.

Когда интерфейс спокойно говорит:

“Не делайте резких движений. Они пугают приложение”

Что сломалось по дороге

Почти всё.

Браузерный предпросмотр не равен Android.

В браузере экран может выглядеть нормально, а на телефоне внезапно появляются белые прямоугольники, safe area исчезает, dock наезжает на контент, SVG ведёт себя как самостоятельная личность.

Expo Pedometer не спасает от Android-реальности.

Пришлось добавлять native step service и fallback через акселерометр.

Фоновые задачи не гарантируют время.

Их нужно воспринимать как best effort, а не как расписание электричек.

Пуш должен нести контекст.

Если локальное уведомление открывает экран, но не передаёт время события и дедлайн, пользователь попадает в пустое состояние. Для чек-апа это критично: окно ответа должно восстанавливаться из самого уведомления.

Красивый glassmorphism на Android может стать прямоугольником.

Это не фигура речи. Реально прямоугольником.

Смешной продукт не освобождает от тестов.

Наоборот, чем абсурднее copywriting, тем строже должна быть логика. Иначе пользователь перестаёт смеяться и начинает дебажить вашу шутку.

Что бы я сделал иначе

Если бы я начинал заново, я бы раньше признал, что мобильная разработка проверяется не в браузерном предпросмотре.

Я бы сразу тестировал ключевые сценарии на standalone Android build:

  • установка приложения;

  • перезапуск телефона;

  • смена дня;

  • выдача разрешений;

  • открытие приложения из пуша;

  • работа без Metro и локального сервера;

  • поведение после долгого сна устройства.

Ещё я бы раньше вынес работу с датчиками в отдельный слой и не завязывал бы интерфейс на оптимистичную веру в Pedometer.

Для pet-проекта очень соблазнительно сначала сделать красивые экраны, а потом “быстро прикрутить шаги”. Но шаги, фоновые задачи и уведомления - это не декор. Это инфраструктура продукта. Также я бы заранее завёл таблицу thresholds. Когда в приложении много токсичных порогов, ачивок, пушей и UI-подсказок, легко получить ситуацию, где один экран говорит одно, сервис считает второе, а уведомление отправляет третье. Для приложения, которое издевается над пользователем, это особенно опасно.

Издеваться надо консистентно.

Ограничения

Stoned не пытается быть медицинским приложением, настоящим фитнес-трекером или универсальным background-tracking решением.

Ограничения честные:

  • поведение датчиков зависит от устройства;

  • estimated steps приблизительные;

  • Android foreground service увеличивает надёжность, но требует аккуратности с батареей;

  • визуальные эффекты нужно проверять на реальном телефоне, а не только в браузерном предпросмотре;

  • поведение установленного приложения отличается от режима разработки;

  • финальную проверку нужно делать именно на собранной Android-версии.

Особенно последнее.

Мобильное приложение можно считать проверенным только тогда, когда оно живёт на телефоне самостоятельно, без локального сервера и открытого терминала рядом. Если рядом всё ещё лежит ноутбук с Metro, это не production.

Это родительский контроль.

Что дальше

Дальше всё зависит от того, залетит проект или нет.

Может быть, я переведу приложение на английский и другие языки. А может быть, оставлю его локальным русскоязычным артефактом, потому что некоторые шутки слишком хорошо лежат именно в родной тревожной интонации.

Если появится живой интерес, хочется добавить лидерборд лени.

Не в духе “кто больше сделал”, а наоборот: кто убедительнее доказал, что сегодня не собирается спасать мир.

Ещё просится расширенный трекер активности: не просто шаги за сегодня, а история по дням, где можно увидеть, в какой день вы двигались меньше всего и насколько уверенно превращались в предмет интерьера.

Отдельная ветка - больше ачивок. Скрытых, абсурдных, токсичных, коллекционных. Чтобы пользователь не просто лежал, а лежал с ощущением прогресса.

И, конечно, тактильный отклик.

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

P.S. про тестирование

У меня довольно слабый ноутбук, и эмулятор устройств в Android Studio на нём скорее не работает, а героически пыхтит. Поэтому живое тестирование Stoned пока проходило только на моём Nothing Phone 1.

Если у вас приложение запустится на другом Android-устройстве, буду рад обратной связи в комментариях: как ведут себя шагомер, фоновые пуши, чек-ап неподвижности и вся эта сомнительная инженерия покоя.

Итог

Последние годы приложения пытались сделать нас быстрее, здоровее, продуктивнее и осознаннее. Мы добавляли кольца активности, streaks, reminders, nudges, goals, персональные планы, AI-коучей, подписки и тревожные графики собственного улучшения.

А потом пришёл Stoned и показал кнопку:

“Я живой, но мне лень”.

Иногда pet-проекты нужны не для пользы, а для вентиляции инженерной психики.

Stoned смешной, местами бессмысленный, но внутри у него вполне настоящая архитектура: локальный state, датчики, background tasks, native Android service, local notifications, achievement engine, story export, тесты и production-грабли.

И в этом есть своя красота.

Если wellness-индустрия всё равно продаёт нам тревогу под видом заботы - пусть хотя бы одно приложение честно скажет:

Не спеши. Вселенная сама как-нибудь докатится.

Демо простите меня грешного за рустор

https://www.rustore.ru/catalog/app/com.stoned.mono извините ребята с яблоками, но сегодня не ваш день