Сидел когда-нибудь в такси, смотрел, как тикают красные цифры, и думал, сколько бы та же поездка стоила в Мадриде? Или в нью-йоркском жёлтом кэбе? Я да, поэтому сделал таксометр.
Fare Enough превращает айфон в таксометр, просто так, для души. Выбираешь опубликованный тариф настоящего города, едешь, и приложение показывает, сколько бы стоила поездка там: расстояние по GPS, ожидание, ночной тариф, доплаты вроде багажа. В конце получаешь чек.
А зачем вообще таксометр
Где-то лежит VHS-кассета (помните такие, дети? 📼), на которой трёх- или четырёхлетнего меня в детском саду спрашивают, кем я хочу стать, когда вырасту. Ответ без секунды сомнений: таксистом. У жизни были другие планы, и пришлось довольствоваться айти, но водить я до сих пор обожаю.
Вторая половина истории: LARP — моё второе имя. Принтер чеков у меня появился раньше, чем машина. До сих пор я в основном отыгрывал голландца, а теперь в этот отыгрыш можно вшить и мечту из детского сада. Четырёхлетний я был бы очень горд. Остальным не обязательно.
Что внутри
Это просто приложение, но вот что оно умеет:
- Тарифы 19 городов в 5 странах: Мадрид, Барселона, Берлин, Мюнхен, Варшава, Краков, Нью-Йорк, Чикаго, Лондон, Эдинбург и ещё несколько, каждый списан с опубликованных городских расценок. Ночной и праздничный тарифы включаются сами.
- Поверни телефон боком, и это старый добрый таксометр со светящимися цифрами и увесистыми кнопками.
- Чек в конце поездки: PDF, картинка или бумажка из крошечного Bluetooth-принтера.
- Стоимость поездки на экране блокировки, пока едешь. CarPlay готов и ждёт разрешения от Apple.
- Скидки для друзей: не будешь же ты выставлять другу полную цену, даже понарошку.
- Бесплатно, без рекламы, без аккаунта и без аналитики. С телефона ничего не уходит, кроме запросов к Apple Maps.
Пока я это пишу, приложение ждёт проверки в App Store (под именем Fare Enough Meter: «Fare Enough» уже было занято). В день выхода ссылка появится на fare.slnt-opp.xyz.


А вот лицевая панель счётчика, можно понажимать: веб-копия, нарисованная по той же геометрии, что и в приложении. Жми «Старт».
Нажми «Старт»
А теперь самое интересное: три вещи, над которыми пришлось больше всего поломать голову.
Skip и счётчик, которому предстоит пережить Android
Сейчас приложение на SwiftUI. Android будет позже, через Skip: в режиме Fuse он компилирует Swift нативно под Android и рисует SwiftUI через Jetpack Compose. План такой: порт добавит горстку файлов и ничего не сдвинет. Поэтому приложение с первого коммита пишется с прицелом на это: каждый вызов Apple SDK живёт в отдельном платформенном файле за протоколом, а ядро (тарифы, арифметика счётчика, чеки) не импортирует ничего, кроме Foundation.
Насколько сложно сделать «игровой» интерфейс по таким правилам? Рисование оказалось самой простой частью. В Skip Shape и Path и есть тот API для рисования, который переезжает на Android. Никакого Canvas, никакого TimelineView, никакого ImageRenderer в общем коде, и ничто, что несёт смысл, не может зависеть от режимов наложения или 3D-поворотов. Поэтому каждая цифра — это геометрия: шестиугольные сегменты, посчитанные от высоты цифры, а курсивный наклон встроен прямо в отображение точек, а не накладывается трансформацией потом.
// A point in one cell, slant included: x' = x + (h − y)·tan 8°
func point(cell: Int, x: Double, y: Double) -> (x: Double, y: Double) {
let left = originX + Double(cell) * style.pitch * height
return (left + x + (height - y) * style.slant, originY + y)
}
// A horizontal bar: a mitred hexagon between two x's.
func horizontal(_ y: Double) -> [(x: Double, y: Double)] {
let x0 = left + g, x1 = right - g
return [pt(x0, y), pt(x0 + half, y - half),
pt(x1 - half, y - half), pt(x1, y),
pt(x1 - half, y + half), pt(x0 + half, y + half)]
}
Свечение — это четыре слоя одного и того же пути, как у настоящего светодиода: «призрак» (все сегменты, едва заметные, тот самый вид «88.88»), ореол (горящий путь, размытый), тело и тонкая «горячая сердцевина» сверху. Каждый слой — один Path на весь дисплей, то есть одна заливка и одно размытие, сколько бы цифр там ни было. Размытие только украшает: если однажды Compose нарисует его проще, счётчик потеряет блеск, но не информацию.
ZStack {
SegmentGlyphs(cells: ghost, frame: ghost, style: style) // ghost
.fill(Color(hex: skin.ghost, opacity: skin.ghostOpacity))
SegmentGlyphs(cells: lit, frame: ghost, style: style) // bloom
.fill(Color(hex: glow))
.blur(radius: barThickness * skin.bloomRadius)
.opacity(skin.bloomOpacity)
SegmentGlyphs(cells: lit, frame: ghost, style: style) // body
.fill(Color(hex: skin.lit))
SegmentGlyphs(cells: lit, frame: ghost, style: style,
insetRatio: 0.28) // hot core
.fill(Color(hex: hot))
.opacity(0.35)
}
А дальше скин — это просто цвета на той же геометрии: красный светодиод, янтарный, зелёный, голубой VFD и серый LCD (плюс винтажный с крутящимися барабанами).





Мигающее двоеточие и тест ламп при включении крутятся в маленьком цикле на Task с частотой 2 Гц и только пока счётчик на экране, потому что TimelineView в Skip нет. С кнопками тоже пришлось схитрить: своего ButtonStyle в Skip нет (есть только PrimitiveButtonStyle), поэтому вид нажатой кнопки живёт в одной прослойке совместимости с отдельной веткой под Android:
func meterKeyStyle() -> some View {
#if os(Android)
self.buttonStyle(.plain)
#else
// Pressed: brightness −0.14 and 1 pt down.
self.buttonStyle(MeterKeyPressStyle())
#endif
}
Сложное — это всё, что прекрасно компилируется под iOS и ломается только на Android, через несколько недель. Text("Fare") ещё раз ищет строку в переводах на языке устройства, так что в одной строке интерфейса могут смешаться два языка: писать нужно Text(verbatim: loc("Fare")). didSet у свойства @Observable в приложении никогда не срабатывает (а в юнит-тестах срабатывает, так оно и прячется). ForEach по чему угодно, кроме Array, роняет приложение. Иконка под Android есть только у 73 SF Symbols, любое другое имя молча ничего не рисует. .foregroundStyle(.tertiary) — это краш. Компилятор ничего из этого не ловит, поэтому каждое правило — это тест, который читает исходники на Swift как текст и валит сборку, заодно объясняя, как починить. Правило, которое живёт только в документации, протягивает недель шесть.
Справедливости ради: сборки под Android пока нет. Весь этот раздел — ставка на то, что когда она появится, будет скучно.
Фоновая геолокация, или как поездка превратилась в ожидание
Счётчик должен продолжать мерить, пока телефон заблокирован и висит в держателе, и при этом просить как можно меньше: только разрешение «При использовании», никогда «Всегда». Это работает, но только когда всё на своих местах:
// A car on roads (.otherNavigation is boats and trains).
manager.activityType = .automotiveNavigation
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.distanceFilter = kCLDistanceFilterNone
// true = iOS stops a meter waiting in traffic, for good.
manager.pausesLocationUpdatesAutomatically = false
// Read from the *built* Info.plist: without the mode, this line kills the app.
if Self.hasLocationBackgroundMode {
manager.allowsBackgroundLocationUpdates = true
}
manager.showsBackgroundLocationIndicator = true
// iOS 17+: held while the meter runs.
backgroundSession = CLBackgroundActivitySession()
manager.startUpdatingLocation()
Грабли, в том порядке, в каком я на них наступал:
- «Режим автомобиля» — это
activityType = .automotiveNavigation. Так Core Location понимает, что это машина на дорогах, и это меняет то, как iOS фильтрует точки и когда ставит их на паузу. pausesLocationUpdatesAutomaticallyдолжно бытьfalse. Иначе iOS решает, что машина, стоящая на светофоре, приехала, и ставит обновления на паузу, а приложение в фоне не получит их обратно, пока его не откроют. А ожидание — это ровно то время, когда таксометр зарабатывает.- Фоновый режим должен быть в самом файле Info.plist.
INFOPLIST_KEY_UIBackgroundModesв настройках сборки молча не доходит до собранного приложения, аallowsBackgroundLocationUpdates = trueбез этого режима не бросает ошибку: оно убивает приложение. Поэтому приложение сначала проверяет свой собственный собранный бандл, а скрипт проверяет собранное приложение перед каждым релизом. - Первая точка — часто вчерашние новости. Сразу после
startUpdatingLocation()iOS отдаёт закешированную позицию, а это может быть место прошлой высадки за сотни метров. Точки старше 30 секунд выбрасываются.
Потом был настоящий полевой тест. Поездка по Магдебургу вернулась как 13 мин 39 с ожидания и ни строчки про расстояние, хотя 1,15 км было намеряно. Телефон отдавал точки с опозданием, на несколько секунд или пачками, пока был заблокирован, а часы счётчика тарифицировали каждую секунду по мере её прохождения, по скорости последней известной точки: ноль. Каждая секунда выглядела как стоянка.
Решение: пусть точки тарифицируют себя сами. Пока они продолжают приходить, часы не вмешиваются:
public mutating func tick(at date: Date) {
guard state.status == .hired else { return }
// Fixes are still coming, however late: each one bills
// its own seconds, at its own speed, when it lands.
if let arrived = state.lastFixArrival, state.gps.lastFix != nil,
date.timeIntervalSince(arrived) <= state.gps.gapSeconds {
return
}
// The GPS has been silent for 10 s: now the clock bills waiting.
advance(to: date)
}
Опоздавшая точка пересчитывает поездку от состояния счётчика на момент предыдущей точки, и сумма на экране при этом никогда не уменьшается. Маршруты в симуляторе нужно прогонять в 1×: iOS ставит на точку время её прихода, так что ускоренный прогон — это более быстрая поездка, а не та же поездка, только раньше. А на странице для разработчиков теперь есть статистика того, как телефон доставлял точки (сколько пришло в фоне, с каким опозданием, самая долгая тишина), потому что лог, который можно прочитать на самом телефоне, лучше гаданий.
Принтер чеков по цене пиццы
Phomemo M110 — карманный принтер этикеток: 203 dpi, печатающая головка на 384 точки (48 мм), Bluetooth LE. Последовательность байтов взята из того, что отправляют два опенсорсных драйвера (pyphomemo и phomymo, спасибо обоим), и из кучи напечатанных тестовых страниц.
Первые грабли — найти его. Большинство M110 не рекламируют ничего, кроме серийного номера вроде Q119G18Q0830029: Q, три цифры, буква, две цифры, буква, семь цифр. Сервисы принтера, FF00 и компания, — обычные последовательные сервисы, которыми пользуются и колонки, и лампы, и трекеры. Однажды по ним в список принтеров попала Bluetooth-колонка соседа под видом M110. Поэтому приложение узнаёт принтеры только по имени.
Вторые грабли — тайминги. Если отправить задание одним потоком, принтер моргнёт лампочкой и всё выбросит. Ему нужны настройки отдельными записями с паузами, один растровый заголовок с полной высотой, строки маленькими порциями и завершение после передышки:
static func steps(for bitmap: ThermalBitmap,
settings: PrintSettings) -> [PrintStep] {
// 1 bit per dot, 48 bytes a row; the height field is 16-bit.
let rows = min(bitmap.height, 65_535)
let raster = bitmap.rows.prefix(bitmap.widthBytes * rows)
return [
.write(speed(settings.speed)), .wait(0.03), // 1B 4E 0D n
.write(density(settings.darkness.density)), // 1B 4E 04 n
.wait(0.03),
.write(media(settings.media)), .wait(0.03), // 1F 11 n
// GS v 0 with the full height, then the rows:
// 128-byte writes, 20 ms apart.
.write(rasterHeader(widthBytes: bitmap.widthBytes, rows: rows)),
.stream(Data(raster), chunk: 128, pause: 0.02),
.wait(0.30),
.write(footer), // 1F F0 05 00 1F F0 03 00
]
}
Третьи грабли — пиксели. Чек — это список операций рисования, и одно SwiftUI-представление рисует их для экрана, PDF, картинки и принтера. Для принтера чек рендерится ровно один пиксель на точку, 384 в ширину: картинку в 385 пикселей родное приложение принтера масштабирует, и от масштабирования размывается каждая буква. Шрифт для головки делается плотнее (чистый чёрный, никаких волосяных линий), а у карты маршрута своя обработка. Карта Apple превращается в «основу для печати»: цвет считается бумагой, выживает только то, что темнее своего окружения (улицы и их названия), а кварталы, парки и вода становятся белыми. Потом карта масштабируется до финального размера, и только тогда её дизерят. Дизеринг — на финальном размере, никогда до масштабирования: сдизеренная картинка, отмасштабированная ещё раз, превращается в серую кашу.

Предпросмотр в приложении — ровно этот битмап, точка в точку. В симуляторе iOS нет Bluetooth, поэтому код принтера ещё и собирается в маленькую утилиту командной строки для Mac, с тем же самым файлом CoreBluetooth. И последний сюрприз: macOS убивает простой бинарник, который лезет в Bluetooth без собственной строки с объяснением, так что утилита живёт внутри минимальной обёртки .app.
Бонус: Live Activity, которая была шире экрана
На экране блокировки айфона карточка поездки была обрезана слева и с пустым отступом справа. На Apple Watch при этом всё выглядело идеально. Виноваты были часы поездки: Text(timerInterval:), считающий до Date.distantFuture, занимает место под самое длинное время, которое он когда-либо сможет показать, а в строке, которая держала свою идеальную ширину, он выталкивал всю карточку за край экрана.
Решение: считать до суток (система всё равно завершает Live Activity через 8 часов) и держать ширину «0:00:00» скрытой заглушкой, а таймер рисовать поверх.
let aDay = start.addingTimeInterval(24 * 3600)
Text(verbatim: "0:00:00") // the width the clock may take
.hidden()
.overlay(alignment: .leading) {
Text(timerInterval: start...aDay, countsDown: false)
}
.monospacedDigit()
На Mac это не воспроизводилось, потому что macOS размечает таймер по времени, которое он показывает прямо сейчас. Так что UI-тест фотографирует карточку на экране блокировки симулятора.
Вот и всё
Fare Enough говорит на английском, испанском, немецком и польском и знает тарифы Испании, Германии, Польши, США и Великобритании. Бесплатно, без рекламы и без аккаунта. Четырёхлетний я с той VHS-кассеты назвал бы это неплохим началом.
- Сайт: fare.slnt-opp.xyz
- Идеи, баги, тариф твоего города: info@slnt-opp.xyz
Если прокатишься с ним куда-нибудь интересно, пришли мне чек. Напечатанные считаются вдвойне.