Реальные истории:Как тренер футбольной школы создал приложения за 1 неделю скоро

Экраны и навигация

Шаг 5 UI-трека. До сих пор трекер был одним экраном с screen.open(). Сегодня он становится приложением: вкладки «Сегодня» и «История» внизу, экран привычки поверх списка, кнопка «назад» — экранная и аппаратная. Три инструмента: Router для стека экранов, onOpen / onClose для жизненного цикла и UITabs — готовая оболочка с вкладками.

Стек экранов — Router

Router держит стек экранов. Router.init(home) вместо home.open() — и дальше Router.push(screen) кладёт экран сверху, Router.pop() снимает. Экраны под верхним остаются в памяти со всем своим состоянием — они просто не рисуются; снятый со стека экран уничтожается.

Экран с данными — это функция-фабрика: HabitScreen(h) строит новый экран для конкретной привычки в момент перехода:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })

type Habit = { title: string; time: string; streak: number }
const habits: Habit[] = [
  { title: "Утренняя пробежка", time: "07:30", streak: 12 },
  { title: "Прочитать 20 страниц", time: "13:00", streak: 5 },
  { title: "Растяжка", time: "20:30", streak: 31 },
]

const BackButton = () =>
  UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
    .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
    .onClick(() => Router.pop())

const HabitScreen = (h: Habit) =>
  UIScreen(
    BackButton(),
    UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText(`Каждый день в ${h.time}`).style({ color: colors.muted, fontSize: 15 }),
    UIColumn(
      UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }),
      UIText("дней подряд").style({ color: colors.muted, fontSize: 13 }),
    ).style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onBackPressed(() => Router.pop())          // аппаратная «назад» на Android

const HabitRow = (h: Habit) =>
  UIButton(
    UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText("›").style({ color: colors.muted, fontSize: 22 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
    .onClick(() => Router.push(HabitScreen(h)))

const home = UIScreen(
  UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(habits.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

Router.init(home)
  • Router.init(home) вызывается один раз в точке входа. Router.push / Router.pop — глобальны: их можно звать из любого файла, ссылку на роутер прокидывать не нужно.
  • pop() по умолчанию снимает один экран; pop(-2) — два, pop(0) — до самого первого. Снятые экраны уничтожаются, но объект UIScreen остаётся рабочим — его можно запушить снова.
  • Router.replace(screen) меняет верхний экран без роста стека — так делают «войти → главная». Переход настраивается: theme({ replaceTransition: "fade" }) или опцией вызова.
  • Кнопка «назад» на Android — screen.onBackPressed(cb). Цепочка разбора нажатия: открытый виджет → текущий экран → вкладка с глубиной → роутер → глобальный app.onBackPressed. Если никто не обработал — нажатие игнорируется, приложение не закрывается.
  • «Назад» на экране — обычная кнопка. Системную можно включить: Router.init(home, { showDefaultBackButton: true }).

Жизненный цикл экрана

У экрана два события: onOpen — стал активным, onClose — перестал. Они срабатывают при каждой активации, не только при первой: push поверх вызывает onClose нижнего экрана, pop обратно — снова его onOpen. Правило одно и без исключений: всё, что запущено в onOpen, останавливается в onClose — циклы, интервалы, сокеты, — иначе оно продолжит работать за спиной у следующего экрана.

Экран привычки с таймером фокуса — минута тикает только пока он открыт:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })

const TimerScreen = (title: string, seconds: number) => {
  const left = signal(seconds)
  let timer: number | null = null

  return UIScreen(
    UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
      .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
      .onClick(() => Router.pop()),
    UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText(() => `${left.value} с`).style({ fontSize: 64, fontWeight: 800, mt: 24 }),
    UIText(() => (left.value > 0 ? "Таймер идёт, пока экран открыт" : "Готово!"))
      .style({ color: colors.muted, fontSize: 15 }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onOpen(() => {
      timer = setInterval(() => { if (left.value > 0) left.value-- }, 1000)
    })
    .onClose(() => {
      if (timer !== null) { clearInterval(timer); timer = null }
    })
    .onBackPressed(() => Router.pop())
}

const Row = (title: string, seconds: number) =>
  UIButton(
    UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText(`${seconds} с`).style({ color: colors.muted, fontSize: 13 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
    .onClick(() => Router.push(TimerScreen(title, seconds)))

const home = UIScreen(
  UIText("Фокус").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(Row("Растяжка", 60), Row("Медитация", 120), Row("Планка", 45)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

Router.init(home)

Открой таймер, подожди пару секунд, вернись и открой снова — он стартует заново с новым экраном, а старый интервал остановлен в onClose. Обрати внимание, что left и timer объявлены внутри фабрики: у каждого открытого экрана своё состояние, и оно умирает вместе с ним.

Тот же паттерн — для setLoop в анимациях, setInterval для опроса сервера, WebSocket в чате. Полные правила — в соглашениях SDK.

Вкладки — UITabs

Нижняя панель вкладок — самая частая оболочка мобильного приложения, и в LeCodes она готовая: UITabs — это UIScreen из UIPager (вкладки, свайпающиеся нативно, у каждой свой стек) и таб-бара, который красится через переменные темы. Ключи объекта — id вкладок, в порядке следования:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })

const HabitScreen = (title: string) =>
  UIScreen(
    UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
      .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
      .onClick(() => UIPager.pop()),
    UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText("Экран открыт внутри вкладки — таб-бар остался").style({ color: colors.muted, fontSize: 15 }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onBackPressed(() => UIPager.pop())

const Row = (title: string) =>
  UIButton(UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }), UIText("›").style({ color: colors.muted, fontSize: 22 }))
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
    .onClick(() => UIPager.push(HabitScreen(title)))     // push ВНУТРИ текущей вкладки

const today = UIScreen(
  UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(Row("Утренняя пробежка"), Row("Прочитать 20 страниц"), Row("Растяжка")).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

const history = UIScreen(
  UIText("История").style({ fontSize: 32, fontWeight: 800 }),
  UIText("Здесь будет список отметок").style({ color: colors.muted, fontSize: 15 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })

const tabs = UITabs({
  today:   { label: "Сегодня", icon: assetIcon("lucide:circle-check"), screen: today },
  history: { label: "История", icon: assetIcon("lucide:calendar"),     screen: history },
})
tabs.badge("history", 3)

Router.init(tabs)

Нажми на привычку — экран откроется внутри вкладки, таб-бар останется на месте; свайп между вкладками работает у корня каждой вкладки. Что стоит запомнить:

  • Два глагола навигации. UIPager.push(screen) — открыть экран в текущей вкладке (бар виден, «назад» жестом с края экрана — нативный). Router.push(screen) — открыть поверх всего, включая бар: авторизация, полноэкранный плеер. Оба глобальны — из любого экрана, без ссылок.
  • Бар красится темой: primaryColor — активная вкладка, mutedColor — неактивные, tabbarBg / tabbarBorder — поверхность и волосяная линия. У неокрашенного приложения тёмные дефолты.
  • assetIcon("lucide:calendar") — макрос компиляции: иконка из реестра инлайнится в бандл на этапе сборки, id — строковый литерал.
  • tabs.select("history"), tabs.tab, tabs.onSelect((id) => …), tabs.badge(id, 3 | true | 0) — программное управление. Каждая вкладка помнит свой стек: ушёл со вкладки на глубине три — вернулся ровно туда же.
  • Нужен свой бар — опускайся на уровень ниже: UIPager(a, b) плюс собственный UIRow кнопок; pager.onSelect синхронизирует подсветку при свайпе.

Как это раскладывается по файлам

Один файл на экран, точка входа собирает оболочку — это стандартная форма приложения LeCodes:

TypeScript
// screens/today.ts
import { colors } from "../theme"
export const todayScreen = UIScreen(/* … */).style({ bgColor: colors.bg })

// screens/habit.ts
export const HabitScreen = (h: Habit) => UIScreen(/* … */).onBackPressed(() => UIPager.pop())

// main.ts
import { todayScreen } from "./screens/today"
import { historyScreen } from "./screens/history"

Router.init(UITabs({
  today:   { label: "Сегодня", icon: assetIcon("lucide:circle-check"), screen: todayScreen },
  history: { label: "История", icon: assetIcon("lucide:calendar"),     screen: historyScreen },
}))

Экран, который открывается с данными, — экспортируемая фабрика; экран-вкладка — готовый объект. Ни один из них не импортирует роутер: Router и UIPager — глобалы.

Приложение целиком

Трекер с двумя вкладками, экраном привычки и живым бейджем — сколько привычек осталось на сегодня:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })
const type = {
  h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
  body: { fontSize: 16, fontWeight: 600 } as Style<UIText>,
  small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}

type Habit = { id: number; title: string; time: string; color: string; done: boolean; streak: number }
const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", time: "07:30", color: "#34c759", done: true, streak: 12 },
  { id: 2, title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a", done: true, streak: 5 },
  { id: 3, title: "Позаниматься на пианино", time: "18:00", color: "#5e9eff", done: false, streak: 2 },
  { id: 4, title: "Растяжка", time: "20:30", color: "#af6bff", done: false, streak: 31 },
])
const leftCount = computed(() => habits.value.filter((h) => !h.done).length)
const toggle = (h: Habit) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}

const BackButton = () =>
  UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
    .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
    .onClick(() => UIPager.pop())

const HabitScreen = (h: Habit) =>
  UIScreen(
    BackButton(),
    UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText(`Каждый день в ${h.time}`).style({ ...type.small, fontSize: 15 }),
    UIColumn(UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }), UIText("дней подряд").style(type.small))
      .style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
    UIButton(UIText(() => (habits.value.find((x) => x.id === h.id)?.done ? "Снять отметку" : "Отметить")).style({ ...type.body, color: "#ffffff" }))
      .style({ bgColor: h.color, borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
      .onClick(() => { const cur = habits.value.find((x) => x.id === h.id); if (cur) toggle(cur) }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onBackPressed(() => UIPager.pop())

const HabitRow = (h: Habit) =>
  UIButton(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color, opacity: h.done ? 1 : 0.35 }),
    UIColumn(UIText(h.title).style(type.body), UIText(h.done ? "готово" : h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
    UIText("›").style({ color: colors.muted, fontSize: 22 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { opacity: 0.6 } })
    .onClick(() => UIPager.push(HabitScreen(h)))

const today = UIScreen(
  UIText("Сегодня").style(type.h1),
  UIText(() => `Осталось ${leftCount.value} из ${habits.value.length}`).style({ ...type.small, fontSize: 15 }),
  UIScrollable(() => habits.value.map(HabitRow)).style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

const history = UIScreen(
  UIText("История").style(type.h1),
  UIColumn(
    [0, 1, 2, 3, 4, 5, 6].map((d) =>
      UIRow(
        UIText(date().subtract(d, "day").format("dddd, D MMMM")).style({ ...type.body, flexGrow: 1 }),
        UIText(`${4 - (d % 3)} / 4`).style(type.small),
      ).style({ bgColor: colors.card, borderRadius: 16, p: 14 }),
    ),
  ).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

const tabs = UITabs({
  today:   { label: "Сегодня", icon: assetIcon("lucide:circle-check"), screen: today },
  history: { label: "История", icon: assetIcon("lucide:calendar"),     screen: history },
})
effect(() => tabs.badge("today", leftCount.value))     // бейдж следует за состоянием

Router.init(tabs)

Две детали, которые легко пропустить. Во-первых, effect — первый раз в треке: бейдж на вкладке — не привязка внутри элемента, а вызов метода, поэтому его нужно повторять руками при каждой смене состояния — ровно то, что делает effect. Во-вторых, экран привычки получает снимок h в момент открытия, поэтому кнопка «Отметить» ищет актуальную запись по id в сигнале — так экран не устаревает, если привычку отметили из списка.

Что ты узнал

  • Router.init один раз в точке входа; Router.push / pop / replace — глобальны. Экраны в стеке живут со своим состоянием, снятые — уничтожаются.
  • Экран с данными — функция-фабрика; его состояние объявляется внутри неё.
  • onOpen / onClose срабатывают при каждой активации; всё запущенное в onOpen останавливается в onClose.
  • UITabs — готовая оболочка: вкладки со своими стеками, бар из переменных темы, badge. UIPager.push — внутри вкладки, Router.push — поверх всего.
  • onBackPressed на экране — для аппаратной «назад»; assetIcon("pack:name") — иконки из реестра на этапе сборки.

Справочник: Экраны и роутер · UIPager и UITabs · Соглашения — жизненный цикл · Контент: иконки.

Дальше: формы и клавиатура — экран «Новая привычка»: поля ввода, валидация, цепочка фокуса и всё, что нужно знать о клавиатуре.